From 1147c1e013513cb7d38d13968b79273163074124 Mon Sep 17 00:00:00 2001 From: maooyer Date: Wed, 22 Apr 2026 21:51:21 +0800 Subject: [PATCH] feat(web): Add api key components --- webapp/src/App.tsx | 2 + webapp/src/components/AppMainRoutes.tsx | 4 + webapp/src/components/SettingsPage.tsx | 87 +++++++++++++++++++ webapp/src/hooks/useAccountSecurityActions.ts | 22 +++++ webapp/src/lib/api/auth.ts | 28 ++++++ webapp/src/lib/i18n.ts | 22 +++++ 6 files changed, 165 insertions(+) diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 9b5895d..5fd2867 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1203,6 +1203,8 @@ export default function App() { }, onOpenDisableTotp: () => setDisableTotpOpen(true), onGetRecoveryCode: accountSecurityActions.getRecoveryCode, + onGetApiKey: accountSecurityActions.getApiKey, + onRotateApiKey: accountSecurityActions.rotateApiKey, onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices, onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice, onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust, diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index de8b18f..55a985f 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -94,6 +94,8 @@ export interface AppMainRoutesProps { onEnableTotp: (secret: string, token: string) => Promise; onOpenDisableTotp: () => void; onGetRecoveryCode: (masterPassword: string) => Promise; + onGetApiKey: (masterPassword: string) => Promise; + onRotateApiKey: (masterPassword: string) => Promise; onRefreshAuthorizedDevices: () => Promise; onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise; onRevokeDeviceTrust: (device: AuthorizedDevice) => void; @@ -225,6 +227,8 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { onEnableTotp={props.onEnableTotp} onOpenDisableTotp={props.onOpenDisableTotp} onGetRecoveryCode={props.onGetRecoveryCode} + onGetApiKey={props.onGetApiKey} + onRotateApiKey={props.onRotateApiKey} onNotify={props.onNotify} /> diff --git a/webapp/src/components/SettingsPage.tsx b/webapp/src/components/SettingsPage.tsx index 5464219..da49ebb 100644 --- a/webapp/src/components/SettingsPage.tsx +++ b/webapp/src/components/SettingsPage.tsx @@ -14,6 +14,8 @@ interface SettingsPageProps { onEnableTotp: (secret: string, token: string) => Promise; onOpenDisableTotp: () => void; onGetRecoveryCode: (masterPassword: string) => Promise; + onGetApiKey: (masterPassword: string) => Promise; + onRotateApiKey: (masterPassword: string) => Promise; onNotify?: (type: 'success' | 'error', text: string) => void; } @@ -48,6 +50,10 @@ export default function SettingsPage(props: SettingsPageProps) { const [totpLocked, setTotpLocked] = useState(props.totpEnabled); const [recoveryMasterPassword, setRecoveryMasterPassword] = useState(''); const [recoveryCode, setRecoveryCode] = useState(''); + const [apiKeyMasterPassword, setApiKeyMasterPassword] = useState(''); + const [apiKey, setApiKey] = useState(''); + const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false); + const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false); useEffect(() => { if (!props.totpEnabled) { @@ -87,6 +93,27 @@ export default function SettingsPage(props: SettingsPageProps) { props.onNotify?.('success', t('txt_recovery_code_loaded')); } + async function loadApiKey(): Promise { + try { + const key = await props.onGetApiKey(apiKeyMasterPassword); + setApiKey(key); + setApiKeyDialogOpen(true); + } catch (error) { + props.onNotify?.('error', error instanceof Error ? error.message : t('txt_api_key_is_empty')); + } + } + + async function doRotateApiKey(): Promise { + try { + const key = await props.onRotateApiKey(apiKeyMasterPassword); + setApiKey(key); + setApiKeyDialogOpen(true); + props.onNotify?.('success', t('txt_api_key_rotated')); + } catch (error) { + props.onNotify?.('error', error instanceof Error ? error.message : t('txt_api_key_is_empty')); + } + } + function formatDateTime(value: string | null | undefined): string { if (!value) return t('txt_dash'); const parsed = new Date(value); @@ -235,8 +262,68 @@ export default function SettingsPage(props: SettingsPageProps) { )} + +
+

{t('txt_api_key')}

+ +
+ + +
+
+ setApiKeyDialogOpen(false)} + onCancel={() => setApiKeyDialogOpen(false)} + > +
+ {([ + [t('txt_client_id'), `user.${props.profile.id}`], + [t('txt_client_secret'), apiKey], + [t('txt_scope'), 'api'], + ] as [string, string][]).map(([label, value]) => ( + + ))} +
+
+ { + setRotateApiKeyConfirmOpen(false); + void doRotateApiKey(); + }} + onCancel={() => setRotateApiKeyConfirmOpen(false)} + /> ); } diff --git a/webapp/src/hooks/useAccountSecurityActions.ts b/webapp/src/hooks/useAccountSecurityActions.ts index 6b46ca4..797f6fb 100644 --- a/webapp/src/hooks/useAccountSecurityActions.ts +++ b/webapp/src/hooks/useAccountSecurityActions.ts @@ -5,7 +5,9 @@ import { deleteAuthorizedDevice, deriveLoginHash, getCurrentDeviceIdentifier, + getApiKey, getTotpRecoveryCode, + rotateApiKey, revokeAuthorizedDeviceTrust, revokeAllAuthorizedDeviceTrust, setTotp, @@ -148,6 +150,26 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct return code; }, + async getApiKey(masterPassword: string): Promise { + if (!profile) throw new Error(t('txt_profile_unavailable')); + const normalized = String(masterPassword || ''); + if (!normalized) throw new Error(t('txt_master_password_is_required')); + const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations); + const key = await getApiKey(authedFetch, derived.hash); + if (!key) throw new Error(t('txt_api_key_is_empty')); + return key; + }, + + async rotateApiKey(masterPassword: string): Promise { + if (!profile) throw new Error(t('txt_profile_unavailable')); + const normalized = String(masterPassword || ''); + if (!normalized) throw new Error(t('txt_master_password_is_required')); + const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations); + const key = await rotateApiKey(authedFetch, derived.hash); + if (!key) throw new Error(t('txt_api_key_is_empty')); + return key; + }, + async refreshAuthorizedDevices() { await refetchAuthorizedDevices(); }, diff --git a/webapp/src/lib/api/auth.ts b/webapp/src/lib/api/auth.ts index 2f5e24c..b6a0ad7 100644 --- a/webapp/src/lib/api/auth.ts +++ b/webapp/src/lib/api/auth.ts @@ -594,3 +594,31 @@ export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Prom const resp = await authedFetch('/api/devices', { method: 'DELETE' }); if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed')); } + +export async function getApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise { + const resp = await authedFetch('/api/accounts/api_key', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ masterPasswordHash }), + }); + if (!resp.ok) { + const body = await parseJson(resp); + throw new Error(body?.error_description || body?.error || 'Failed to get API key'); + } + const body = (await parseJson<{ apiKey?: string }>(resp)) || {}; + return String(body.apiKey || ''); +} + +export async function rotateApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise { + const resp = await authedFetch('/api/accounts/rotate_api_key', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ masterPasswordHash }), + }); + if (!resp.ok) { + const body = await parseJson(resp); + throw new Error(body?.error_description || body?.error || 'Failed to rotate API key'); + } + const body = (await parseJson<{ apiKey?: string }>(resp)) || {}; + return String(body.apiKey || ''); +} diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index bc7a1a0..f237e1c 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -601,6 +601,17 @@ const messages: Record> = { txt_recovery_code_copied: "Recovery code copied", txt_recovery_code_is_empty: "Recovery code is empty", txt_recovery_code_loaded: "Recovery code loaded", + txt_api_key: "API Key", + txt_view_api_key: "View API Key", + txt_rotate_api_key: "Rotate API Key", + txt_api_key_copied: "API key copied", + txt_api_key_loaded: "API key loaded", + txt_api_key_rotated: "API key rotated", + txt_rotate_api_key_confirm: "Rotate API key? The current key will stop working immediately.", + txt_api_key_is_empty: "API key is empty", + txt_client_id: "client_id", + txt_client_secret: "client_secret", + txt_scope: "scope", txt_refresh: "Refresh", txt_refresh_in_seconds_s: "Refresh in {seconds}s", txt_regenerate: "Regenerate", @@ -1363,6 +1374,17 @@ const zhCNOverrides: Record = { txt_recovery_code_copied: '恢复代码已复制', txt_recovery_code_is_empty: '恢复代码为空', txt_recovery_code_loaded: '恢复代码已加载', + txt_api_key: 'API 密钥', + txt_view_api_key: '查看 API 密钥', + txt_rotate_api_key: '轮换 API 密钥', + txt_api_key_copied: 'API 密钥已复制', + txt_api_key_loaded: 'API 密钥已加载', + txt_api_key_rotated: 'API 密钥已轮换', + txt_rotate_api_key_confirm: '轮换 API 密钥?当前密钥将立即失效。', + txt_api_key_is_empty: 'API 密钥为空', + txt_client_id: 'client_id', + txt_client_secret: 'client_secret', + txt_scope: 'scope', txt_refresh_in_seconds_s: '{seconds} 秒后刷新', txt_registration_succeeded_please_sign_in: '注册成功,请登录', txt_remove_device: '移除设备',