feat: secure KYC storage, Google OAuth, terms gating
This commit is contained in:
@@ -9,13 +9,18 @@ type Initial = {
|
||||
nik: string;
|
||||
birthDate: Date;
|
||||
address: string;
|
||||
ktpImageUrl: string;
|
||||
selfieUrl: string;
|
||||
ktpImageKey: string;
|
||||
selfieKey: string;
|
||||
bankName: string;
|
||||
bankAccountNumber: string;
|
||||
bankAccountName: string;
|
||||
} | null;
|
||||
|
||||
type UploadKind = "ktp" | "selfie";
|
||||
|
||||
const ACCEPT_MIME = "image/jpeg,image/png,image/webp";
|
||||
const MAX_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
function toYmd(d: Date): string {
|
||||
const y = d.getUTCFullYear();
|
||||
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
|
||||
@@ -27,12 +32,20 @@ export function VerifyForm({ initial }: { initial: Initial }) {
|
||||
const router = useRouter();
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ktpKey, setKtpKey] = useState(initial?.ktpImageKey ?? "");
|
||||
const [selfieKey, setSelfieKey] = useState(initial?.selfieKey ?? "");
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
if (!ktpKey || !selfieKey) {
|
||||
setError("Foto KTP dan selfie wajib diunggah");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
const formData = new FormData(e.currentTarget);
|
||||
formData.set("ktpImageKey", ktpKey);
|
||||
formData.set("selfieKey", selfieKey);
|
||||
const result = await submitVerificationAction(formData);
|
||||
setLoading(false);
|
||||
if (result.error) {
|
||||
@@ -119,36 +132,24 @@ export function VerifyForm({ initial }: { initial: Initial }) {
|
||||
<section>
|
||||
<h2 className="mb-3 text-base font-bold text-neutral-900">🖼️ Foto</h2>
|
||||
<p className="mb-3 text-xs text-neutral-500">
|
||||
Upload foto ke hosting (imgur, imgbb, dll) lalu paste URL-nya. Foto akan
|
||||
dilihat tim admin saat review.
|
||||
Foto disimpan terenkripsi di server SeTrip dan hanya bisa dilihat oleh
|
||||
tim admin saat review. Maks 5MB, JPG/PNG/WebP.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
||||
URL Foto KTP
|
||||
</label>
|
||||
<input
|
||||
name="ktpImageUrl"
|
||||
type="url"
|
||||
required
|
||||
defaultValue={initial?.ktpImageUrl ?? ""}
|
||||
className={inputCls}
|
||||
placeholder="https://i.imgur.com/xxxx.jpg"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
||||
URL Selfie dengan KTP
|
||||
</label>
|
||||
<input
|
||||
name="selfieUrl"
|
||||
type="url"
|
||||
required
|
||||
defaultValue={initial?.selfieUrl ?? ""}
|
||||
className={inputCls}
|
||||
placeholder="https://i.imgur.com/yyyy.jpg"
|
||||
/>
|
||||
</div>
|
||||
<FileUpload
|
||||
label="Foto KTP"
|
||||
kind="ktp"
|
||||
value={ktpKey}
|
||||
onChange={setKtpKey}
|
||||
onError={setError}
|
||||
/>
|
||||
<FileUpload
|
||||
label="Selfie dengan KTP"
|
||||
kind="selfie"
|
||||
value={selfieKey}
|
||||
onChange={setSelfieKey}
|
||||
onError={setError}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -213,3 +214,91 @@ export function VerifyForm({ initial }: { initial: Initial }) {
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function FileUpload({
|
||||
label,
|
||||
kind,
|
||||
value,
|
||||
onChange,
|
||||
onError,
|
||||
}: {
|
||||
label: string;
|
||||
kind: UploadKind;
|
||||
value: string;
|
||||
onChange: (key: string) => void;
|
||||
onError: (msg: string) => void;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [previewUrl, setPreviewUrl] = useState<string>("");
|
||||
|
||||
async function onPick(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
if (file.size > MAX_BYTES) {
|
||||
onError(`${label} maksimal 5MB`);
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
if (!ACCEPT_MIME.split(",").includes(file.type)) {
|
||||
onError(`${label} harus JPG, PNG, atau WebP`);
|
||||
e.target.value = "";
|
||||
return;
|
||||
}
|
||||
|
||||
setBusy(true);
|
||||
onError("");
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.set("kind", kind);
|
||||
fd.set("file", file);
|
||||
const res = await fetch("/api/upload/kyc", { method: "POST", body: fd });
|
||||
const json = await res.json();
|
||||
if (!res.ok) {
|
||||
onError(json.error ?? `Gagal mengunggah ${label}`);
|
||||
return;
|
||||
}
|
||||
onChange(json.key);
|
||||
const obj = URL.createObjectURL(file);
|
||||
setPreviewUrl((old) => {
|
||||
if (old) URL.revokeObjectURL(old);
|
||||
return obj;
|
||||
});
|
||||
} catch {
|
||||
onError(`Gagal mengunggah ${label}`);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
e.target.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-semibold text-neutral-700">
|
||||
{label}
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="inline-flex cursor-pointer items-center rounded-xl border border-neutral-200 bg-neutral-50 px-4 py-2 text-sm font-medium text-neutral-700 hover:bg-neutral-100">
|
||||
{busy ? "Mengunggah..." : value ? "Ganti file" : "Pilih file"}
|
||||
<input
|
||||
type="file"
|
||||
accept={ACCEPT_MIME}
|
||||
onChange={onPick}
|
||||
disabled={busy}
|
||||
className="sr-only"
|
||||
/>
|
||||
</label>
|
||||
{value && !busy && (
|
||||
<span className="text-xs text-neutral-500">✓ Terunggah</span>
|
||||
)}
|
||||
</div>
|
||||
{previewUrl && (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt={`${label} preview`}
|
||||
className="mt-2 max-h-40 rounded-lg border border-neutral-200"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user