feat: wire settings API keys to real profile attributes
Co-authored-by: Velik <hongochai10@users.noreply.github.com>
This commit is contained in:
@@ -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}`);
|
||||
|
||||
196
apps/web-client/src/services/api/api-keys.api.ts
Normal file
196
apps/web-client/src/services/api/api-keys.api.ts
Normal 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(),
|
||||
};
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user