feat: secure KYC storage, Google OAuth, terms gating
This commit is contained in:
@@ -1,7 +1,10 @@
|
||||
"use server";
|
||||
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
import { registerSchema } from "./schemas";
|
||||
import { authService } from "@/server/services/auth.service";
|
||||
import { userRepo } from "@/server/repositories/user.repo";
|
||||
|
||||
export async function registerAction(formData: FormData) {
|
||||
const raw = {
|
||||
@@ -29,3 +32,16 @@ export async function registerAction(formData: FormData) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function acceptTermsAction() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session?.user) {
|
||||
return { error: "Kamu harus login terlebih dahulu" };
|
||||
}
|
||||
try {
|
||||
await userRepo.markAcceptedTerms(session.user.id);
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
return { error: (err as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ export async function submitVerificationAction(formData: FormData) {
|
||||
nik: formData.get("nik") as string,
|
||||
birthDate: formData.get("birthDate") as string,
|
||||
address: formData.get("address") as string,
|
||||
ktpImageUrl: formData.get("ktpImageUrl") as string,
|
||||
selfieUrl: formData.get("selfieUrl") as string,
|
||||
ktpImageKey: formData.get("ktpImageKey") as string,
|
||||
selfieKey: formData.get("selfieKey") as string,
|
||||
bankName: formData.get("bankName") as string,
|
||||
bankAccountNumber: formData.get("bankAccountNumber") as string,
|
||||
bankAccountName: formData.get("bankAccountName") as string,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,18 +22,14 @@ export const submitVerificationSchema = z.object({
|
||||
.trim()
|
||||
.min(5, "Alamat minimal 5 karakter")
|
||||
.max(LIMITS.MAX_ADDRESS_LENGTH, `Alamat maksimal ${LIMITS.MAX_ADDRESS_LENGTH} karakter`),
|
||||
ktpImageUrl: z
|
||||
ktpImageKey: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Foto KTP wajib diisi")
|
||||
.max(LIMITS.MAX_URL_LENGTH, "URL foto KTP terlalu panjang")
|
||||
.pipe(z.url("URL foto KTP tidak valid")),
|
||||
selfieUrl: z
|
||||
.regex(/^ktp\/[A-Za-z0-9_-]+\.(jpg|png|webp)$/, "Foto KTP wajib diunggah"),
|
||||
selfieKey: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Foto selfie dengan KTP wajib diisi")
|
||||
.max(LIMITS.MAX_URL_LENGTH, "URL foto selfie terlalu panjang")
|
||||
.pipe(z.url("URL foto selfie tidak valid")),
|
||||
.regex(/^selfie\/[A-Za-z0-9_-]+\.(jpg|png|webp)$/, "Foto selfie wajib diunggah"),
|
||||
bankName: z
|
||||
.string()
|
||||
.trim()
|
||||
|
||||
Reference in New Issue
Block a user