feat(web): Add api key components

This commit is contained in:
maooyer
2026-04-22 21:51:21 +08:00
committed by shuaiplus
parent 31ffd98166
commit 1147c1e013
6 changed files with 165 additions and 0 deletions
+2
View File
@@ -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,
+4
View File
@@ -94,6 +94,8 @@ export interface AppMainRoutesProps {
onEnableTotp: (secret: string, token: string) => Promise<void>;
onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>;
onRefreshAuthorizedDevices: () => Promise<void>;
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
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}
/>
</Suspense>
+87
View File
@@ -14,6 +14,8 @@ interface SettingsPageProps {
onEnableTotp: (secret: string, token: string) => Promise<void>;
onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>;
onRotateApiKey: (masterPassword: string) => Promise<string>;
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<void> {
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<void> {
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) {
</div>
)}
</div>
<div className="settings-subcard">
<h3>{t('txt_api_key')}</h3>
<label className="field">
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
value={apiKeyMasterPassword}
onInput={(e) => setApiKeyMasterPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
<div className="actions">
<button type="button" className="btn btn-secondary" onClick={() => void loadApiKey()}>
<KeyRound size={14} className="btn-icon" />
{t('txt_view_api_key')}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={() => setRotateApiKeyConfirmOpen(true)}
>
<RefreshCw size={14} className="btn-icon" />
{t('txt_rotate_api_key')}
</button>
</div>
</div>
</div>
</section>
<ConfirmDialog
open={apiKeyDialogOpen}
title={t('txt_api_key')}
message=""
hideCancel
confirmText={t('txt_close')}
onConfirm={() => setApiKeyDialogOpen(false)}
onCancel={() => setApiKeyDialogOpen(false)}
>
<div className="stack" style={{ gap: 8, marginTop: 4 }}>
{([
[t('txt_client_id'), `user.${props.profile.id}`],
[t('txt_client_secret'), apiKey],
[t('txt_scope'), 'api'],
] as [string, string][]).map(([label, value]) => (
<label key={label} className="field">
<span>{label}</span>
<input className="input" readOnly value={value} onFocus={(e) => (e.currentTarget as HTMLInputElement).select()} />
</label>
))}
</div>
</ConfirmDialog>
<ConfirmDialog
open={rotateApiKeyConfirmOpen}
title={t('txt_rotate_api_key')}
message={t('txt_rotate_api_key_confirm')}
danger
onConfirm={() => {
setRotateApiKeyConfirmOpen(false);
void doRotateApiKey();
}}
onCancel={() => setRotateApiKeyConfirmOpen(false)}
/>
</div>
);
}
@@ -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<string> {
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<string> {
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();
},
+28
View File
@@ -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<string> {
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<TokenError>(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<string> {
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<TokenError>(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 || '');
}
+22
View File
@@ -601,6 +601,17 @@ const messages: Record<Locale, Record<string, string>> = {
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<string, string> = {
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: '移除设备',