feat: wire settings API keys to real profile attributes

Co-authored-by: Velik <hongochai10@users.noreply.github.com>
This commit is contained in:
Cursor Agent
2026-02-23 11:39:37 +00:00
parent 549eb714c1
commit 82421efea7
2 changed files with 226 additions and 77 deletions

View File

@@ -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}`);

View File

@@ -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<string> => {
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<ApiResponse<ApiKey[]>> => {
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<ApiResponse<CreateApiKeyResult>> => {
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<ApiResponse<void>> => {
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(),
};
},
};