feat: secure KYC storage, Google OAuth, terms gating

This commit is contained in:
arifal
2026-04-28 23:10:21 +07:00
parent 58da4608ac
commit 05d0929f7a
41 changed files with 3087 additions and 262 deletions
+16 -13
View File
@@ -2,17 +2,15 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import Image from "next/image";
import { reviewVerificationAction } from "@/features/organizer/actions";
type Verification = {
id: string;
fullName: string;
/** NIK plaintext, sudah di-decrypt di server sebelum sampai ke komponen ini. */
nik: string;
birthDate: Date;
address: string;
ktpImageUrl: string;
selfieUrl: string;
bankName: string;
bankAccountNumber: string;
bankAccountName: string;
@@ -90,8 +88,14 @@ export function ReviewCard({ verification }: { verification: Verification }) {
</div>
<div className="mt-5 grid gap-4 sm:grid-cols-2">
<ImagePreview label="Foto KTP" url={verification.ktpImageUrl} />
<ImagePreview label="Selfie + KTP" url={verification.selfieUrl} />
<ImagePreview
label="Foto KTP"
src={`/api/files/kyc/${verification.id}/ktp`}
/>
<ImagePreview
label="Selfie + KTP"
src={`/api/files/kyc/${verification.id}/selfie`}
/>
</div>
{verification.status === "REJECTED" && verification.rejectionReason && (
@@ -196,26 +200,25 @@ function Field({
);
}
function ImagePreview({ label, url }: { label: string; url: string }) {
function ImagePreview({ label, src }: { label: string; src: string }) {
return (
<div>
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-neutral-500">
{label}
</p>
<a
href={url}
href={src}
target="_blank"
rel="noopener noreferrer"
className="block overflow-hidden rounded-xl border border-neutral-200 bg-neutral-100"
>
<div className="relative aspect-[4/3] w-full">
<Image
src={url}
{/* Secure endpoint sends Cache-Control: private,no-store. Use plain <img> to skip Next/Image optimizer. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
alt={label}
fill
unoptimized
className="object-cover"
sizes="(min-width: 640px) 50vw, 100vw"
className="h-full w-full object-cover"
/>
</div>
</a>
+119 -30
View File
@@ -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>
);
}