mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat(web): Add api key components
This commit is contained in:
@@ -1203,6 +1203,8 @@ export default function App() {
|
|||||||
},
|
},
|
||||||
onOpenDisableTotp: () => setDisableTotpOpen(true),
|
onOpenDisableTotp: () => setDisableTotpOpen(true),
|
||||||
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
|
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
|
||||||
|
onGetApiKey: accountSecurityActions.getApiKey,
|
||||||
|
onRotateApiKey: accountSecurityActions.rotateApiKey,
|
||||||
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
||||||
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
|
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
|
||||||
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
||||||
|
|||||||
@@ -94,6 +94,8 @@ export interface AppMainRoutesProps {
|
|||||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||||
onOpenDisableTotp: () => void;
|
onOpenDisableTotp: () => void;
|
||||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||||
|
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||||
|
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||||
onRefreshAuthorizedDevices: () => Promise<void>;
|
onRefreshAuthorizedDevices: () => Promise<void>;
|
||||||
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
onRenameAuthorizedDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
|
||||||
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
|
||||||
@@ -225,6 +227,8 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
onEnableTotp={props.onEnableTotp}
|
onEnableTotp={props.onEnableTotp}
|
||||||
onOpenDisableTotp={props.onOpenDisableTotp}
|
onOpenDisableTotp={props.onOpenDisableTotp}
|
||||||
onGetRecoveryCode={props.onGetRecoveryCode}
|
onGetRecoveryCode={props.onGetRecoveryCode}
|
||||||
|
onGetApiKey={props.onGetApiKey}
|
||||||
|
onRotateApiKey={props.onRotateApiKey}
|
||||||
onNotify={props.onNotify}
|
onNotify={props.onNotify}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ interface SettingsPageProps {
|
|||||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||||
onOpenDisableTotp: () => void;
|
onOpenDisableTotp: () => void;
|
||||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||||
|
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||||
|
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||||
onNotify?: (type: 'success' | 'error', text: string) => void;
|
onNotify?: (type: 'success' | 'error', text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,6 +50,10 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
|
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
|
||||||
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
|
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
|
||||||
const [recoveryCode, setRecoveryCode] = useState('');
|
const [recoveryCode, setRecoveryCode] = useState('');
|
||||||
|
const [apiKeyMasterPassword, setApiKeyMasterPassword] = useState('');
|
||||||
|
const [apiKey, setApiKey] = useState('');
|
||||||
|
const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false);
|
||||||
|
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!props.totpEnabled) {
|
if (!props.totpEnabled) {
|
||||||
@@ -87,6 +93,27 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
props.onNotify?.('success', t('txt_recovery_code_loaded'));
|
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 {
|
function formatDateTime(value: string | null | undefined): string {
|
||||||
if (!value) return t('txt_dash');
|
if (!value) return t('txt_dash');
|
||||||
const parsed = new Date(value);
|
const parsed = new Date(value);
|
||||||
@@ -235,8 +262,68 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
deleteAuthorizedDevice,
|
deleteAuthorizedDevice,
|
||||||
deriveLoginHash,
|
deriveLoginHash,
|
||||||
getCurrentDeviceIdentifier,
|
getCurrentDeviceIdentifier,
|
||||||
|
getApiKey,
|
||||||
getTotpRecoveryCode,
|
getTotpRecoveryCode,
|
||||||
|
rotateApiKey,
|
||||||
revokeAuthorizedDeviceTrust,
|
revokeAuthorizedDeviceTrust,
|
||||||
revokeAllAuthorizedDeviceTrust,
|
revokeAllAuthorizedDeviceTrust,
|
||||||
setTotp,
|
setTotp,
|
||||||
@@ -148,6 +150,26 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
|||||||
return code;
|
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() {
|
async refreshAuthorizedDevices() {
|
||||||
await refetchAuthorizedDevices();
|
await refetchAuthorizedDevices();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -594,3 +594,31 @@ export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Prom
|
|||||||
const resp = await authedFetch('/api/devices', { method: 'DELETE' });
|
const resp = await authedFetch('/api/devices', { method: 'DELETE' });
|
||||||
if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed'));
|
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 || '');
|
||||||
|
}
|
||||||
|
|||||||
@@ -601,6 +601,17 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_recovery_code_copied: "Recovery code copied",
|
txt_recovery_code_copied: "Recovery code copied",
|
||||||
txt_recovery_code_is_empty: "Recovery code is empty",
|
txt_recovery_code_is_empty: "Recovery code is empty",
|
||||||
txt_recovery_code_loaded: "Recovery code loaded",
|
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: "Refresh",
|
||||||
txt_refresh_in_seconds_s: "Refresh in {seconds}s",
|
txt_refresh_in_seconds_s: "Refresh in {seconds}s",
|
||||||
txt_regenerate: "Regenerate",
|
txt_regenerate: "Regenerate",
|
||||||
@@ -1363,6 +1374,17 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_recovery_code_copied: '恢复代码已复制',
|
txt_recovery_code_copied: '恢复代码已复制',
|
||||||
txt_recovery_code_is_empty: '恢复代码为空',
|
txt_recovery_code_is_empty: '恢复代码为空',
|
||||||
txt_recovery_code_loaded: '恢复代码已加载',
|
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_refresh_in_seconds_s: '{seconds} 秒后刷新',
|
||||||
txt_registration_succeeded_please_sign_in: '注册成功,请登录',
|
txt_registration_succeeded_please_sign_in: '注册成功,请登录',
|
||||||
txt_remove_device: '移除设备',
|
txt_remove_device: '移除设备',
|
||||||
|
|||||||
Reference in New Issue
Block a user