feat(auth): implement KYC upload with presigned URLs and multi-step form
Backend: - GenerateKycUploadUrls command — presigned MinIO URLs (5-min expiry), MIME validation (JPEG/PNG/WebP), unique object keys per user - SubmitKyc command — stores document type, number, and image URLs in kycData JSON field, updates kycStatus to PENDING - POST /auth/kyc/upload-urls and POST /auth/kyc/submit endpoints Frontend: - 3-step KYC form: document info → image upload → review - Direct client-to-MinIO upload via presigned URLs with progress tracking - Status-aware UI (NONE/PENDING/VERIFIED/REJECTED) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,13 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select } from '@/components/ui/select';
|
||||
import { apiClient } from '@/lib/api-client';
|
||||
import { useAuthStore } from '@/lib/auth-store';
|
||||
|
||||
const KYC_STATUS_MAP: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline'; description: string }> = {
|
||||
@@ -30,12 +29,67 @@ const KYC_STEPS = [
|
||||
{ step: 3, title: 'Xác nhận', description: 'Kiểm tra và gửi hồ sơ' },
|
||||
];
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1';
|
||||
|
||||
function getCsrfToken(): string | undefined {
|
||||
const csrfMatch = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]*)/);
|
||||
return csrfMatch?.[1] ? decodeURIComponent(csrfMatch[1]) : undefined;
|
||||
}
|
||||
|
||||
function buildHeaders(): Record<string, string> {
|
||||
const headers: Record<string, string> = { 'Content-Type': 'application/json' };
|
||||
const csrfToken = getCsrfToken();
|
||||
if (csrfToken) headers['X-CSRF-Token'] = csrfToken;
|
||||
return headers;
|
||||
}
|
||||
|
||||
interface PresignedUrlResult {
|
||||
field: string;
|
||||
uploadUrl: string;
|
||||
publicUrl: string;
|
||||
objectKey: string;
|
||||
}
|
||||
|
||||
function uploadFileWithProgress(
|
||||
url: string,
|
||||
file: File,
|
||||
onProgress: (percent: number) => void,
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('PUT', url);
|
||||
xhr.setRequestHeader('Content-Type', file.type);
|
||||
|
||||
xhr.upload.addEventListener('progress', (e) => {
|
||||
if (e.lengthComputable) {
|
||||
onProgress(Math.round((e.loaded / e.total) * 100));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('load', () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
onProgress(100);
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Upload thất bại (${xhr.status})`));
|
||||
}
|
||||
});
|
||||
|
||||
xhr.addEventListener('error', () => reject(new Error('Lỗi kết nối khi tải ảnh')));
|
||||
xhr.addEventListener('abort', () => reject(new Error('Tải ảnh đã bị hủy')));
|
||||
|
||||
xhr.send(file);
|
||||
});
|
||||
}
|
||||
|
||||
export default function KycPage() {
|
||||
const { user, fetchProfile } = useAuthStore();
|
||||
const [currentStep, setCurrentStep] = useState(1);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<Record<string, number>>({});
|
||||
|
||||
const [documentType, setDocumentType] = useState('CCCD');
|
||||
const [documentNumber, setDocumentNumber] = useState('');
|
||||
@@ -47,6 +101,12 @@ export default function KycPage() {
|
||||
const kycInfo = KYC_STATUS_MAP[kycStatus] ?? { label: 'Chưa xác minh', variant: 'outline' as const, description: 'Bạn chưa gửi hồ sơ xác minh danh tính.' };
|
||||
const canSubmit = kycStatus === 'NONE' || kycStatus === 'REJECTED';
|
||||
|
||||
const totalProgress = useCallback(() => {
|
||||
const fields = Object.values(uploadProgress);
|
||||
if (fields.length === 0) return 0;
|
||||
return Math.round(fields.reduce((sum, p) => sum + p, 0) / fields.length);
|
||||
}, [uploadProgress]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!documentNumber.trim()) {
|
||||
setError('Vui lòng nhập số giấy tờ');
|
||||
@@ -59,14 +119,74 @@ export default function KycPage() {
|
||||
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
setUploadProgress({});
|
||||
|
||||
try {
|
||||
await apiClient.patch('/auth/profile', {
|
||||
kycData: {
|
||||
// Step 1: Build the list of files to upload
|
||||
const filesToUpload: { field: 'frontImage' | 'backImage' | 'selfieImage'; file: File }[] = [
|
||||
{ field: 'frontImage', file: frontImage },
|
||||
];
|
||||
if (backImage) filesToUpload.push({ field: 'backImage', file: backImage });
|
||||
if (selfieImage) filesToUpload.push({ field: 'selfieImage', file: selfieImage });
|
||||
|
||||
// Step 2: Request presigned URLs from the backend
|
||||
const uploadUrlsRes = await fetch(`${API_BASE_URL}/auth/kyc/upload-urls`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: buildHeaders(),
|
||||
body: JSON.stringify({
|
||||
files: filesToUpload.map((f) => ({
|
||||
field: f.field,
|
||||
mimeType: f.file.type,
|
||||
fileName: f.file.name,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!uploadUrlsRes.ok) {
|
||||
const errData = await uploadUrlsRes.json().catch(() => ({ message: uploadUrlsRes.statusText }));
|
||||
throw new Error(errData.message || 'Không thể tạo URL tải lên');
|
||||
}
|
||||
|
||||
const presignedUrls: PresignedUrlResult[] = await uploadUrlsRes.json();
|
||||
|
||||
// Step 3: Upload each file directly to MinIO via presigned URL
|
||||
const urlMap = new Map(presignedUrls.map((p) => [p.field, p]));
|
||||
|
||||
await Promise.all(
|
||||
filesToUpload.map(({ field, file }) => {
|
||||
const presigned = urlMap.get(field);
|
||||
if (!presigned) throw new Error(`Không tìm thấy URL tải lên cho ${field}`);
|
||||
|
||||
return uploadFileWithProgress(presigned.uploadUrl, file, (percent) => {
|
||||
setUploadProgress((prev) => ({ ...prev, [field]: percent }));
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
// Step 4: Submit KYC with the uploaded image URLs
|
||||
const frontUrl = urlMap.get('frontImage')!.publicUrl;
|
||||
const backUrl = urlMap.get('backImage')?.publicUrl;
|
||||
const selfieUrl = urlMap.get('selfieImage')?.publicUrl;
|
||||
|
||||
const submitRes = await fetch(`${API_BASE_URL}/auth/kyc/submit`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: buildHeaders(),
|
||||
body: JSON.stringify({
|
||||
documentType,
|
||||
documentNumber: documentNumber.trim(),
|
||||
submittedAt: new Date().toISOString(),
|
||||
},
|
||||
frontImageUrl: frontUrl,
|
||||
backImageUrl: backUrl,
|
||||
selfieUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!submitRes.ok) {
|
||||
const errData = await submitRes.json().catch(() => ({ message: submitRes.statusText }));
|
||||
throw new Error(errData.message || 'Gửi hồ sơ thất bại');
|
||||
}
|
||||
|
||||
await fetchProfile();
|
||||
setSuccess(true);
|
||||
} catch (e) {
|
||||
@@ -247,10 +367,26 @@ export default function KycPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Upload progress */}
|
||||
{submitting && Object.keys(uploadProgress).length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Đang tải ảnh lên...</span>
|
||||
<span className="font-medium">{totalProgress()}%</span>
|
||||
</div>
|
||||
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${totalProgress()}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex justify-between pt-2">
|
||||
{currentStep > 1 ? (
|
||||
<Button variant="outline" onClick={() => setCurrentStep((s) => s - 1)}>
|
||||
<Button variant="outline" onClick={() => setCurrentStep((s) => s - 1)} disabled={submitting}>
|
||||
Quay lại
|
||||
</Button>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user