diff --git a/apps/web-client/src/app/(dashboard)/settings/api-keys/page.tsx b/apps/web-client/src/app/(dashboard)/settings/api-keys/page.tsx index afe22551..9bc17086 100644 --- a/apps/web-client/src/app/(dashboard)/settings/api-keys/page.tsx +++ b/apps/web-client/src/app/(dashboard)/settings/api-keys/page.tsx @@ -34,20 +34,8 @@ import { } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { useI18n } from '@/features/theme'; - -/** - * EN: API Key interface - * VI: Interface cho API Key - */ -interface ApiKey { - id: string; - name: string; - keyPrefix: string; // EN: First few characters of the key / VI: Một vài ký tự đầu của key - keySuffix: string; // EN: Last 4 characters of the key / VI: 4 ký tự cuối của key - createdAt: string; - lastUsedAt: string | null; - expiresAt: string | null; -} +import { useAuthStore } from '@/stores/auth-store'; +import { apiKeysApi, type ApiKey } from '@/services/api/api-keys.api'; /** * EN: Create API key schema with translated messages @@ -121,6 +109,7 @@ export default function ApiKeysPage() { // EN: Translation hook / VI: Hook translation const t = useTranslations(); const { locale } = useI18n(); + const { user } = useAuthStore(); // EN: Create schema with translations / VI: Tạo schema với translations const apiKeySchema = createApiKeySchema(t); @@ -157,29 +146,25 @@ export default function ApiKeysPage() { // EN: Load API keys on mount / VI: Tải API keys khi mount useEffect(() => { - loadApiKeys(); - }, []); + if (user?.id) { + loadApiKeys(); + } + }, [user?.id]); /** * EN: Load API keys from API * VI: Tải API keys từ API */ const loadApiKeys = async () => { + if (!user?.id) return; + setIsLoading(true); setError(''); try { - // EN: TODO: Replace with actual API call - // VI: TODO: Thay thế bằng API call thực tế - // const response = await apiKeysApi.list(); - // if (response.success && response.data) { - // setApiKeys(response.data); - // } - - // EN: Mock data for now / VI: Dữ liệu mock tạm thời - // In a real implementation, the API would return keys with masked values - // Trong implementation thực tế, API sẽ trả về keys với giá trị đã được che - await new Promise((resolve) => setTimeout(resolve, 500)); - setApiKeys([]); + const response = await apiKeysApi.list(user.id); + if (response.success && response.data) { + setApiKeys(response.data); + } } catch (err: any) { setError(err.message || t('settings.apiKeys.failedToLoad')); } finally { @@ -192,46 +177,26 @@ export default function ApiKeysPage() { * VI: Xử lý submit form tạo API key */ const onCreateSubmit = async (data: CreateApiKeyFormData) => { + if (!user?.id) return; + setError(''); setSuccess(''); try { - // EN: TODO: Replace with actual API call - // VI: TODO: Thay thế bằng API call thực tế - // const response = await apiKeysApi.create({ - // name: data.name, - // description: data.description, - // }); - // if (response.success && response.data) { - // setNewApiKey(response.data.key); // EN: Full key shown only once / VI: Key đầy đủ chỉ hiển thị một lần - // setNewApiKeyName(data.name); - // setShowCreateDialog(false); - // setShowNewKeyDialog(true); - // reset(); - // await loadApiKeys(); - // } + const response = await apiKeysApi.create(user.id, { + name: data.name, + description: data.description, + }); - // EN: Mock implementation / VI: Implementation mock - await new Promise((resolve) => setTimeout(resolve, 500)); - - // EN: Generate mock API key / VI: Tạo API key mock - const mockKey = `gk_${Math.random().toString(36).substring(2, 15)}${Math.random().toString(36).substring(2, 15)}`; - setNewApiKey(mockKey); + if (!response.success || !response.data) { + throw new Error(t('settings.apiKeys.failedToCreate')); + } + + setNewApiKey(response.data.key); setNewApiKeyName(data.name); setShowCreateDialog(false); setShowNewKeyDialog(true); reset(); - - // EN: Add to list with masked value / VI: Thêm vào danh sách với giá trị đã che - const newKey: ApiKey = { - id: `key_${Date.now()}`, - name: data.name, - keyPrefix: mockKey.substring(0, 8), - keySuffix: mockKey.slice(-4), - createdAt: new Date().toISOString(), - lastUsedAt: null, - expiresAt: null, - }; - setApiKeys((prev) => [newKey, ...prev]); + setApiKeys((prev) => [response.data!.apiKey, ...prev]); } catch (err: any) { setError(err.message || t('settings.apiKeys.failedToCreate')); } @@ -242,6 +207,8 @@ export default function ApiKeysPage() { * VI: Xử lý xóa API key */ const handleDeleteKey = async (keyId: string, keyName: string) => { + if (!user?.id) return; + if (!confirm(t('settings.apiKeys.confirmDelete', { name: keyName }))) { return; } @@ -249,16 +216,7 @@ export default function ApiKeysPage() { setDeletingKeyId(keyId); setError(''); try { - // EN: TODO: Replace with actual API call - // VI: TODO: Thay thế bằng API call thực tế - // const response = await apiKeysApi.delete(keyId); - // if (response.success) { - // setSuccess('API key deleted successfully / API key đã được xóa thành công'); - // await loadApiKeys(); - // } - - // EN: Mock implementation / VI: Implementation mock - await new Promise((resolve) => setTimeout(resolve, 500)); + await apiKeysApi.delete(user.id, keyId); setApiKeys((prev) => prev.filter((key) => key.id !== keyId)); setSuccess(t('settings.apiKeys.deletedSuccessfully')); setTimeout(() => setSuccess(''), 3000); @@ -279,13 +237,8 @@ export default function ApiKeysPage() { if (fullKey) { await navigator.clipboard.writeText(fullKey); } else { - // EN: In real implementation, fetch full key from API / VI: Trong implementation thực tế, lấy full key từ API - // const response = await apiKeysApi.getFullKey(keyId); - // if (response.success && response.data) { - // await navigator.clipboard.writeText(response.data.key); - // } - - // EN: For now, show masked key / VI: Tạm thời, hiển thị key đã che + // EN: Existing keys are stored masked for security; copy masked value. + // VI: Các key đã lưu được che để bảo mật; sao chép giá trị đã che. const key = apiKeys.find((k) => k.id === keyId); if (key) { await navigator.clipboard.writeText(`${key.keyPrefix}...${key.keySuffix}`); diff --git a/apps/web-client/src/services/api/api-keys.api.ts b/apps/web-client/src/services/api/api-keys.api.ts new file mode 100644 index 00000000..1891cb46 --- /dev/null +++ b/apps/web-client/src/services/api/api-keys.api.ts @@ -0,0 +1,196 @@ +import { ApiResponse } from '@goodgo/types'; + +import { userApi } from './user.api'; + +const API_KEY_ATTRIBUTE_PREFIX = 'api_key_'; + +/** + * EN: Public API key metadata exposed to UI. + * VI: Metadata API key public cho UI. + */ +export interface ApiKey { + id: string; + name: string; + keyPrefix: string; + keySuffix: string; + createdAt: string; + lastUsedAt: string | null; + expiresAt: string | null; +} + +interface StoredApiKeyRecord extends ApiKey { + keyHash: string; + revokedAt: string | null; +} + +interface CreateApiKeyResult { + apiKey: ApiKey; + key: string; +} + +const toAttributeKey = (id: string) => `${API_KEY_ATTRIBUTE_PREFIX}${id}`; + +const parseStoredRecord = (value: string): StoredApiKeyRecord | null => { + try { + const parsed = JSON.parse(value) as StoredApiKeyRecord; + if (!parsed?.id || !parsed?.name || !parsed?.keyPrefix || !parsed?.keySuffix) { + return null; + } + return parsed; + } catch { + return null; + } +}; + +const createRandomApiKey = (): string => { + if (typeof crypto !== 'undefined' && typeof crypto.getRandomValues === 'function') { + const randomBuffer = new Uint8Array(24); + crypto.getRandomValues(randomBuffer); + const randomPart = Array.from(randomBuffer) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); + return `gg_${randomPart}`; + } + return `gg_${Math.random().toString(36).slice(2)}${Date.now().toString(36)}`; +}; + +const hashApiKey = async (apiKey: string): Promise => { + if (typeof crypto !== 'undefined' && crypto.subtle) { + const encoded = new TextEncoder().encode(apiKey); + const hashBuffer = await crypto.subtle.digest('SHA-256', encoded); + return Array.from(new Uint8Array(hashBuffer)) + .map((byte) => byte.toString(16).padStart(2, '0')) + .join(''); + } + return apiKey; +}; + +const toPublicApiKey = (record: StoredApiKeyRecord): ApiKey => ({ + id: record.id, + name: record.name, + keyPrefix: record.keyPrefix, + keySuffix: record.keySuffix, + createdAt: record.createdAt, + lastUsedAt: record.lastUsedAt, + expiresAt: record.expiresAt, +}); + +/** + * EN: API keys service persisted via profile attributes. + * VI: Service API keys lưu trữ qua profile attributes. + */ +export const apiKeysApi = { + /** + * EN: List active API keys. + * VI: Lấy danh sách API key đang hoạt động. + */ + list: async (userId: string): Promise> => { + const profileResponse = await userApi.getProfile(userId); + if (!profileResponse.success || !profileResponse.data) { + throw new Error(profileResponse.error?.message || 'Failed to load API keys'); + } + + const records = profileResponse.data.attributes + .filter((attribute) => attribute.key.startsWith(API_KEY_ATTRIBUTE_PREFIX)) + .map((attribute) => parseStoredRecord(attribute.value)) + .filter((record): record is StoredApiKeyRecord => !!record && !record.revokedAt) + .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt)) + .map(toPublicApiKey); + + return { + success: true, + data: records, + timestamp: new Date().toISOString(), + }; + }, + + /** + * EN: Create API key and return full key once. + * VI: Tạo API key và trả full key duy nhất một lần. + */ + create: async ( + userId: string, + payload: { name: string; description?: string } + ): Promise> => { + const fullKey = createRandomApiKey(); + const keyHash = await hashApiKey(fullKey); + const keyId = typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + + const record: StoredApiKeyRecord = { + id: keyId, + name: payload.name.trim(), + keyPrefix: fullKey.slice(0, 8), + keySuffix: fullKey.slice(-4), + keyHash, + createdAt: new Date().toISOString(), + lastUsedAt: null, + expiresAt: null, + revokedAt: null, + }; + + if (payload.description?.trim()) { + await userApi.setProfileAttribute(userId, `api_key_desc_${keyId}`, { + value: payload.description.trim(), + valueType: 'String', + }); + } + + await userApi.setProfileAttribute(userId, toAttributeKey(keyId), { + value: JSON.stringify(record), + valueType: 'Json', + }); + + return { + success: true, + data: { + apiKey: toPublicApiKey(record), + key: fullKey, + }, + timestamp: new Date().toISOString(), + }; + }, + + /** + * EN: Revoke API key by marking profile attribute as revoked. + * VI: Thu hồi API key bằng cách đánh dấu revoked trong profile attribute. + */ + delete: async (userId: string, keyId: string): Promise> => { + const profileResponse = await userApi.getProfile(userId); + if (!profileResponse.success || !profileResponse.data) { + throw new Error(profileResponse.error?.message || 'Failed to load API key'); + } + + const targetAttribute = profileResponse.data.attributes.find( + (attribute) => attribute.key === toAttributeKey(keyId) + ); + + if (!targetAttribute) { + return { + success: true, + timestamp: new Date().toISOString(), + }; + } + + const record = parseStoredRecord(targetAttribute.value); + if (!record) { + throw new Error('Corrupted API key data'); + } + + const revokedRecord: StoredApiKeyRecord = { + ...record, + revokedAt: new Date().toISOString(), + }; + + await userApi.setProfileAttribute(userId, toAttributeKey(keyId), { + value: JSON.stringify(revokedRecord), + valueType: 'Json', + }); + + return { + success: true, + timestamp: new Date().toISOString(), + }; + }, +};