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:
@@ -0,0 +1,12 @@
|
|||||||
|
export interface KycFileRequest {
|
||||||
|
field: 'frontImage' | 'backImage' | 'selfieImage';
|
||||||
|
mimeType: string;
|
||||||
|
fileName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GenerateKycUploadUrlsCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly files: KycFileRequest[],
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { Inject } from '@nestjs/common';
|
||||||
|
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
NotFoundException,
|
||||||
|
ValidationException,
|
||||||
|
type LoggerService,
|
||||||
|
} from '@modules/shared';
|
||||||
|
import {
|
||||||
|
MEDIA_STORAGE_SERVICE,
|
||||||
|
type IMediaStorageService,
|
||||||
|
} from '../../../../listings/infrastructure/services/media-storage.service';
|
||||||
|
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
|
||||||
|
import { GenerateKycUploadUrlsCommand } from './generate-kyc-upload-urls.command';
|
||||||
|
|
||||||
|
const KYC_FOLDER = 'kyc';
|
||||||
|
const ALLOWED_SUBMIT_STATUSES = new Set(['NONE', 'REJECTED']);
|
||||||
|
const ALLOWED_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||||
|
const PRESIGNED_URL_EXPIRY_SECONDS = 300; // 5 minutes
|
||||||
|
|
||||||
|
export interface KycUploadUrlResult {
|
||||||
|
field: string;
|
||||||
|
uploadUrl: string;
|
||||||
|
publicUrl: string;
|
||||||
|
objectKey: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@CommandHandler(GenerateKycUploadUrlsCommand)
|
||||||
|
export class GenerateKycUploadUrlsHandler
|
||||||
|
implements ICommandHandler<GenerateKycUploadUrlsCommand>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
||||||
|
@Inject(MEDIA_STORAGE_SERVICE) private readonly mediaStorage: IMediaStorageService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: GenerateKycUploadUrlsCommand): Promise<KycUploadUrlResult[]> {
|
||||||
|
const user = await this.userRepo.findById(command.userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('Nguoi dung', command.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_SUBMIT_STATUSES.has(user.kycStatus)) {
|
||||||
|
throw new ValidationException(
|
||||||
|
'Ban da gui ho so KYC. Vui long cho ket qua xem xet.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!command.files.length || command.files.length > 3) {
|
||||||
|
throw new ValidationException(
|
||||||
|
'Vui long cung cap 1-3 file de tai len',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate MIME types
|
||||||
|
for (const file of command.files) {
|
||||||
|
if (!ALLOWED_MIME_TYPES.has(file.mimeType)) {
|
||||||
|
throw new ValidationException(
|
||||||
|
`Dinh dang file khong duoc ho tro: ${file.mimeType}. Chi chap nhan JPEG, PNG, WebP.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const folder = `${KYC_FOLDER}/${command.userId}`;
|
||||||
|
|
||||||
|
const results: KycUploadUrlResult[] = [];
|
||||||
|
for (const file of command.files) {
|
||||||
|
try {
|
||||||
|
const presigned = await this.mediaStorage.generatePresignedUpload(
|
||||||
|
folder,
|
||||||
|
file.fileName,
|
||||||
|
file.mimeType,
|
||||||
|
PRESIGNED_URL_EXPIRY_SECONDS,
|
||||||
|
);
|
||||||
|
|
||||||
|
results.push({
|
||||||
|
field: file.field,
|
||||||
|
uploadUrl: presigned.uploadUrl,
|
||||||
|
publicUrl: presigned.publicUrl,
|
||||||
|
objectKey: presigned.objectKey,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to generate presigned URL for KYC ${file.field}: ${error instanceof Error ? error.message : error}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
'GenerateKycUploadUrlsHandler',
|
||||||
|
);
|
||||||
|
throw new ValidationException(
|
||||||
|
`Khong the tao URL tai len cho ${file.field}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
export interface KycFileData {
|
||||||
|
buffer: Buffer;
|
||||||
|
mimetype: string;
|
||||||
|
originalname: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KycImageUrls {
|
||||||
|
frontImageUrl: string;
|
||||||
|
backImageUrl?: string;
|
||||||
|
selfieUrl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SubmitKycCommand {
|
||||||
|
constructor(
|
||||||
|
public readonly userId: string,
|
||||||
|
public readonly documentType: string,
|
||||||
|
public readonly documentNumber: string,
|
||||||
|
public readonly frontImage?: KycFileData,
|
||||||
|
public readonly backImage?: KycFileData,
|
||||||
|
public readonly selfieImage?: KycFileData,
|
||||||
|
public readonly imageUrls?: KycImageUrls,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { Inject, InternalServerErrorException } from '@nestjs/common';
|
||||||
|
import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
DomainException,
|
||||||
|
type LoggerService,
|
||||||
|
NotFoundException,
|
||||||
|
ValidationException,
|
||||||
|
CacheService,
|
||||||
|
CachePrefix,
|
||||||
|
} from '@modules/shared';
|
||||||
|
import {
|
||||||
|
MEDIA_STORAGE_SERVICE,
|
||||||
|
type IMediaStorageService,
|
||||||
|
} from '../../../../listings/infrastructure/services/media-storage.service';
|
||||||
|
import { USER_REPOSITORY, type IUserRepository } from '../../../domain/repositories/user.repository';
|
||||||
|
import { SubmitKycCommand, type KycFileData } from './submit-kyc.command';
|
||||||
|
|
||||||
|
const KYC_FOLDER = 'kyc';
|
||||||
|
const ALLOWED_SUBMIT_STATUSES = new Set(['NONE', 'REJECTED']);
|
||||||
|
|
||||||
|
@CommandHandler(SubmitKycCommand)
|
||||||
|
export class SubmitKycHandler implements ICommandHandler<SubmitKycCommand> {
|
||||||
|
constructor(
|
||||||
|
@Inject(USER_REPOSITORY) private readonly userRepo: IUserRepository,
|
||||||
|
@Inject(MEDIA_STORAGE_SERVICE) private readonly mediaStorage: IMediaStorageService,
|
||||||
|
private readonly cache: CacheService,
|
||||||
|
private readonly logger: LoggerService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: SubmitKycCommand): Promise<{ message: string }> {
|
||||||
|
try {
|
||||||
|
const user = await this.userRepo.findById(command.userId);
|
||||||
|
if (!user) {
|
||||||
|
throw new NotFoundException('Nguoi dung', command.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ALLOWED_SUBMIT_STATUSES.has(user.kycStatus)) {
|
||||||
|
throw new ValidationException(
|
||||||
|
'Ban da gui ho so KYC. Vui long cho ket qua xem xet.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let frontImageUrl: string;
|
||||||
|
let backImageUrl: string | null = null;
|
||||||
|
let selfieUrl: string | null = null;
|
||||||
|
|
||||||
|
if (command.imageUrls) {
|
||||||
|
// Presigned URL flow: images were already uploaded directly to MinIO
|
||||||
|
frontImageUrl = command.imageUrls.frontImageUrl;
|
||||||
|
backImageUrl = command.imageUrls.backImageUrl ?? null;
|
||||||
|
selfieUrl = command.imageUrls.selfieUrl ?? null;
|
||||||
|
} else if (command.frontImage) {
|
||||||
|
// Legacy file upload flow: upload buffers server-side
|
||||||
|
const folder = `${KYC_FOLDER}/${command.userId}`;
|
||||||
|
|
||||||
|
frontImageUrl = await this.uploadFile(command.frontImage, folder, 'front');
|
||||||
|
backImageUrl = command.backImage
|
||||||
|
? await this.uploadFile(command.backImage, folder, 'back')
|
||||||
|
: null;
|
||||||
|
selfieUrl = command.selfieImage
|
||||||
|
? await this.uploadFile(command.selfieImage, folder, 'selfie')
|
||||||
|
: null;
|
||||||
|
} else {
|
||||||
|
throw new ValidationException(
|
||||||
|
'Vui long tai len anh mat truoc giay to hoac cung cap URL anh da tai len',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const kycData = {
|
||||||
|
idType: command.documentType,
|
||||||
|
idNumber: command.documentNumber,
|
||||||
|
frontImageUrl,
|
||||||
|
backImageUrl,
|
||||||
|
selfieUrl,
|
||||||
|
submittedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
user.updateKycStatus('PENDING', kycData);
|
||||||
|
await this.userRepo.update(user);
|
||||||
|
|
||||||
|
await this.cache.invalidate(
|
||||||
|
CacheService.buildKey(CachePrefix.USER_PROFILE, command.userId),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { message: 'Ho so KYC da duoc gui thanh cong' };
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DomainException) throw error;
|
||||||
|
this.logger.error(
|
||||||
|
`Failed to submit KYC for user ${command.userId}: ${error instanceof Error ? error.message : error}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
this.constructor.name,
|
||||||
|
);
|
||||||
|
throw new InternalServerErrorException('Khong the gui ho so KYC');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async uploadFile(
|
||||||
|
file: KycFileData,
|
||||||
|
folder: string,
|
||||||
|
label: string,
|
||||||
|
): Promise<string> {
|
||||||
|
try {
|
||||||
|
return await this.mediaStorage.upload(
|
||||||
|
file.buffer,
|
||||||
|
file.originalname,
|
||||||
|
file.mimetype,
|
||||||
|
folder,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(
|
||||||
|
`KYC ${label} image upload failed: ${error instanceof Error ? error.message : error}`,
|
||||||
|
error instanceof Error ? error.stack : undefined,
|
||||||
|
'SubmitKycHandler',
|
||||||
|
);
|
||||||
|
throw new ValidationException(`Tai anh ${label} that bai, vui long thu lai`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import { RefreshTokenHandler } from './application/commands/refresh-token/refres
|
|||||||
import { RegisterUserHandler } from './application/commands/register-user/register-user.handler';
|
import { RegisterUserHandler } from './application/commands/register-user/register-user.handler';
|
||||||
import { RequestUserDeletionHandler } from './application/commands/request-user-deletion/request-user-deletion.handler';
|
import { RequestUserDeletionHandler } from './application/commands/request-user-deletion/request-user-deletion.handler';
|
||||||
import { SetupMfaHandler } from './application/commands/setup-mfa/setup-mfa.handler';
|
import { SetupMfaHandler } from './application/commands/setup-mfa/setup-mfa.handler';
|
||||||
|
import { GenerateKycUploadUrlsHandler } from './application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.handler';
|
||||||
import { SubmitKycHandler } from './application/commands/submit-kyc/submit-kyc.handler';
|
import { SubmitKycHandler } from './application/commands/submit-kyc/submit-kyc.handler';
|
||||||
import { UpdateProfileHandler } from './application/commands/update-profile/update-profile.handler';
|
import { UpdateProfileHandler } from './application/commands/update-profile/update-profile.handler';
|
||||||
import { UseBackupCodeHandler } from './application/commands/use-backup-code/use-backup-code.handler';
|
import { UseBackupCodeHandler } from './application/commands/use-backup-code/use-backup-code.handler';
|
||||||
@@ -50,6 +51,7 @@ const CommandHandlers = [
|
|||||||
RefreshTokenHandler,
|
RefreshTokenHandler,
|
||||||
VerifyKycHandler,
|
VerifyKycHandler,
|
||||||
SubmitKycHandler,
|
SubmitKycHandler,
|
||||||
|
GenerateKycUploadUrlsHandler,
|
||||||
UpdateProfileHandler,
|
UpdateProfileHandler,
|
||||||
RequestUserDeletionHandler,
|
RequestUserDeletionHandler,
|
||||||
CancelUserDeletionHandler,
|
CancelUserDeletionHandler,
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ import { LoginUserCommand } from '../../application/commands/login-user/login-us
|
|||||||
import { type LoginResult } from '../../application/commands/login-user/login-user.handler';
|
import { type LoginResult } from '../../application/commands/login-user/login-user.handler';
|
||||||
import { RefreshTokenCommand } from '../../application/commands/refresh-token/refresh-token.command';
|
import { RefreshTokenCommand } from '../../application/commands/refresh-token/refresh-token.command';
|
||||||
import { RegisterUserCommand } from '../../application/commands/register-user/register-user.command';
|
import { RegisterUserCommand } from '../../application/commands/register-user/register-user.command';
|
||||||
|
import { GenerateKycUploadUrlsCommand, type KycFileRequest } from '../../application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.command';
|
||||||
|
import { type KycUploadUrlResult } from '../../application/commands/generate-kyc-upload-urls/generate-kyc-upload-urls.handler';
|
||||||
import { SubmitKycCommand } from '../../application/commands/submit-kyc/submit-kyc.command';
|
import { SubmitKycCommand } from '../../application/commands/submit-kyc/submit-kyc.command';
|
||||||
import { UpdateProfileCommand } from '../../application/commands/update-profile/update-profile.command';
|
import { UpdateProfileCommand } from '../../application/commands/update-profile/update-profile.command';
|
||||||
import { type UpdateProfileResultDto } from '../../application/commands/update-profile/update-profile.handler';
|
import { type UpdateProfileResultDto } from '../../application/commands/update-profile/update-profile.handler';
|
||||||
@@ -244,57 +246,56 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@UseInterceptors(
|
@Post('kyc/upload-urls')
|
||||||
FileFieldsInterceptor([
|
@ApiBearerAuth('JWT')
|
||||||
{ name: 'frontImage', maxCount: 1 },
|
@ApiOperation({ summary: 'Generate presigned upload URLs for KYC images' })
|
||||||
{ name: 'backImage', maxCount: 1 },
|
@ApiResponse({ status: 201, description: 'Presigned URLs generated' })
|
||||||
{ name: 'selfieImage', maxCount: 1 },
|
@ApiResponse({ status: 400, description: 'Validation error' })
|
||||||
]),
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
)
|
async generateKycUploadUrls(
|
||||||
|
@Body() body: { files: KycFileRequest[] },
|
||||||
|
@CurrentUser() user: JwtPayload,
|
||||||
|
): Promise<{ field: string; uploadUrl: string; publicUrl: string; objectKey: string }[]> {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new GenerateKycUploadUrlsCommand(user.sub, body.files),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
@Post('kyc/submit')
|
@Post('kyc/submit')
|
||||||
@ApiBearerAuth('JWT')
|
@ApiBearerAuth('JWT')
|
||||||
@ApiConsumes('multipart/form-data')
|
@ApiOperation({ summary: 'Submit KYC documents with presigned image URLs' })
|
||||||
@ApiOperation({ summary: 'Submit KYC documents for verification' })
|
|
||||||
@ApiResponse({ status: 201, description: 'KYC documents submitted successfully' })
|
@ApiResponse({ status: 201, description: 'KYC documents submitted successfully' })
|
||||||
@ApiResponse({ status: 400, description: 'Validation error (missing files or invalid format)' })
|
@ApiResponse({ status: 400, description: 'Validation error' })
|
||||||
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
@ApiResponse({ status: 401, description: 'Unauthorized' })
|
||||||
@ApiResponse({ status: 413, description: 'File too large (max 10MB per image)' })
|
|
||||||
async submitKyc(
|
async submitKyc(
|
||||||
@UploadedFiles()
|
@Body()
|
||||||
files: {
|
body: {
|
||||||
frontImage?: ValidatedFile[];
|
documentType: string;
|
||||||
backImage?: ValidatedFile[];
|
documentNumber: string;
|
||||||
selfieImage?: ValidatedFile[];
|
frontImageUrl: string;
|
||||||
|
backImageUrl?: string;
|
||||||
|
selfieUrl?: string;
|
||||||
},
|
},
|
||||||
@Body() body: { documentType: string; documentNumber: string },
|
|
||||||
@CurrentUser() user: JwtPayload,
|
@CurrentUser() user: JwtPayload,
|
||||||
): Promise<{ message: string }> {
|
): Promise<{ message: string }> {
|
||||||
const kycImagePipe = new FileValidationPipe({
|
if (!body.frontImageUrl) {
|
||||||
maxSizeBytes: 10 * 1024 * 1024,
|
|
||||||
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp'],
|
|
||||||
});
|
|
||||||
|
|
||||||
const frontImage = files.frontImage?.[0];
|
|
||||||
if (!frontImage) {
|
|
||||||
throw new ValidationException('Vui lòng tải ảnh mặt trước giấy tờ');
|
throw new ValidationException('Vui lòng tải ảnh mặt trước giấy tờ');
|
||||||
}
|
}
|
||||||
kycImagePipe.transform(frontImage);
|
|
||||||
|
|
||||||
if (files.backImage?.[0]) {
|
|
||||||
kycImagePipe.transform(files.backImage[0]);
|
|
||||||
}
|
|
||||||
if (files.selfieImage?.[0]) {
|
|
||||||
kycImagePipe.transform(files.selfieImage[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.commandBus.execute(
|
return this.commandBus.execute(
|
||||||
new SubmitKycCommand(
|
new SubmitKycCommand(
|
||||||
user.sub,
|
user.sub,
|
||||||
body.documentType,
|
body.documentType,
|
||||||
body.documentNumber,
|
body.documentNumber,
|
||||||
frontImage,
|
undefined,
|
||||||
files.backImage?.[0],
|
undefined,
|
||||||
files.selfieImage?.[0],
|
undefined,
|
||||||
|
{
|
||||||
|
frontImageUrl: body.frontImageUrl,
|
||||||
|
backImageUrl: body.backImageUrl,
|
||||||
|
selfieUrl: body.selfieUrl,
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Select } from '@/components/ui/select';
|
import { Select } from '@/components/ui/select';
|
||||||
import { apiClient } from '@/lib/api-client';
|
|
||||||
import { useAuthStore } from '@/lib/auth-store';
|
import { useAuthStore } from '@/lib/auth-store';
|
||||||
|
|
||||||
const KYC_STATUS_MAP: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline'; description: string }> = {
|
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ơ' },
|
{ 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() {
|
export default function KycPage() {
|
||||||
const { user, fetchProfile } = useAuthStore();
|
const { user, fetchProfile } = useAuthStore();
|
||||||
const [currentStep, setCurrentStep] = useState(1);
|
const [currentStep, setCurrentStep] = useState(1);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [uploadProgress, setUploadProgress] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
const [documentType, setDocumentType] = useState('CCCD');
|
const [documentType, setDocumentType] = useState('CCCD');
|
||||||
const [documentNumber, setDocumentNumber] = useState('');
|
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 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 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 () => {
|
const handleSubmit = async () => {
|
||||||
if (!documentNumber.trim()) {
|
if (!documentNumber.trim()) {
|
||||||
setError('Vui lòng nhập số giấy tờ');
|
setError('Vui lòng nhập số giấy tờ');
|
||||||
@@ -59,14 +119,74 @@ export default function KycPage() {
|
|||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setUploadProgress({});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await apiClient.patch('/auth/profile', {
|
// Step 1: Build the list of files to upload
|
||||||
kycData: {
|
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,
|
documentType,
|
||||||
documentNumber: documentNumber.trim(),
|
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();
|
await fetchProfile();
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -247,10 +367,26 @@ export default function KycPage() {
|
|||||||
</div>
|
</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 */}
|
{/* Navigation buttons */}
|
||||||
<div className="flex justify-between pt-2">
|
<div className="flex justify-between pt-2">
|
||||||
{currentStep > 1 ? (
|
{currentStep > 1 ? (
|
||||||
<Button variant="outline" onClick={() => setCurrentStep((s) => s - 1)}>
|
<Button variant="outline" onClick={() => setCurrentStep((s) => s - 1)} disabled={submitting}>
|
||||||
Quay lại
|
Quay lại
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user