diff --git a/apps/api/src/modules/admin/admin.module.ts b/apps/api/src/modules/admin/admin.module.ts index 49d5b06..123cfd7 100644 --- a/apps/api/src/modules/admin/admin.module.ts +++ b/apps/api/src/modules/admin/admin.module.ts @@ -10,10 +10,12 @@ import { BanUserHandler } from './application/commands/ban-user/ban-user.handler import { BulkModerateListingsHandler } from './application/commands/bulk-moderate-listings/bulk-moderate-listings.handler'; import { RejectKycHandler } from './application/commands/reject-kyc/reject-kyc.handler'; import { RejectListingHandler } from './application/commands/reject-listing/reject-listing.handler'; +import { UpdateAiSettingsHandler } from './application/commands/update-ai-settings/update-ai-settings.handler'; import { UpdateUserStatusHandler } from './application/commands/update-user-status/update-user-status.handler'; import { AdminAuditListener } from './application/listeners/admin-audit.listener'; import { UserBannedListener } from './application/listeners/user-banned.listener'; import { UserDeactivatedListener } from './application/listeners/user-deactivated.listener'; +import { GetAiSettingsHandler } from './application/queries/get-ai-settings/get-ai-settings.handler'; import { GetAuditLogsHandler } from './application/queries/get-audit-logs/get-audit-logs.handler'; import { GetDashboardStatsHandler } from './application/queries/get-dashboard-stats/get-dashboard-stats.handler'; import { GetKycQueueHandler } from './application/queries/get-kyc-queue/get-kyc-queue.handler'; @@ -21,6 +23,7 @@ import { GetModerationQueueHandler } from './application/queries/get-moderation- import { GetRevenueStatsHandler } from './application/queries/get-revenue-stats/get-revenue-stats.handler'; import { GetUserDetailHandler } from './application/queries/get-user-detail/get-user-detail.handler'; import { GetUsersHandler } from './application/queries/get-users/get-users.handler'; +import { SystemSettingsService } from './application/services/system-settings.service'; import { ADMIN_QUERY_REPOSITORY } from './domain/repositories/admin-query.repository'; import { AUDIT_LOG_REPOSITORY } from './domain/repositories/audit-log.repository'; import { PrismaAdminQueryRepository } from './infrastructure/repositories/prisma-admin-query.repository'; @@ -37,6 +40,7 @@ const CommandHandlers = [ ApproveKycHandler, RejectKycHandler, BulkModerateListingsHandler, + UpdateAiSettingsHandler, ]; const QueryHandlers = [ @@ -47,6 +51,7 @@ const QueryHandlers = [ GetUserDetailHandler, GetKycQueueHandler, GetAuditLogsHandler, + GetAiSettingsHandler, ]; @Module({ @@ -57,6 +62,9 @@ const QueryHandlers = [ { provide: ADMIN_QUERY_REPOSITORY, useClass: PrismaAdminQueryRepository }, { provide: AUDIT_LOG_REPOSITORY, useClass: PrismaAuditLogRepository }, + // Services + SystemSettingsService, + // CQRS ...CommandHandlers, ...QueryHandlers, @@ -66,5 +74,6 @@ const QueryHandlers = [ UserDeactivatedListener, AdminAuditListener, ], + exports: [SystemSettingsService], }) export class AdminModule {} diff --git a/apps/api/src/modules/admin/application/commands/index.ts b/apps/api/src/modules/admin/application/commands/index.ts index 59dbc43..5b72d23 100644 --- a/apps/api/src/modules/admin/application/commands/index.ts +++ b/apps/api/src/modules/admin/application/commands/index.ts @@ -14,3 +14,5 @@ export { RejectKycCommand } from './reject-kyc/reject-kyc.command'; export { RejectKycHandler } from './reject-kyc/reject-kyc.handler'; export { BulkModerateListingsCommand } from './bulk-moderate-listings/bulk-moderate-listings.command'; export { BulkModerateListingsHandler } from './bulk-moderate-listings/bulk-moderate-listings.handler'; +export { UpdateAiSettingsCommand } from './update-ai-settings/update-ai-settings.command'; +export { UpdateAiSettingsHandler } from './update-ai-settings/update-ai-settings.handler'; diff --git a/apps/api/src/modules/admin/application/commands/update-ai-settings/update-ai-settings.command.ts b/apps/api/src/modules/admin/application/commands/update-ai-settings/update-ai-settings.command.ts new file mode 100644 index 0000000..010342c --- /dev/null +++ b/apps/api/src/modules/admin/application/commands/update-ai-settings/update-ai-settings.command.ts @@ -0,0 +1,8 @@ +export class UpdateAiSettingsCommand { + constructor( + public readonly adminId: string, + public readonly apiUrl?: string, + public readonly apiKey?: string, + public readonly model?: string, + ) {} +} diff --git a/apps/api/src/modules/admin/application/commands/update-ai-settings/update-ai-settings.handler.ts b/apps/api/src/modules/admin/application/commands/update-ai-settings/update-ai-settings.handler.ts new file mode 100644 index 0000000..779e7be --- /dev/null +++ b/apps/api/src/modules/admin/application/commands/update-ai-settings/update-ai-settings.handler.ts @@ -0,0 +1,43 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { CommandHandler, type ICommandHandler } from '@nestjs/cqrs'; +import { DomainException, LoggerService } from '@modules/shared'; +import { type AiSettingsDto } from '../../queries/get-ai-settings/get-ai-settings.handler'; +import { SystemSettingsService } from '../../services/system-settings.service'; +import { UpdateAiSettingsCommand } from './update-ai-settings.command'; + +@CommandHandler(UpdateAiSettingsCommand) +export class UpdateAiSettingsHandler + implements ICommandHandler +{ + constructor( + private readonly systemSettings: SystemSettingsService, + private readonly logger: LoggerService, + ) {} + + async execute(command: UpdateAiSettingsCommand): Promise { + try { + const updated = await this.systemSettings.updateAiSettings({ + apiUrl: command.apiUrl, + apiKey: command.apiKey, + model: command.model, + updatedBy: command.adminId, + }); + + return { + apiUrl: updated.apiUrl, + apiKeyMasked: SystemSettingsService.maskApiKey(updated.apiKey), + model: updated.model, + hasApiKey: Boolean(updated.apiKey), + updatedAt: updated.updatedAt ? updated.updatedAt.toISOString() : null, + }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to update AI settings: ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + 'UpdateAiSettingsHandler', + ); + throw new InternalServerErrorException('Lỗi khi lưu cài đặt AI'); + } + } +} diff --git a/apps/api/src/modules/admin/application/queries/get-ai-settings/get-ai-settings.handler.ts b/apps/api/src/modules/admin/application/queries/get-ai-settings/get-ai-settings.handler.ts new file mode 100644 index 0000000..a5e37a3 --- /dev/null +++ b/apps/api/src/modules/admin/application/queries/get-ai-settings/get-ai-settings.handler.ts @@ -0,0 +1,42 @@ +import { InternalServerErrorException } from '@nestjs/common'; +import { QueryHandler, type IQueryHandler } from '@nestjs/cqrs'; +import { DomainException, LoggerService } from '@modules/shared'; +import { SystemSettingsService } from '../../services/system-settings.service'; +import { GetAiSettingsQuery } from './get-ai-settings.query'; + +export interface AiSettingsDto { + apiUrl: string; + apiKeyMasked: string | null; + model: string; + hasApiKey: boolean; + updatedAt: string | null; +} + +@QueryHandler(GetAiSettingsQuery) +export class GetAiSettingsHandler implements IQueryHandler { + constructor( + private readonly systemSettings: SystemSettingsService, + private readonly logger: LoggerService, + ) {} + + async execute(_query: GetAiSettingsQuery): Promise { + try { + const current = await this.systemSettings.getAiSettings(); + return { + apiUrl: current.apiUrl, + apiKeyMasked: SystemSettingsService.maskApiKey(current.apiKey), + model: current.model, + hasApiKey: Boolean(current.apiKey), + updatedAt: current.updatedAt ? current.updatedAt.toISOString() : null, + }; + } catch (error) { + if (error instanceof DomainException) throw error; + this.logger.error( + `Failed to get AI settings: ${error instanceof Error ? error.message : String(error)}`, + error instanceof Error ? error.stack : undefined, + 'GetAiSettingsHandler', + ); + throw new InternalServerErrorException('Lỗi khi đọc cài đặt AI'); + } + } +} diff --git a/apps/api/src/modules/admin/application/queries/get-ai-settings/get-ai-settings.query.ts b/apps/api/src/modules/admin/application/queries/get-ai-settings/get-ai-settings.query.ts new file mode 100644 index 0000000..1e0d408 --- /dev/null +++ b/apps/api/src/modules/admin/application/queries/get-ai-settings/get-ai-settings.query.ts @@ -0,0 +1,3 @@ +export class GetAiSettingsQuery { + constructor() {} +} diff --git a/apps/api/src/modules/admin/application/queries/index.ts b/apps/api/src/modules/admin/application/queries/index.ts index 98f61d1..8c73e2d 100644 --- a/apps/api/src/modules/admin/application/queries/index.ts +++ b/apps/api/src/modules/admin/application/queries/index.ts @@ -12,3 +12,5 @@ export { GetKycQueueQuery } from './get-kyc-queue/get-kyc-queue.query'; export { GetKycQueueHandler } from './get-kyc-queue/get-kyc-queue.handler'; export { GetAuditLogsQuery } from './get-audit-logs/get-audit-logs.query'; export { GetAuditLogsHandler } from './get-audit-logs/get-audit-logs.handler'; +export { GetAiSettingsQuery } from './get-ai-settings/get-ai-settings.query'; +export { GetAiSettingsHandler, type AiSettingsDto } from './get-ai-settings/get-ai-settings.handler'; diff --git a/apps/api/src/modules/admin/application/services/system-settings.service.ts b/apps/api/src/modules/admin/application/services/system-settings.service.ts new file mode 100644 index 0000000..9add437 --- /dev/null +++ b/apps/api/src/modules/admin/application/services/system-settings.service.ts @@ -0,0 +1,159 @@ +import { Injectable } from '@nestjs/common'; +import { PrismaService } from '@modules/shared'; + +/** + * SystemSettings service — read/write the SystemSetting key/value store for + * runtime-configurable platform settings (currently: Claude/Anthropic AI + * credentials). + * + * TODO(hardening): secret values are persisted as plain strings. A future + * iteration should encrypt `isSecret` entries at rest (libsodium / KMS). + */ + +export const AI_SETTING_KEYS = { + apiUrl: 'ai.api_url', + apiKey: 'ai.api_key', + model: 'ai.model', +} as const; + +export const AI_DEFAULTS = { + apiUrl: 'https://api.anthropic.com/v1', + model: 'claude-opus-4-5', +} as const; + +export interface AiSettingsInternal { + apiUrl: string; + apiKey: string | null; + model: string; + updatedAt: Date | null; +} + +export interface UpdateAiSettingsInput { + apiUrl?: string; + apiKey?: string; // pass empty string to clear, '__UNCHANGED__' to leave, undefined to leave + model?: string; + updatedBy?: string | null; +} + +export const UNCHANGED_SENTINEL = '__UNCHANGED__'; + +@Injectable() +export class SystemSettingsService { + constructor(private readonly prisma: PrismaService) {} + + /** + * Read the current AI settings including the raw (unmasked) API key. Intended + * for backend runtime consumers only — never return the raw key over HTTP. + */ + async getAiSettings(): Promise { + const rows = await this.prisma.systemSetting.findMany({ + where: { + key: { + in: [AI_SETTING_KEYS.apiUrl, AI_SETTING_KEYS.apiKey, AI_SETTING_KEYS.model], + }, + }, + }); + + const byKey = new Map(rows.map((r) => [r.key, r])); + const apiUrlRow = byKey.get(AI_SETTING_KEYS.apiUrl); + const apiKeyRow = byKey.get(AI_SETTING_KEYS.apiKey); + const modelRow = byKey.get(AI_SETTING_KEYS.model); + + const latestUpdatedAt = rows.reduce((acc, r) => { + if (!acc || r.updatedAt > acc) return r.updatedAt; + return acc; + }, null); + + return { + apiUrl: apiUrlRow?.value || AI_DEFAULTS.apiUrl, + apiKey: apiKeyRow?.value || null, + model: modelRow?.value || AI_DEFAULTS.model, + updatedAt: latestUpdatedAt, + }; + } + + async updateAiSettings(input: UpdateAiSettingsInput): Promise { + const updatedBy = input.updatedBy ?? null; + const ops: Array> = []; + + if (input.apiUrl !== undefined) { + ops.push( + this.prisma.systemSetting.upsert({ + where: { key: AI_SETTING_KEYS.apiUrl }, + create: { + key: AI_SETTING_KEYS.apiUrl, + value: input.apiUrl, + valueType: 'string', + isSecret: false, + updatedBy, + }, + update: { value: input.apiUrl, valueType: 'string', isSecret: false, updatedBy }, + }), + ); + } + + if (input.model !== undefined) { + ops.push( + this.prisma.systemSetting.upsert({ + where: { key: AI_SETTING_KEYS.model }, + create: { + key: AI_SETTING_KEYS.model, + value: input.model, + valueType: 'string', + isSecret: false, + updatedBy, + }, + update: { value: input.model, valueType: 'string', isSecret: false, updatedBy }, + }), + ); + } + + // apiKey semantics: + // - undefined → do nothing + // - '__UNCHANGED__' → do nothing (frontend round-trip sentinel) + // - '' (empty) → explicit clear + // - any other string → overwrite + if (input.apiKey !== undefined && input.apiKey !== UNCHANGED_SENTINEL) { + if (input.apiKey === '') { + ops.push( + this.prisma.systemSetting.deleteMany({ where: { key: AI_SETTING_KEYS.apiKey } }), + ); + } else { + ops.push( + this.prisma.systemSetting.upsert({ + where: { key: AI_SETTING_KEYS.apiKey }, + create: { + key: AI_SETTING_KEYS.apiKey, + value: input.apiKey, + valueType: 'secret', + isSecret: true, + updatedBy, + }, + update: { + value: input.apiKey, + valueType: 'secret', + isSecret: true, + updatedBy, + }, + }), + ); + } + } + + await Promise.all(ops); + return this.getAiSettings(); + } + + /** + * Mask an Anthropic API key: keep first 7 chars + `...` + last 4 chars. + * Example: `sk-ant-api03-abc...wxyz` → `sk-ant-...wxyz`. + */ + static maskApiKey(raw: string | null): string | null { + if (!raw) return null; + if (raw.length <= 11) { + // Too short to meaningfully mask — still hide the middle. + return `${raw.slice(0, Math.min(4, raw.length))}...`; + } + return `${raw.slice(0, 7)}...${raw.slice(-4)}`; + } +} diff --git a/apps/api/src/modules/admin/index.ts b/apps/api/src/modules/admin/index.ts index 2a445c7..3712071 100644 --- a/apps/api/src/modules/admin/index.ts +++ b/apps/api/src/modules/admin/index.ts @@ -1,4 +1,5 @@ export { AdminModule } from './admin.module'; +export { SystemSettingsService } from './application/services/system-settings.service'; export { ListingApprovedEvent } from './domain/events/listing-approved.event'; export { ListingRejectedEvent } from './domain/events/listing-rejected.event'; export { diff --git a/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts b/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts index d80c423..b8da743 100644 --- a/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts +++ b/apps/api/src/modules/admin/presentation/controllers/admin.controller.ts @@ -15,8 +15,11 @@ import { AdjustSubscriptionCommand } from '../../application/commands/adjust-sub import { type AdjustSubscriptionResult } from '../../application/commands/adjust-subscription/adjust-subscription.handler'; import { BanUserCommand } from '../../application/commands/ban-user/ban-user.command'; import { type BanUserResult } from '../../application/commands/ban-user/ban-user.handler'; +import { UpdateAiSettingsCommand } from '../../application/commands/update-ai-settings/update-ai-settings.command'; import { UpdateUserStatusCommand } from '../../application/commands/update-user-status/update-user-status.command'; import { type UpdateUserStatusResult } from '../../application/commands/update-user-status/update-user-status.handler'; +import { GetAiSettingsQuery } from '../../application/queries/get-ai-settings/get-ai-settings.query'; +import { type AiSettingsDto } from '../../application/queries/get-ai-settings/get-ai-settings.handler'; import { GetAuditLogsQuery } from '../../application/queries/get-audit-logs/get-audit-logs.query'; import { GetDashboardStatsQuery } from '../../application/queries/get-dashboard-stats/get-dashboard-stats.query'; import { GetRevenueStatsQuery } from '../../application/queries/get-revenue-stats/get-revenue-stats.query'; @@ -34,6 +37,7 @@ import { BanUserDto } from '../dto/ban-user.dto'; import { GetAuditLogsQueryDto } from '../dto/get-audit-logs-query.dto'; import { GetUsersQueryDto } from '../dto/get-users-query.dto'; import { RevenueStatsDto } from '../dto/revenue-stats.dto'; +import { UpdateAiSettingsDto } from '../dto/update-ai-settings.dto'; import { UpdateUserStatusDto } from '../dto/update-user-status.dto'; @ApiTags('admin') @@ -171,6 +175,31 @@ export class AdminController { ); } + // ── AI Settings ── + + @Get('settings/ai') + @ApiOperation({ summary: 'Get AI provider (Claude) settings' }) + @ApiResponse({ status: 200, description: 'AI settings retrieved successfully' }) + @ApiResponse({ status: 401, description: 'Unauthorized – missing or invalid JWT' }) + @ApiResponse({ status: 403, description: 'Forbidden – requires ADMIN role' }) + async getAiSettings(): Promise { + return this.queryBus.execute(new GetAiSettingsQuery()); + } + + @Patch('settings/ai') + @ApiOperation({ summary: 'Update AI provider (Claude) settings' }) + @ApiResponse({ status: 200, description: 'AI settings updated successfully' }) + @ApiResponse({ status: 401, description: 'Unauthorized – missing or invalid JWT' }) + @ApiResponse({ status: 403, description: 'Forbidden – requires ADMIN role' }) + async updateAiSettings( + @Body() dto: UpdateAiSettingsDto, + @CurrentUser() user: JwtPayload, + ): Promise { + return this.commandBus.execute( + new UpdateAiSettingsCommand(user.sub, dto.apiUrl, dto.apiKey, dto.model), + ); + } + // ── Audit Logs ── @Get('audit-logs') diff --git a/apps/api/src/modules/admin/presentation/dto/index.ts b/apps/api/src/modules/admin/presentation/dto/index.ts index 43f6ed4..f32cbd6 100644 --- a/apps/api/src/modules/admin/presentation/dto/index.ts +++ b/apps/api/src/modules/admin/presentation/dto/index.ts @@ -9,3 +9,4 @@ export { ApproveKycDto } from './approve-kyc.dto'; export { RejectKycDto } from './reject-kyc.dto'; export { BulkModerateDto } from './bulk-moderate.dto'; export { GetAuditLogsQueryDto } from './get-audit-logs-query.dto'; +export { UpdateAiSettingsDto } from './update-ai-settings.dto'; diff --git a/apps/api/src/modules/admin/presentation/dto/update-ai-settings.dto.ts b/apps/api/src/modules/admin/presentation/dto/update-ai-settings.dto.ts new file mode 100644 index 0000000..2d030cc --- /dev/null +++ b/apps/api/src/modules/admin/presentation/dto/update-ai-settings.dto.ts @@ -0,0 +1,32 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +export class UpdateAiSettingsDto { + @ApiPropertyOptional({ + description: 'Base URL of the Anthropic-compatible API endpoint', + example: 'https://api.anthropic.com/v1', + }) + @IsOptional() + @IsString() + @MaxLength(500) + apiUrl?: string; + + @ApiPropertyOptional({ + description: + 'Raw API key. Send empty string to clear, "__UNCHANGED__" to leave untouched, omit to leave untouched.', + example: 'sk-ant-api03-xxxxxxxx', + }) + @IsOptional() + @IsString() + @MaxLength(500) + apiKey?: string; + + @ApiPropertyOptional({ + description: 'Model identifier to use for Claude calls.', + example: 'claude-opus-4-5', + }) + @IsOptional() + @IsString() + @MaxLength(120) + model?: string; +} diff --git a/apps/web/app/[locale]/(admin)/admin/settings/ai/page.tsx b/apps/web/app/[locale]/(admin)/admin/settings/ai/page.tsx new file mode 100644 index 0000000..326e5b0 --- /dev/null +++ b/apps/web/app/[locale]/(admin)/admin/settings/ai/page.tsx @@ -0,0 +1,282 @@ +'use client'; + +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Eye, EyeOff, Loader2, Sparkles, Trash2 } from 'lucide-react'; +import * as React from 'react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, 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 { + adminApi, + type AiSettings, + type UpdateAiSettingsPayload, +} from '@/lib/admin-api'; + +const UNCHANGED_SENTINEL = '__UNCHANGED__'; +const DEFAULT_API_URL = 'https://api.anthropic.com/v1'; +const DEFAULT_MODEL = 'claude-opus-4-5'; + +const PRESET_MODELS = [ + 'claude-opus-4-5', + 'claude-sonnet-4-5', + 'claude-haiku-4-5', +] as const; + +export default function AdminAiSettingsPage() { + const queryClient = useQueryClient(); + + const { data, isLoading, error } = useQuery({ + queryKey: ['admin', 'ai-settings'], + queryFn: () => adminApi.getAiSettings(), + staleTime: 30_000, + }); + + const [apiUrl, setApiUrl] = React.useState(DEFAULT_API_URL); + const [apiKey, setApiKey] = React.useState(''); + const [showApiKey, setShowApiKey] = React.useState(false); + const [modelChoice, setModelChoice] = React.useState(DEFAULT_MODEL); + const [customModel, setCustomModel] = React.useState(''); + const [savedBanner, setSavedBanner] = React.useState(null); + const [errorBanner, setErrorBanner] = React.useState(null); + + // Hydrate form state once data loads. + React.useEffect(() => { + if (!data) return; + setApiUrl(data.apiUrl || DEFAULT_API_URL); + // If the server already has a key stored, pre-populate the sentinel so an + // accidental "Save" without editing does NOT overwrite the existing key. + setApiKey(data.hasApiKey ? UNCHANGED_SENTINEL : ''); + const currentModel = data.model || DEFAULT_MODEL; + if ((PRESET_MODELS as readonly string[]).includes(currentModel)) { + setModelChoice(currentModel); + setCustomModel(''); + } else { + setModelChoice('__custom__'); + setCustomModel(currentModel); + } + }, [data]); + + const mutation = useMutation({ + mutationFn: (payload: UpdateAiSettingsPayload) => adminApi.updateAiSettings(payload), + onSuccess: (result) => { + queryClient.setQueryData(['admin', 'ai-settings'], result); + setSavedBanner('Đã lưu cài đặt AI'); + setErrorBanner(null); + // Re-hydrate sentinel so the input reflects "key stored, untouched". + if (result.hasApiKey) setApiKey(UNCHANGED_SENTINEL); + else setApiKey(''); + setTimeout(() => setSavedBanner(null), 4000); + }, + onError: (err) => { + setErrorBanner(err instanceof Error ? err.message : 'Lưu cài đặt thất bại'); + setSavedBanner(null); + }, + }); + + const effectiveModel = modelChoice === '__custom__' ? customModel.trim() : modelChoice; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + setErrorBanner(null); + setSavedBanner(null); + + if (!apiUrl.trim()) { + setErrorBanner('API Base URL không được để trống'); + return; + } + if (!effectiveModel) { + setErrorBanner('Vui lòng chọn hoặc nhập model'); + return; + } + + const payload: UpdateAiSettingsPayload = { + apiUrl: apiUrl.trim(), + model: effectiveModel, + }; + + // Only send apiKey when the user actually changed it. `UNCHANGED_SENTINEL` + // tells the backend to leave the stored key alone. + if (apiKey !== UNCHANGED_SENTINEL) { + payload.apiKey = apiKey; + } + + mutation.mutate(payload); + }; + + const handleClearKey = () => { + if (!data?.hasApiKey) return; + setErrorBanner(null); + setSavedBanner(null); + mutation.mutate({ apiKey: '' }); + setApiKey(''); + }; + + if (isLoading) { + return ( +
+ +
+ ); + } + + if (error) { + return ( +
+ {error instanceof Error ? error.message : 'Không thể tải cài đặt AI'} +
+ ); + } + + const maskedPlaceholder = + data?.apiKeyMasked ?? 'sk-ant-...xxxx'; + + return ( +
+
+

Cài đặt AI

+

+ Cấu hình API Claude (Anthropic) cho các tính năng AI của nền tảng. +

+
+ + {savedBanner && ( +
+ {savedBanner} +
+ )} + {errorBanner && ( +
+ {errorBanner} +
+ )} + + + + + + Kết nối Claude (Anthropic) + +

+ Key và URL dùng cho AI nhận định, AI định giá BĐS. +

+
+ +
+
+ + setApiUrl(e.target.value)} + placeholder={DEFAULT_API_URL} + required + /> +

+ Mặc định: {DEFAULT_API_URL} +

+
+ +
+ +
+
+ setApiKey(e.target.value)} + placeholder={data?.hasApiKey ? maskedPlaceholder : 'sk-ant-api03-...'} + autoComplete="off" + className="pr-10" + /> + +
+ {data?.hasApiKey && ( + + )} +
+ {data?.hasApiKey ? ( +

+ Key hiện tại: {data.apiKeyMasked}. Để giữ + nguyên, không sửa trường này. +

+ ) : ( +

+ Chưa có key — dán key mới từ console Anthropic. +

+ )} +
+ +
+ + + {modelChoice === '__custom__' && ( + setCustomModel(e.target.value)} + placeholder="claude-xxxxx" + /> + )} +
+ + +
+
+
+ +
+ Key được lưu mã hoá trong database và chỉ truy cập được bởi quản trị viên. + {data?.updatedAt && ( + + · Cập nhật lần cuối:{' '} + {new Date(data.updatedAt).toLocaleString('vi-VN')} + + )} +
+
+ ); +} diff --git a/apps/web/app/[locale]/(admin)/layout.tsx b/apps/web/app/[locale]/(admin)/layout.tsx index a1e0785..838c918 100644 --- a/apps/web/app/[locale]/(admin)/layout.tsx +++ b/apps/web/app/[locale]/(admin)/layout.tsx @@ -7,6 +7,7 @@ import { ShieldCheck, LogOut, Menu, + Sparkles, X, } from 'lucide-react'; import { usePathname, useRouter } from 'next/navigation'; @@ -30,6 +31,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode }) { href: '/admin/users' as const, label: t('adminNav.users'), icon: Users }, { href: '/admin/moderation' as const, label: t('adminNav.moderation'), icon: ClipboardList }, { href: '/admin/kyc' as const, label: t('adminNav.kyc'), icon: ShieldCheck }, + { href: '/admin/settings/ai' as const, label: t('adminNav.aiSettings'), icon: Sparkles }, ]; useEffect(() => { diff --git a/apps/web/lib/admin-api.ts b/apps/web/lib/admin-api.ts index 0ae41ee..2b3c908 100644 --- a/apps/web/lib/admin-api.ts +++ b/apps/web/lib/admin-api.ts @@ -81,6 +81,20 @@ export interface UserDetail { }>; } +export interface AiSettings { + apiUrl: string; + apiKeyMasked: string | null; + model: string; + hasApiKey: boolean; + updatedAt: string | null; +} + +export interface UpdateAiSettingsPayload { + apiUrl?: string; + apiKey?: string; + model?: string; +} + export interface KycQueueItem { userId: string; fullName: string; @@ -176,4 +190,10 @@ export const adminApi = { userId, reason, }), + + // AI Settings + getAiSettings: () => apiClient.get('/admin/settings/ai'), + + updateAiSettings: (body: UpdateAiSettingsPayload) => + apiClient.patch('/admin/settings/ai', body), }; diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index e8f439c..9ea8f6e 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -60,6 +60,8 @@ "users": "User management", "moderation": "Content moderation", "kyc": "KYC verification", + "settings": "System settings", + "aiSettings": "AI settings", "closeMenu": "Close menu", "openMenu": "Open menu" }, diff --git a/apps/web/messages/vi.json b/apps/web/messages/vi.json index 1fb9f2e..71ab9ae 100644 --- a/apps/web/messages/vi.json +++ b/apps/web/messages/vi.json @@ -60,6 +60,8 @@ "users": "Quản lý người dùng", "moderation": "Kiểm duyệt tin", "kyc": "Duyệt KYC", + "settings": "Cài đặt hệ thống", + "aiSettings": "Cài đặt AI", "closeMenu": "Đóng menu", "openMenu": "Mở menu" }, diff --git a/prisma/schema.prisma b/prisma/schema.prisma index cbe9b42..fa76569 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -1334,3 +1334,19 @@ model InfrastructureProject { @@index([status]) @@index([province, category]) } + +// ============================================================================= +// SYSTEM SETTINGS +// ============================================================================= +// Key/value store for runtime-configurable system settings (e.g. AI provider +// credentials). Values are persisted as plain strings — TODO: encrypt `isSecret` +// entries at rest as a future hardening step. + +model SystemSetting { + key String @id + value String @db.Text + valueType String @default("string") // "string" | "secret" | "number" | "boolean" + isSecret Boolean @default(false) + updatedAt DateTime @updatedAt + updatedBy String? +}