mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: implement session timeout feature with customizable actions and update UI components
This commit is contained in:
+36
-8
@@ -84,8 +84,10 @@ const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 13;
|
|||||||
|
|
||||||
type ThemePreference = 'system' | 'light' | 'dark';
|
type ThemePreference = 'system' | 'light' | 'dark';
|
||||||
type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30;
|
type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30;
|
||||||
|
type SessionTimeoutAction = 'lock' | 'logout';
|
||||||
|
|
||||||
const LOCK_TIMEOUT_STORAGE_KEY = 'nodewarden.lock.timeout-minutes.v1';
|
const LOCK_TIMEOUT_STORAGE_KEY = 'nodewarden.lock.timeout-minutes.v1';
|
||||||
|
const SESSION_TIMEOUT_ACTION_STORAGE_KEY = 'nodewarden.session.timeout-action.v1';
|
||||||
const LOCK_TIMEOUT_VALUES = new Set<LockTimeoutMinutes>([0, 1, 5, 15, 30]);
|
const LOCK_TIMEOUT_VALUES = new Set<LockTimeoutMinutes>([0, 1, 5, 15, 30]);
|
||||||
|
|
||||||
function readThemePreference(): ThemePreference {
|
function readThemePreference(): ThemePreference {
|
||||||
@@ -106,6 +108,12 @@ function readLockTimeoutMinutes(): LockTimeoutMinutes {
|
|||||||
return LOCK_TIMEOUT_VALUES.has(value as LockTimeoutMinutes) ? (value as LockTimeoutMinutes) : 15;
|
return LOCK_TIMEOUT_VALUES.has(value as LockTimeoutMinutes) ? (value as LockTimeoutMinutes) : 15;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readSessionTimeoutAction(): SessionTimeoutAction {
|
||||||
|
if (typeof window === 'undefined') return 'lock';
|
||||||
|
const value = String(window.localStorage.getItem(SESSION_TIMEOUT_ACTION_STORAGE_KEY) || '').trim();
|
||||||
|
return value === 'logout' ? 'logout' : 'lock';
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
|
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
|
||||||
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
|
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
|
||||||
@@ -151,6 +159,7 @@ export default function App() {
|
|||||||
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
|
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
|
||||||
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
|
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
|
||||||
const [lockTimeoutMinutes, setLockTimeoutMinutesState] = useState<LockTimeoutMinutes>(() => readLockTimeoutMinutes());
|
const [lockTimeoutMinutes, setLockTimeoutMinutesState] = useState<LockTimeoutMinutes>(() => readLockTimeoutMinutes());
|
||||||
|
const [sessionTimeoutAction, setSessionTimeoutActionState] = useState<SessionTimeoutAction>(() => readSessionTimeoutAction());
|
||||||
const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialBootstrap.session?.email);
|
const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialBootstrap.session?.email);
|
||||||
|
|
||||||
const [confirm, setConfirm] = useState<AppConfirmState | null>(null);
|
const [confirm, setConfirm] = useState<AppConfirmState | null>(null);
|
||||||
@@ -269,6 +278,11 @@ export default function App() {
|
|||||||
window.localStorage.setItem(LOCK_TIMEOUT_STORAGE_KEY, String(lockTimeoutMinutes));
|
window.localStorage.setItem(LOCK_TIMEOUT_STORAGE_KEY, String(lockTimeoutMinutes));
|
||||||
}, [lockTimeoutMinutes]);
|
}, [lockTimeoutMinutes]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
window.localStorage.setItem(SESSION_TIMEOUT_ACTION_STORAGE_KEY, sessionTimeoutAction);
|
||||||
|
}, [sessionTimeoutAction]);
|
||||||
|
|
||||||
function handleToggleTheme() {
|
function handleToggleTheme() {
|
||||||
setThemePreference((prev) => {
|
setThemePreference((prev) => {
|
||||||
const current = prev === 'system' ? systemTheme : prev;
|
const current = prev === 'system' ? systemTheme : prev;
|
||||||
@@ -284,7 +298,12 @@ export default function App() {
|
|||||||
|
|
||||||
function setLockTimeoutMinutes(next: LockTimeoutMinutes) {
|
function setLockTimeoutMinutes(next: LockTimeoutMinutes) {
|
||||||
setLockTimeoutMinutesState(next);
|
setLockTimeoutMinutesState(next);
|
||||||
pushToast('success', t('txt_auto_lock_updated'));
|
pushToast('success', t('txt_session_timeout_updated'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSessionTimeoutAction(next: SessionTimeoutAction) {
|
||||||
|
setSessionTimeoutActionState(next);
|
||||||
|
pushToast('success', t('txt_session_timeout_updated'));
|
||||||
}
|
}
|
||||||
|
|
||||||
const authedFetch = useMemo(
|
const authedFetch = useMemo(
|
||||||
@@ -639,25 +658,32 @@ export default function App() {
|
|||||||
timerId = null;
|
timerId = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const scheduleLock = () => {
|
const runTimeoutAction = () => {
|
||||||
|
if (sessionTimeoutAction === 'logout') {
|
||||||
|
logoutNow();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (sessionRef.current?.symEncKey || sessionRef.current?.symMacKey) {
|
||||||
|
lockCurrentSession();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const scheduleTimeout = () => {
|
||||||
clearTimer();
|
clearTimer();
|
||||||
timerId = window.setTimeout(() => {
|
timerId = window.setTimeout(() => {
|
||||||
if (sessionRef.current?.symEncKey || sessionRef.current?.symMacKey) {
|
runTimeoutAction();
|
||||||
lockCurrentSession();
|
|
||||||
}
|
|
||||||
}, timeoutMs);
|
}, timeoutMs);
|
||||||
};
|
};
|
||||||
const markActivity = () => {
|
const markActivity = () => {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now - lastActivityAt < 1000) return;
|
if (now - lastActivityAt < 1000) return;
|
||||||
lastActivityAt = now;
|
lastActivityAt = now;
|
||||||
scheduleLock();
|
scheduleTimeout();
|
||||||
};
|
};
|
||||||
const handleVisibilityChange = () => {
|
const handleVisibilityChange = () => {
|
||||||
if (document.visibilityState === 'visible') markActivity();
|
if (document.visibilityState === 'visible') markActivity();
|
||||||
};
|
};
|
||||||
|
|
||||||
scheduleLock();
|
scheduleTimeout();
|
||||||
window.addEventListener('pointerdown', markActivity, { passive: true });
|
window.addEventListener('pointerdown', markActivity, { passive: true });
|
||||||
window.addEventListener('keydown', markActivity);
|
window.addEventListener('keydown', markActivity);
|
||||||
window.addEventListener('scroll', markActivity, { passive: true });
|
window.addEventListener('scroll', markActivity, { passive: true });
|
||||||
@@ -672,7 +698,7 @@ export default function App() {
|
|||||||
window.removeEventListener('touchstart', markActivity);
|
window.removeEventListener('touchstart', markActivity);
|
||||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||||
};
|
};
|
||||||
}, [phase, lockTimeoutMinutes]);
|
}, [phase, lockTimeoutMinutes, sessionTimeoutAction]);
|
||||||
|
|
||||||
function renderPassiveOverlays() {
|
function renderPassiveOverlays() {
|
||||||
return (
|
return (
|
||||||
@@ -1213,6 +1239,7 @@ export default function App() {
|
|||||||
invites: invitesQuery.data || [],
|
invites: invitesQuery.data || [],
|
||||||
totpEnabled: !!totpStatusQuery.data?.enabled,
|
totpEnabled: !!totpStatusQuery.data?.enabled,
|
||||||
lockTimeoutMinutes,
|
lockTimeoutMinutes,
|
||||||
|
sessionTimeoutAction,
|
||||||
authorizedDevices: authorizedDevicesQuery.data || [],
|
authorizedDevices: authorizedDevicesQuery.data || [],
|
||||||
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
|
authorizedDevicesLoading: authorizedDevicesQuery.isFetching,
|
||||||
onNavigate: navigate,
|
onNavigate: navigate,
|
||||||
@@ -1260,6 +1287,7 @@ export default function App() {
|
|||||||
onGetApiKey: accountSecurityActions.getApiKey,
|
onGetApiKey: accountSecurityActions.getApiKey,
|
||||||
onRotateApiKey: accountSecurityActions.rotateApiKey,
|
onRotateApiKey: accountSecurityActions.rotateApiKey,
|
||||||
onLockTimeoutChange: setLockTimeoutMinutes,
|
onLockTimeoutChange: setLockTimeoutMinutes,
|
||||||
|
onSessionTimeoutActionChange: setSessionTimeoutAction,
|
||||||
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
||||||
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
|
onRenameAuthorizedDevice: accountSecurityActions.renameAuthorizedDevice,
|
||||||
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ export interface AppMainRoutesProps {
|
|||||||
invites: AdminInvite[];
|
invites: AdminInvite[];
|
||||||
totpEnabled: boolean;
|
totpEnabled: boolean;
|
||||||
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||||
|
sessionTimeoutAction: 'lock' | 'logout';
|
||||||
authorizedDevices: AuthorizedDevice[];
|
authorizedDevices: AuthorizedDevice[];
|
||||||
authorizedDevicesLoading: boolean;
|
authorizedDevicesLoading: boolean;
|
||||||
onNavigate: (path: string) => void;
|
onNavigate: (path: string) => void;
|
||||||
@@ -99,6 +100,7 @@ export interface AppMainRoutesProps {
|
|||||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||||
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||||
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
||||||
|
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
|
||||||
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;
|
||||||
@@ -228,6 +230,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
profile={props.profile}
|
profile={props.profile}
|
||||||
totpEnabled={props.totpEnabled}
|
totpEnabled={props.totpEnabled}
|
||||||
lockTimeoutMinutes={props.lockTimeoutMinutes}
|
lockTimeoutMinutes={props.lockTimeoutMinutes}
|
||||||
|
sessionTimeoutAction={props.sessionTimeoutAction}
|
||||||
onChangePassword={props.onChangePassword}
|
onChangePassword={props.onChangePassword}
|
||||||
onSavePasswordHint={props.onSavePasswordHint}
|
onSavePasswordHint={props.onSavePasswordHint}
|
||||||
onEnableTotp={props.onEnableTotp}
|
onEnableTotp={props.onEnableTotp}
|
||||||
@@ -236,6 +239,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
|||||||
onGetApiKey={props.onGetApiKey}
|
onGetApiKey={props.onGetApiKey}
|
||||||
onRotateApiKey={props.onRotateApiKey}
|
onRotateApiKey={props.onRotateApiKey}
|
||||||
onLockTimeoutChange={props.onLockTimeoutChange}
|
onLockTimeoutChange={props.onLockTimeoutChange}
|
||||||
|
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
|
||||||
onNotify={props.onNotify}
|
onNotify={props.onNotify}
|
||||||
/>
|
/>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact';
|
import { Clipboard, KeyRound, Lightbulb, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import qrcode from 'qrcode-generator';
|
import qrcode from 'qrcode-generator';
|
||||||
import type { Profile } from '@/lib/types';
|
import type { Profile } from '@/lib/types';
|
||||||
@@ -10,6 +10,7 @@ interface SettingsPageProps {
|
|||||||
profile: Profile;
|
profile: Profile;
|
||||||
totpEnabled: boolean;
|
totpEnabled: boolean;
|
||||||
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||||
|
sessionTimeoutAction: 'lock' | 'logout';
|
||||||
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
||||||
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
|
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
|
||||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||||
@@ -18,15 +19,16 @@ interface SettingsPageProps {
|
|||||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||||
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||||
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
||||||
|
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
|
||||||
onNotify?: (type: 'success' | 'error', text: string) => void;
|
onNotify?: (type: 'success' | 'error', text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const LOCK_TIMEOUT_OPTIONS = [
|
const LOCK_TIMEOUT_OPTIONS = [
|
||||||
{ value: 1, labelKey: 'txt_lock_after_1_minute' },
|
{ value: 1, labelKey: 'txt_timeout_1_minute' },
|
||||||
{ value: 5, labelKey: 'txt_lock_after_5_minutes' },
|
{ value: 5, labelKey: 'txt_timeout_5_minutes' },
|
||||||
{ value: 15, labelKey: 'txt_lock_after_15_minutes' },
|
{ value: 15, labelKey: 'txt_timeout_15_minutes' },
|
||||||
{ value: 30, labelKey: 'txt_lock_after_30_minutes' },
|
{ value: 30, labelKey: 'txt_timeout_30_minutes' },
|
||||||
{ value: 0, labelKey: 'txt_lock_after_never' },
|
{ value: 0, labelKey: 'txt_timeout_never' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function randomBase32Secret(length: number): string {
|
function randomBase32Secret(length: number): string {
|
||||||
@@ -70,12 +72,13 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
const [secret, setSecret] = useState(() => randomBase32Secret(32));
|
const [secret, setSecret] = useState(() => randomBase32Secret(32));
|
||||||
const [token, setToken] = useState('');
|
const [token, setToken] = useState('');
|
||||||
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
|
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
|
||||||
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
|
|
||||||
const [recoveryCode, setRecoveryCode] = useState('');
|
const [recoveryCode, setRecoveryCode] = useState('');
|
||||||
const [apiKeyMasterPassword, setApiKeyMasterPassword] = useState('');
|
|
||||||
const [apiKey, setApiKey] = useState('');
|
const [apiKey, setApiKey] = useState('');
|
||||||
const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false);
|
const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false);
|
||||||
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
|
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
|
||||||
|
const [masterPasswordPrompt, setMasterPasswordPrompt] = useState<null | 'recovery' | 'apiKey' | 'rotateApiKey'>(null);
|
||||||
|
const [masterPasswordPromptValue, setMasterPasswordPromptValue] = useState('');
|
||||||
|
const [masterPasswordPromptSubmitting, setMasterPasswordPromptSubmitting] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
clearLegacyTotpSetupSecrets();
|
clearLegacyTotpSetupSecrets();
|
||||||
@@ -111,32 +114,51 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadRecoveryCode(): Promise<void> {
|
function openMasterPasswordPrompt(action: 'recovery' | 'apiKey' | 'rotateApiKey'): void {
|
||||||
const code = await props.onGetRecoveryCode(recoveryMasterPassword);
|
setMasterPasswordPrompt(action);
|
||||||
setRecoveryCode(code);
|
setMasterPasswordPromptValue('');
|
||||||
props.onNotify?.('success', t('txt_recovery_code_loaded'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadApiKey(): Promise<void> {
|
function closeMasterPasswordPrompt(): void {
|
||||||
|
if (masterPasswordPromptSubmitting) return;
|
||||||
|
setMasterPasswordPrompt(null);
|
||||||
|
setMasterPasswordPromptValue('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitMasterPasswordPrompt(): Promise<void> {
|
||||||
|
if (!masterPasswordPrompt || masterPasswordPromptSubmitting) return;
|
||||||
|
const masterPassword = masterPasswordPromptValue;
|
||||||
|
setMasterPasswordPromptSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const key = await props.onGetApiKey(apiKeyMasterPassword);
|
if (masterPasswordPrompt === 'recovery') {
|
||||||
setApiKey(key);
|
const code = await props.onGetRecoveryCode(masterPassword);
|
||||||
setApiKeyDialogOpen(true);
|
setRecoveryCode(code);
|
||||||
|
props.onNotify?.('success', t('txt_recovery_code_loaded'));
|
||||||
|
} else if (masterPasswordPrompt === 'apiKey') {
|
||||||
|
const key = await props.onGetApiKey(masterPassword);
|
||||||
|
setApiKey(key);
|
||||||
|
setApiKeyDialogOpen(true);
|
||||||
|
} else {
|
||||||
|
const key = await props.onRotateApiKey(masterPassword);
|
||||||
|
setApiKey(key);
|
||||||
|
setApiKeyDialogOpen(true);
|
||||||
|
props.onNotify?.('success', t('txt_api_key_rotated'));
|
||||||
|
}
|
||||||
|
setMasterPasswordPrompt(null);
|
||||||
|
setMasterPasswordPromptValue('');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_api_key_is_empty'));
|
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_master_password_is_required_2'));
|
||||||
|
} finally {
|
||||||
|
setMasterPasswordPromptSubmitting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function doRotateApiKey(): Promise<void> {
|
const masterPasswordPromptTitle =
|
||||||
try {
|
masterPasswordPrompt === 'recovery'
|
||||||
const key = await props.onRotateApiKey(apiKeyMasterPassword);
|
? t('txt_view_recovery_code')
|
||||||
setApiKey(key);
|
: masterPasswordPrompt === 'rotateApiKey'
|
||||||
setApiKeyDialogOpen(true);
|
? t('txt_rotate_api_key')
|
||||||
props.onNotify?.('success', t('txt_api_key_rotated'));
|
: t('txt_view_api_key');
|
||||||
} 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');
|
||||||
@@ -146,12 +168,12 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stack">
|
<div className="settings-modules-grid">
|
||||||
<section className="card">
|
<section className="card settings-module">
|
||||||
<h3>{t('txt_security_preferences')}</h3>
|
<h3>{t('txt_session_timeout')}</h3>
|
||||||
<div className="field-grid">
|
<div className="session-timeout-fields">
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_auto_lock')}</span>
|
<span>{t('txt_timeout_time')}</span>
|
||||||
<select
|
<select
|
||||||
className="input"
|
className="input"
|
||||||
value={String(props.lockTimeoutMinutes)}
|
value={String(props.lockTimeoutMinutes)}
|
||||||
@@ -163,30 +185,27 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<div className="field-help">{t('txt_auto_lock_description')}</div>
|
|
||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_password_hint_optional')}</span>
|
<span>{t('txt_timeout_action')}</span>
|
||||||
<input
|
<select
|
||||||
className="input"
|
className="input"
|
||||||
maxLength={120}
|
value={props.sessionTimeoutAction}
|
||||||
value={passwordHint}
|
onInput={(e) => props.onSessionTimeoutActionChange((e.currentTarget as HTMLSelectElement).value === 'logout' ? 'logout' : 'lock')}
|
||||||
placeholder={t('txt_password_hint_placeholder')}
|
|
||||||
onInput={(e) => setPasswordHint((e.currentTarget as HTMLInputElement).value)}
|
|
||||||
/>
|
|
||||||
<div className="field-help">{t('txt_password_hint_register_help')}</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
onClick={() => void props.onSavePasswordHint(passwordHint)}
|
|
||||||
>
|
>
|
||||||
{t('txt_save_profile')}
|
<option value="logout">{t('txt_timeout_action_logout')}</option>
|
||||||
</button>
|
<option value="lock">{t('txt_timeout_action_lock')}</option>
|
||||||
|
</select>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card">
|
<section className="card settings-module settings-module-placeholder">
|
||||||
|
<Lightbulb size={26} aria-hidden="true" />
|
||||||
|
<span>{t('txt_in_planning')}</span>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="card settings-module">
|
||||||
<h3>{t('txt_change_master_password')}</h3>
|
<h3>{t('txt_change_master_password')}</h3>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_current_password')}</span>
|
<span>{t('txt_current_password')}</span>
|
||||||
@@ -217,71 +236,87 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
</button>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="card">
|
<section className="card settings-module">
|
||||||
<div className="settings-twofactor-grid">
|
<h3>{t('txt_password_hint_optional')}</h3>
|
||||||
<div className="settings-subcard">
|
<label className="field">
|
||||||
<h3>{t('txt_totp')}</h3>
|
<span>{t('txt_password_hint')}</span>
|
||||||
{totpLocked && <div className="status-ok">{t('txt_totp_is_enabled_for_this_account')}</div>}
|
<input
|
||||||
<div className="totp-grid">
|
className="input"
|
||||||
<div className="totp-qr">
|
maxLength={120}
|
||||||
<img src={qrDataUrl} alt="TOTP QR" />
|
value={passwordHint}
|
||||||
</div>
|
placeholder={t('txt_password_hint_placeholder')}
|
||||||
<div>
|
onInput={(e) => setPasswordHint((e.currentTarget as HTMLInputElement).value)}
|
||||||
<div>
|
/>
|
||||||
<label className="field">
|
<div className="field-help">{t('txt_password_hint_register_help')}</div>
|
||||||
<span>{t('txt_authenticator_key')}</span>
|
</label>
|
||||||
<input className="input" value={secret} disabled={totpLocked} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
|
<button
|
||||||
</label>
|
type="button"
|
||||||
<label className="field">
|
className="btn btn-secondary"
|
||||||
<span>{t('txt_verification_code')}</span>
|
onClick={() => void props.onSavePasswordHint(passwordHint)}
|
||||||
<input className="input" value={token} disabled={totpLocked} onInput={(e) => setToken((e.currentTarget as HTMLInputElement).value)} />
|
>
|
||||||
</label>
|
{t('txt_save_profile')}
|
||||||
<div className="actions">
|
</button>
|
||||||
<button type="button" className="btn btn-primary" disabled={totpLocked} onClick={() => void enableTotp()}>
|
</section>
|
||||||
<ShieldCheck size={14} className="btn-icon" />
|
|
||||||
{totpLocked ? t('txt_enabled') : t('txt_enable_totp')}
|
<section className="card settings-module">
|
||||||
</button>
|
<h3>{t('txt_totp')}</h3>
|
||||||
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => setSecret(randomBase32Secret(32))}>
|
{totpLocked && <div className="status-ok">{t('txt_totp_is_enabled_for_this_account')}</div>}
|
||||||
<RefreshCw size={14} className="btn-icon" />
|
<div className="totp-grid">
|
||||||
{t('txt_regenerate')}
|
<div className="totp-qr">
|
||||||
</button>
|
<img src={qrDataUrl} alt="TOTP QR" />
|
||||||
<button
|
</div>
|
||||||
type="button"
|
<div>
|
||||||
className="btn btn-secondary"
|
<div>
|
||||||
disabled={totpLocked}
|
<label className="field">
|
||||||
onClick={() => {
|
<span>{t('txt_authenticator_key')}</span>
|
||||||
void copyTextToClipboard(secret, { successMessage: t('txt_secret_copied') });
|
<input className="input" value={secret} disabled={totpLocked} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
|
||||||
}}
|
</label>
|
||||||
>
|
<label className="field">
|
||||||
<Clipboard size={14} className="btn-icon" />
|
<span>{t('txt_verification_code')}</span>
|
||||||
{t('txt_copy_secret')}
|
<input className="input" value={token} disabled={totpLocked} onInput={(e) => setToken((e.currentTarget as HTMLInputElement).value)} />
|
||||||
</button>
|
</label>
|
||||||
</div>
|
<div className="actions">
|
||||||
</div>
|
<button type="button" className="btn btn-primary" disabled={totpLocked} onClick={() => void enableTotp()}>
|
||||||
|
<ShieldCheck size={14} className="btn-icon" />
|
||||||
|
{totpLocked ? t('txt_enabled') : t('txt_enable_totp')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => setSecret(randomBase32Secret(32))}>
|
||||||
|
<RefreshCw size={14} className="btn-icon" />
|
||||||
|
{t('txt_regenerate')}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={totpLocked}
|
||||||
|
onClick={() => {
|
||||||
|
void copyTextToClipboard(secret, { successMessage: t('txt_secret_copied') });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Clipboard size={14} className="btn-icon" />
|
||||||
|
{t('txt_copy_secret')}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-danger" disabled={!totpLocked} onClick={props.onOpenDisableTotp}>
|
|
||||||
<ShieldOff size={14} className="btn-icon" />
|
|
||||||
{t('txt_disable_totp')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="btn btn-danger" disabled={!totpLocked} onClick={props.onOpenDisableTotp}>
|
||||||
|
<ShieldOff size={14} className="btn-icon" />
|
||||||
|
{t('txt_disable_totp')}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="settings-subcard">
|
<section className="card settings-module">
|
||||||
<h3>{t('txt_recovery_code')}</h3>
|
<h3>{t('txt_recovery_code_and_api_key')}</h3>
|
||||||
<p className="muted-inline settings-field-note">
|
<div className="sensitive-actions-grid">
|
||||||
{t('txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically')}
|
<div className="sensitive-action">
|
||||||
</p>
|
<div>
|
||||||
<label className="field">
|
<h4>{t('txt_recovery_code')}</h4>
|
||||||
<span>{t('txt_master_password')}</span>
|
<p className="muted-inline settings-field-note">
|
||||||
<input
|
{t('txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically')}
|
||||||
className="input"
|
</p>
|
||||||
type="password"
|
</div>
|
||||||
value={recoveryMasterPassword}
|
|
||||||
onInput={(e) => setRecoveryMasterPassword((e.currentTarget as HTMLInputElement).value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => void loadRecoveryCode()}>
|
<button type="button" className="btn btn-secondary" onClick={() => openMasterPasswordPrompt('recovery')}>
|
||||||
<ShieldCheck size={14} className="btn-icon" />
|
<ShieldCheck size={14} className="btn-icon" />
|
||||||
{t('txt_view_recovery_code')}
|
{t('txt_view_recovery_code')}
|
||||||
</button>
|
</button>
|
||||||
@@ -298,25 +333,19 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{recoveryCode && (
|
{recoveryCode && (
|
||||||
<div className="card recovery-code-card">
|
<div className="recovery-code-card">
|
||||||
<div className="recovery-code-value">{recoveryCode}</div>
|
<div className="recovery-code-value">{recoveryCode}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="settings-subcard">
|
<div className="sensitive-action">
|
||||||
<h3>{t('txt_api_key')}</h3>
|
<div>
|
||||||
<label className="field">
|
<h4>{t('txt_api_key')}</h4>
|
||||||
<span>{t('txt_master_password')}</span>
|
<p className="muted-inline settings-field-note">{t('txt_api_key_dialog_intro')}</p>
|
||||||
<input
|
</div>
|
||||||
className="input"
|
|
||||||
type="password"
|
|
||||||
value={apiKeyMasterPassword}
|
|
||||||
onInput={(e) => setApiKeyMasterPassword((e.currentTarget as HTMLInputElement).value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => void loadApiKey()}>
|
<button type="button" className="btn btn-secondary" onClick={() => openMasterPasswordPrompt('apiKey')}>
|
||||||
<KeyRound size={14} className="btn-icon" />
|
<KeyRound size={14} className="btn-icon" />
|
||||||
{t('txt_view_api_key')}
|
{t('txt_view_api_key')}
|
||||||
</button>
|
</button>
|
||||||
@@ -332,6 +361,28 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={masterPasswordPrompt !== null}
|
||||||
|
title={masterPasswordPromptTitle}
|
||||||
|
message={t('txt_enter_master_password_to_continue')}
|
||||||
|
confirmText={t('txt_continue')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
confirmDisabled={masterPasswordPromptSubmitting || !masterPasswordPromptValue.trim()}
|
||||||
|
cancelDisabled={masterPasswordPromptSubmitting}
|
||||||
|
onConfirm={() => void submitMasterPasswordPrompt()}
|
||||||
|
onCancel={closeMasterPasswordPrompt}
|
||||||
|
>
|
||||||
|
<label className="field">
|
||||||
|
<span>{t('txt_master_password')}</span>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={masterPasswordPromptValue}
|
||||||
|
onInput={(e) => setMasterPasswordPromptValue((e.currentTarget as HTMLInputElement).value)}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</ConfirmDialog>
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
open={apiKeyDialogOpen}
|
open={apiKeyDialogOpen}
|
||||||
title={t('txt_api_key')}
|
title={t('txt_api_key')}
|
||||||
@@ -381,7 +432,7 @@ export default function SettingsPage(props: SettingsPageProps) {
|
|||||||
danger
|
danger
|
||||||
onConfirm={() => {
|
onConfirm={() => {
|
||||||
setRotateApiKeyConfirmOpen(false);
|
setRotateApiKeyConfirmOpen(false);
|
||||||
void doRotateApiKey();
|
openMasterPasswordPrompt('rotateApiKey');
|
||||||
}}
|
}}
|
||||||
onCancel={() => setRotateApiKeyConfirmOpen(false)}
|
onCancel={() => setRotateApiKeyConfirmOpen(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
+33
-3
@@ -309,6 +309,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_bulk_delete_sends_failed: "Bulk delete sends failed",
|
txt_bulk_delete_sends_failed: "Bulk delete sends failed",
|
||||||
txt_bulk_move_failed: "Bulk move failed",
|
txt_bulk_move_failed: "Bulk move failed",
|
||||||
txt_cancel: "Cancel",
|
txt_cancel: "Cancel",
|
||||||
|
txt_continue: "Continue",
|
||||||
txt_card: "Card",
|
txt_card: "Card",
|
||||||
txt_card_details: "Card Details",
|
txt_card_details: "Card Details",
|
||||||
txt_cardholder_name: "Cardholder Name",
|
txt_cardholder_name: "Cardholder Name",
|
||||||
@@ -417,6 +418,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_encrypted_file_2: "Encrypted file",
|
txt_encrypted_file_2: "Encrypted file",
|
||||||
txt_enter_a_folder_name: "Enter a folder name.",
|
txt_enter_a_folder_name: "Enter a folder name.",
|
||||||
txt_enter_master_password_to_disable_two_step_verification: "Enter master password to disable two-step verification.",
|
txt_enter_master_password_to_disable_two_step_verification: "Enter master password to disable two-step verification.",
|
||||||
|
txt_enter_master_password_to_continue: "Enter your master password to continue.",
|
||||||
txt_enter_master_password_to_view_this_item: "Enter master password to view this item.",
|
txt_enter_master_password_to_view_this_item: "Enter master password to view this item.",
|
||||||
txt_expiration_date: "Expiration Date",
|
txt_expiration_date: "Expiration Date",
|
||||||
txt_expiration_days_0_never: "Expiration Days (0 = never)",
|
txt_expiration_days_0_never: "Expiration Days (0 = never)",
|
||||||
@@ -598,6 +600,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_recover_two_step_login: "Recover Two-step Login",
|
txt_recover_two_step_login: "Recover Two-step Login",
|
||||||
txt_recovered_but_auto_login_failed_please_sign_in: "Recovered but auto-login failed, please sign in.",
|
txt_recovered_but_auto_login_failed_please_sign_in: "Recovered but auto-login failed, please sign in.",
|
||||||
txt_recovery_code: "Recovery Code",
|
txt_recovery_code: "Recovery Code",
|
||||||
|
txt_recovery_code_and_api_key: "Recovery Code and API Key",
|
||||||
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",
|
||||||
@@ -1041,6 +1044,7 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_confirm_master_password: '确认主密码',
|
txt_confirm_master_password: '确认主密码',
|
||||||
txt_submit: '提交',
|
txt_submit: '提交',
|
||||||
txt_cancel: '取消',
|
txt_cancel: '取消',
|
||||||
|
txt_continue: '继续',
|
||||||
txt_yes: '是',
|
txt_yes: '是',
|
||||||
txt_no: '否',
|
txt_no: '否',
|
||||||
txt_loading: '加载中...',
|
txt_loading: '加载中...',
|
||||||
@@ -1308,6 +1312,7 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_encrypted_file_2: '加密文件',
|
txt_encrypted_file_2: '加密文件',
|
||||||
txt_enter_a_folder_name: '请输入文件夹名称',
|
txt_enter_a_folder_name: '请输入文件夹名称',
|
||||||
txt_enter_master_password_to_disable_two_step_verification: '输入主密码以禁用两步验证',
|
txt_enter_master_password_to_disable_two_step_verification: '输入主密码以禁用两步验证',
|
||||||
|
txt_enter_master_password_to_continue: '输入主密码以继续',
|
||||||
txt_enter_master_password_to_view_this_item: '输入主密码以查看此项目',
|
txt_enter_master_password_to_view_this_item: '输入主密码以查看此项目',
|
||||||
txt_expiry: '有效期',
|
txt_expiry: '有效期',
|
||||||
txt_expiry_month: '有效期月',
|
txt_expiry_month: '有效期月',
|
||||||
@@ -1376,6 +1381,7 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_recover_2fa_failed: '恢复 2FA 失败',
|
txt_recover_2fa_failed: '恢复 2FA 失败',
|
||||||
txt_recovered_but_auto_login_failed_please_sign_in: '已恢复,但自动登录失败,请手动登录',
|
txt_recovered_but_auto_login_failed_please_sign_in: '已恢复,但自动登录失败,请手动登录',
|
||||||
txt_recovery_code_copied: '恢复代码已复制',
|
txt_recovery_code_copied: '恢复代码已复制',
|
||||||
|
txt_recovery_code_and_api_key: '恢复代码和 API 密钥',
|
||||||
txt_recovery_code_is_empty: '恢复代码为空',
|
txt_recovery_code_is_empty: '恢复代码为空',
|
||||||
txt_recovery_code_loaded: '恢复代码已加载',
|
txt_recovery_code_loaded: '恢复代码已加载',
|
||||||
txt_api_key: 'API 密钥',
|
txt_api_key: 'API 密钥',
|
||||||
@@ -1488,16 +1494,40 @@ zhCNOverrides.txt_back = '返回';
|
|||||||
messages.en.txt_auto_lock = 'Auto-lock';
|
messages.en.txt_auto_lock = 'Auto-lock';
|
||||||
messages.en.txt_auto_lock_description = 'Locks after inactivity. Closing and reopening the page always starts locked.';
|
messages.en.txt_auto_lock_description = 'Locks after inactivity. Closing and reopening the page always starts locked.';
|
||||||
messages.en.txt_auto_lock_updated = 'Auto-lock updated';
|
messages.en.txt_auto_lock_updated = 'Auto-lock updated';
|
||||||
|
messages.en.txt_session_timeout = 'Session timeout';
|
||||||
|
messages.en.txt_session_timeout_updated = 'Session timeout updated';
|
||||||
|
messages.en.txt_timeout_time = 'Timeout time';
|
||||||
|
messages.en.txt_timeout_action = 'Timeout action';
|
||||||
|
messages.en.txt_timeout_action_logout = 'Log out';
|
||||||
|
messages.en.txt_timeout_action_lock = 'Lock';
|
||||||
|
messages.en.txt_in_planning = 'In planning';
|
||||||
messages.en.txt_security_preferences = 'Security Preferences';
|
messages.en.txt_security_preferences = 'Security Preferences';
|
||||||
|
messages.en.txt_timeout_1_minute = '1 minute';
|
||||||
|
messages.en.txt_timeout_5_minutes = '5 minutes';
|
||||||
|
messages.en.txt_timeout_15_minutes = '15 minutes';
|
||||||
|
messages.en.txt_timeout_30_minutes = '30 minutes';
|
||||||
|
messages.en.txt_timeout_never = 'Never';
|
||||||
messages.en.txt_lock_after_1_minute = 'After 1 minute';
|
messages.en.txt_lock_after_1_minute = 'After 1 minute';
|
||||||
messages.en.txt_lock_after_5_minutes = 'After 5 minutes';
|
messages.en.txt_lock_after_5_minutes = 'After 5 minutes';
|
||||||
messages.en.txt_lock_after_15_minutes = 'After 15 minutes';
|
messages.en.txt_lock_after_15_minutes = 'After 15 minutes';
|
||||||
messages.en.txt_lock_after_30_minutes = 'After 30 minutes';
|
messages.en.txt_lock_after_30_minutes = 'After 30 minutes';
|
||||||
messages.en.txt_lock_after_never = 'Never for inactivity';
|
messages.en.txt_lock_after_never = 'Never for inactivity';
|
||||||
zhCNOverrides.txt_auto_lock = '自动锁定';
|
zhCNOverrides.txt_auto_lock = '会话超时';
|
||||||
zhCNOverrides.txt_auto_lock_description = '页面闲置后锁定;关闭页面或浏览器后再次打开始终进入锁定页。';
|
zhCNOverrides.txt_auto_lock_description = '页面闲置后执行会话超时动作;关闭页面或浏览器后再次打开始终进入锁定页。';
|
||||||
zhCNOverrides.txt_auto_lock_updated = '自动锁定时间已更新';
|
zhCNOverrides.txt_auto_lock_updated = '会话超时已更新';
|
||||||
|
zhCNOverrides.txt_session_timeout = '会话超时';
|
||||||
|
zhCNOverrides.txt_session_timeout_updated = '会话超时已更新';
|
||||||
|
zhCNOverrides.txt_timeout_time = '超时时间';
|
||||||
|
zhCNOverrides.txt_timeout_action = '超时动作';
|
||||||
|
zhCNOverrides.txt_timeout_action_logout = '注销';
|
||||||
|
zhCNOverrides.txt_timeout_action_lock = '锁定';
|
||||||
|
zhCNOverrides.txt_in_planning = '构思中';
|
||||||
zhCNOverrides.txt_security_preferences = '安全偏好';
|
zhCNOverrides.txt_security_preferences = '安全偏好';
|
||||||
|
zhCNOverrides.txt_timeout_1_minute = '1 分钟';
|
||||||
|
zhCNOverrides.txt_timeout_5_minutes = '5 分钟';
|
||||||
|
zhCNOverrides.txt_timeout_15_minutes = '15 分钟';
|
||||||
|
zhCNOverrides.txt_timeout_30_minutes = '30 分钟';
|
||||||
|
zhCNOverrides.txt_timeout_never = '从不';
|
||||||
zhCNOverrides.txt_lock_after_1_minute = '闲置 1 分钟后';
|
zhCNOverrides.txt_lock_after_1_minute = '闲置 1 分钟后';
|
||||||
zhCNOverrides.txt_lock_after_5_minutes = '闲置 5 分钟后';
|
zhCNOverrides.txt_lock_after_5_minutes = '闲置 5 分钟后';
|
||||||
zhCNOverrides.txt_lock_after_15_minutes = '闲置 15 分钟后';
|
zhCNOverrides.txt_lock_after_15_minutes = '闲置 15 分钟后';
|
||||||
|
|||||||
@@ -425,8 +425,57 @@
|
|||||||
@apply mb-2;
|
@apply mb-2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-modules-grid {
|
||||||
|
@apply grid gap-3;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-module {
|
||||||
|
@apply min-w-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-module h3 {
|
||||||
|
@apply mb-4 mt-0 text-base font-extrabold;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-module-placeholder {
|
||||||
|
@apply flex min-h-[150px] flex-col items-center justify-center gap-3 text-base font-extrabold;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-module-placeholder svg {
|
||||||
|
color: var(--primary-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-module .field:last-child,
|
||||||
|
.session-timeout-fields .field {
|
||||||
|
@apply mb-0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-timeout-fields {
|
||||||
|
@apply grid gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensitive-actions-grid {
|
||||||
|
@apply grid gap-3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensitive-action {
|
||||||
|
@apply rounded-lg border p-3.5;
|
||||||
|
border-color: var(--line-soft);
|
||||||
|
background: color-mix(in srgb, var(--surface) 74%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sensitive-action h4 {
|
||||||
|
@apply mb-1 mt-0 text-base font-extrabold;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
.recovery-code-card {
|
.recovery-code-card {
|
||||||
@apply mb-0 mt-2.5;
|
@apply mb-0 mt-2.5 rounded-lg border p-3;
|
||||||
|
border-color: var(--line-soft);
|
||||||
|
background: color-mix(in srgb, var(--surface) 84%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.recovery-code-value {
|
.recovery-code-value {
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
.dialog-card {
|
.dialog-card {
|
||||||
@apply rounded-[20px] border bg-white p-5 text-center;
|
@apply rounded-[20px] border bg-white p-5 text-center;
|
||||||
width: min(460px, 100%);
|
width: min(5000px, 100%);
|
||||||
border: 1px solid var(--line);
|
border: 1px solid var(--line);
|
||||||
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.2);
|
box-shadow: 0 20px 50px rgba(15, 23, 42, 0.2);
|
||||||
transform-origin: 50% 30%;
|
transform-origin: 50% 30%;
|
||||||
|
|||||||
@@ -462,6 +462,11 @@
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-modules-grid,
|
||||||
|
.password-settings-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
.import-export-panel .actions .btn,
|
.import-export-panel .actions .btn,
|
||||||
.settings-subcard .actions .btn,
|
.settings-subcard .actions .btn,
|
||||||
.section-head .actions .btn {
|
.section-head .actions .btn {
|
||||||
|
|||||||
Reference in New Issue
Block a user