mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 06:50:10 +00:00
feat(i18n): add new localization strings for settings and two-step login across multiple languages
style: adjust grid layout for app main and add responsive styles for settings category style: enhance management styles with new settings category layout and tabs style: improve responsive design for settings modules and submodules
This commit is contained in:
@@ -1939,6 +1939,7 @@ export default function App() {
|
||||
session,
|
||||
mobileLayout,
|
||||
mobileSidebarToggleKey,
|
||||
themePreference,
|
||||
importRoute: IMPORT_ROUTE,
|
||||
settingsHomeRoute: SETTINGS_HOME_ROUTE,
|
||||
settingsAccountRoute: SETTINGS_ACCOUNT_ROUTE,
|
||||
@@ -1966,6 +1967,7 @@ export default function App() {
|
||||
onNavigate: navigate,
|
||||
onLogout: handleLogout,
|
||||
onNotify: pushToast,
|
||||
onThemePreferenceChange: setThemePreference,
|
||||
onImport: vaultSendActions.importVault,
|
||||
onImportEncryptedRaw: vaultSendActions.importEncryptedRaw,
|
||||
onExport: vaultSendActions.exportVault,
|
||||
|
||||
@@ -39,6 +39,7 @@ export interface AppMainRoutesProps {
|
||||
session: SessionState | null;
|
||||
mobileLayout: boolean;
|
||||
mobileSidebarToggleKey: number;
|
||||
themePreference: 'system' | 'light' | 'dark';
|
||||
importRoute: string;
|
||||
settingsHomeRoute: string;
|
||||
settingsAccountRoute: string;
|
||||
@@ -66,6 +67,7 @@ export interface AppMainRoutesProps {
|
||||
onNavigate: (path: string) => void;
|
||||
onLogout: () => void;
|
||||
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
onThemePreferenceChange: (preference: 'system' | 'light' | 'dark') => void;
|
||||
onImport: (
|
||||
payload: CiphersImportPayload,
|
||||
options: { folderMode: 'original' | 'none' | 'target'; targetFolderId: string | null },
|
||||
@@ -266,8 +268,11 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
<SettingsPage
|
||||
profile={props.profile}
|
||||
totpEnabled={props.totpEnabled}
|
||||
themePreference={props.themePreference}
|
||||
lockTimeoutMinutes={props.lockTimeoutMinutes}
|
||||
sessionTimeoutAction={props.sessionTimeoutAction}
|
||||
onThemePreferenceChange={props.onThemePreferenceChange}
|
||||
onVerifyMasterPassword={props.onVerifyMasterPassword}
|
||||
onChangePassword={props.onChangePassword}
|
||||
onSavePasswordHint={props.onSavePasswordHint}
|
||||
onEnableTotp={props.onEnableTotp}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createPortal } from 'preact/compat';
|
||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||
import type { ComponentChildren } from 'preact';
|
||||
import { TriangleAlert } from 'lucide-preact';
|
||||
import { TriangleAlert, X } from 'lucide-preact';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
interface ConfirmDialogProps {
|
||||
@@ -14,6 +14,8 @@ interface ConfirmDialogProps {
|
||||
cancelText?: string;
|
||||
danger?: boolean;
|
||||
hideCancel?: boolean;
|
||||
hideConfirm?: boolean;
|
||||
closeButton?: boolean;
|
||||
confirmDisabled?: boolean;
|
||||
cancelDisabled?: boolean;
|
||||
onConfirm: () => void;
|
||||
@@ -211,17 +213,33 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
{props.closeButton && (
|
||||
<button
|
||||
type="button"
|
||||
className="dialog-close-btn"
|
||||
aria-label={t('txt_close')}
|
||||
disabled={props.cancelDisabled}
|
||||
onClick={() => {
|
||||
if (props.cancelDisabled) return;
|
||||
props.onCancel();
|
||||
}}
|
||||
>
|
||||
<X size={18} />
|
||||
</button>
|
||||
)}
|
||||
<h3 id={titleId} className="dialog-title">{props.title}</h3>
|
||||
<div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>
|
||||
{props.children}
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
|
||||
disabled={props.confirmDisabled}
|
||||
data-dialog-confirm="true"
|
||||
>
|
||||
{props.confirmText || t('txt_yes')}
|
||||
</button>
|
||||
{!props.hideConfirm && (
|
||||
<button
|
||||
type="submit"
|
||||
className={`btn ${props.danger ? 'btn-danger' : 'btn-primary'} dialog-btn`}
|
||||
disabled={props.confirmDisabled}
|
||||
data-dialog-confirm="true"
|
||||
>
|
||||
{props.confirmText || t('txt_yes')}
|
||||
</button>
|
||||
)}
|
||||
{!props.hideCancel && (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -9,8 +9,11 @@ import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
interface SettingsPageProps {
|
||||
profile: Profile;
|
||||
totpEnabled: boolean;
|
||||
themePreference: ThemePreference;
|
||||
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||
sessionTimeoutAction: 'lock' | 'logout';
|
||||
onThemePreferenceChange: (preference: ThemePreference) => void;
|
||||
onVerifyMasterPassword: (email: string, password: string) => Promise<void>;
|
||||
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
||||
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
|
||||
onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise<void>;
|
||||
@@ -27,11 +30,15 @@ interface SettingsPageProps {
|
||||
onNotify?: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
}
|
||||
|
||||
type ThemePreference = 'system' | 'light' | 'dark';
|
||||
type SettingsSection = 'appearance' | 'session' | 'masterPassword' | 'twoStep' | 'keys';
|
||||
|
||||
type MasterPasswordPromptAction =
|
||||
| 'enableTotp'
|
||||
| 'recovery'
|
||||
| 'apiKey'
|
||||
| 'rotateApiKey'
|
||||
| 'manageTotp'
|
||||
| 'createPasskey'
|
||||
| 'enablePasskeyDirectUnlock'
|
||||
| 'deletePasskey';
|
||||
@@ -99,12 +106,18 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
const [accountPasskeyName, setAccountPasskeyName] = useState(t('txt_account_passkey'));
|
||||
const [accountPasskeyDirectUnlock, setAccountPasskeyDirectUnlock] = useState(false);
|
||||
const [accountPasskeyPromptId, setAccountPasskeyPromptId] = useState<string | null>(null);
|
||||
const [createPasskeyDialogOpen, setCreatePasskeyDialogOpen] = useState(false);
|
||||
const [createPasskeyMasterPassword, setCreatePasskeyMasterPassword] = useState('');
|
||||
const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false);
|
||||
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
|
||||
const [totpManageDialogOpen, setTotpManageDialogOpen] = useState(false);
|
||||
const [recoveryCodeDialogOpen, setRecoveryCodeDialogOpen] = useState(false);
|
||||
const [totpManagePassword, setTotpManagePassword] = useState('');
|
||||
const [masterPasswordPrompt, setMasterPasswordPrompt] = useState<MasterPasswordPromptAction | null>(null);
|
||||
const [masterPasswordPromptValue, setMasterPasswordPromptValue] = useState('');
|
||||
const [masterPasswordPromptSubmitting, setMasterPasswordPromptSubmitting] = useState(false);
|
||||
const [selectedLocale, setSelectedLocale] = useState<Locale>(() => getLocale());
|
||||
const [activeSection, setActiveSection] = useState<SettingsSection>('appearance');
|
||||
|
||||
useEffect(() => {
|
||||
clearLegacyTotpSetupSecrets();
|
||||
@@ -179,6 +192,7 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
} else if (masterPasswordPrompt === 'recovery') {
|
||||
const code = await props.onGetRecoveryCode(masterPassword);
|
||||
setRecoveryCode(code);
|
||||
setRecoveryCodeDialogOpen(true);
|
||||
props.onNotify?.('success', t('txt_recovery_code_loaded'));
|
||||
} else if (masterPasswordPrompt === 'apiKey') {
|
||||
const key = await props.onGetApiKey(masterPassword);
|
||||
@@ -189,9 +203,14 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
setApiKey(key);
|
||||
setApiKeyDialogOpen(true);
|
||||
props.onNotify?.('success', t('txt_api_key_rotated'));
|
||||
} else if (masterPasswordPrompt === 'manageTotp') {
|
||||
await props.onVerifyMasterPassword(props.profile.email, masterPassword);
|
||||
setTotpManagePassword(masterPassword);
|
||||
setTotpManageDialogOpen(true);
|
||||
} else if (masterPasswordPrompt === 'createPasskey') {
|
||||
const credential = await props.onCreateAccountPasskey(accountPasskeyName, masterPassword, accountPasskeyDirectUnlock);
|
||||
if (credential) await refreshAccountPasskeys();
|
||||
await props.onVerifyMasterPassword(props.profile.email, masterPassword);
|
||||
setCreatePasskeyMasterPassword(masterPassword);
|
||||
setCreatePasskeyDialogOpen(true);
|
||||
} else if (masterPasswordPrompt === 'enablePasskeyDirectUnlock') {
|
||||
if (!accountPasskeyPromptId) throw new Error(t('txt_account_passkey_not_found'));
|
||||
await props.onEnableAccountPasskeyDirectUnlock(accountPasskeyPromptId, masterPassword);
|
||||
@@ -218,13 +237,15 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
? t('txt_view_recovery_code')
|
||||
: masterPasswordPrompt === 'rotateApiKey'
|
||||
? t('txt_rotate_api_key')
|
||||
: masterPasswordPrompt === 'createPasskey'
|
||||
? t('txt_add_account_passkey')
|
||||
: masterPasswordPrompt === 'enablePasskeyDirectUnlock'
|
||||
? t('txt_enable_passkey_direct_unlock')
|
||||
: masterPasswordPrompt === 'deletePasskey'
|
||||
? t('txt_delete_account_passkey')
|
||||
: t('txt_view_api_key');
|
||||
: masterPasswordPrompt === 'manageTotp'
|
||||
? t('txt_totp')
|
||||
: masterPasswordPrompt === 'createPasskey'
|
||||
? t('txt_add_account_passkey')
|
||||
: masterPasswordPrompt === 'enablePasskeyDirectUnlock'
|
||||
? t('txt_enable_passkey_direct_unlock')
|
||||
: masterPasswordPrompt === 'deletePasskey'
|
||||
? t('txt_delete_account_passkey')
|
||||
: t('txt_view_api_key');
|
||||
|
||||
function accountPasskeyStatusText(credential: AccountPasskeyCredential): string {
|
||||
if (credential.prfStatus === 0) return t('txt_direct_unlock');
|
||||
@@ -239,126 +260,398 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
function closeTotpManageDialog(): void {
|
||||
setTotpManageDialogOpen(false);
|
||||
setTotpManagePassword('');
|
||||
}
|
||||
|
||||
async function enableTotpFromManageDialog(): Promise<void> {
|
||||
if (totpLocked) return;
|
||||
if (!secret.trim() || !token.trim()) {
|
||||
props.onNotify?.('error', t('txt_secret_and_code_are_required'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await props.onEnableTotp(secret, token, totpManagePassword);
|
||||
setTotpLocked(true);
|
||||
} catch (error) {
|
||||
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_enable_totp_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
function closeCreatePasskeyDialog(): void {
|
||||
setCreatePasskeyDialogOpen(false);
|
||||
setCreatePasskeyMasterPassword('');
|
||||
setAccountPasskeyName(t('txt_account_passkey'));
|
||||
setAccountPasskeyDirectUnlock(false);
|
||||
}
|
||||
|
||||
async function submitCreatePasskeyDialog(): Promise<void> {
|
||||
if (!createPasskeyMasterPassword || masterPasswordPromptSubmitting) return;
|
||||
setMasterPasswordPromptSubmitting(true);
|
||||
try {
|
||||
const credential = await props.onCreateAccountPasskey(accountPasskeyName, createPasskeyMasterPassword, accountPasskeyDirectUnlock);
|
||||
if (credential) await refreshAccountPasskeys();
|
||||
closeCreatePasskeyDialog();
|
||||
} catch (error) {
|
||||
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_account_passkeys_load_failed'));
|
||||
} finally {
|
||||
setMasterPasswordPromptSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const settingsSections: Array<{ id: SettingsSection; label: string }> = [
|
||||
{ id: 'appearance', label: t('txt_settings_appearance') },
|
||||
{ id: 'session', label: t('txt_session_timeout') },
|
||||
{ id: 'masterPassword', label: t('txt_master_password') },
|
||||
{ id: 'twoStep', label: t('txt_two_step_login') },
|
||||
{ id: 'keys', label: t('txt_keys') },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="settings-modules-grid">
|
||||
<section className="card settings-module">
|
||||
<h3>{t('txt_session_timeout')}</h3>
|
||||
<div className="session-timeout-fields">
|
||||
<label className="field">
|
||||
<span>{t('txt_timeout_time')}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={String(props.lockTimeoutMinutes)}
|
||||
onInput={(e) => props.onLockTimeoutChange(Number((e.currentTarget as HTMLSelectElement).value) as 0 | 1 | 5 | 15 | 30)}
|
||||
<div className="settings-page-categorized">
|
||||
<div className="settings-category-layout">
|
||||
<nav className="settings-category-tabs" aria-label={t('nav_account_settings')}>
|
||||
{settingsSections.map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
className={`settings-category-tab ${activeSection === section.id ? 'active' : ''}`}
|
||||
onClick={() => setActiveSection(section.id)}
|
||||
>
|
||||
{LOCK_TIMEOUT_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{t(option.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_timeout_action')}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={props.sessionTimeoutAction}
|
||||
onInput={(e) => props.onSessionTimeoutActionChange((e.currentTarget as HTMLSelectElement).value === 'logout' ? 'logout' : 'lock')}
|
||||
>
|
||||
<option value="logout">{t('txt_timeout_action_logout')}</option>
|
||||
<option value="lock">{t('txt_timeout_action_lock')}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
{section.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<section className="card settings-module">
|
||||
<h3>{t('txt_language')}</h3>
|
||||
<label className="field">
|
||||
<span>{t('txt_display_language')}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={selectedLocale}
|
||||
onInput={(e) => void changeLocale((e.currentTarget as HTMLSelectElement).value as Locale)}
|
||||
>
|
||||
{AVAILABLE_LOCALES.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="field-help">{t('txt_language_saved_locally')}</div>
|
||||
</label>
|
||||
</section>
|
||||
<section className="settings-category-panel">
|
||||
{activeSection === 'appearance' && (
|
||||
<div className="settings-section-stack">
|
||||
<section className="settings-submodule">
|
||||
<label className="field">
|
||||
<span>{t('txt_theme')}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={props.themePreference}
|
||||
onInput={(e) => props.onThemePreferenceChange((e.currentTarget as HTMLSelectElement).value as ThemePreference)}
|
||||
>
|
||||
<option value="system">{t('txt_use_system_theme')}</option>
|
||||
<option value="light">{t('txt_light_theme')}</option>
|
||||
<option value="dark">{t('txt_dark_theme')}</option>
|
||||
</select>
|
||||
<div className="field-help">{t('txt_theme_saved_locally')}</div>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<section className="card settings-module">
|
||||
<h3>{t('txt_change_master_password')}</h3>
|
||||
<section className="settings-submodule">
|
||||
<label className="field">
|
||||
<span>{t('txt_display_language')}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={selectedLocale}
|
||||
onInput={(e) => void changeLocale((e.currentTarget as HTMLSelectElement).value as Locale)}
|
||||
>
|
||||
{AVAILABLE_LOCALES.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="field-help">{t('txt_display_language_help')}</div>
|
||||
</label>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'session' && (
|
||||
<div className="settings-section-stack">
|
||||
<section className="settings-submodule">
|
||||
<div className="session-timeout-fields">
|
||||
<label className="field">
|
||||
<span>{t('txt_timeout_time')}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={String(props.lockTimeoutMinutes)}
|
||||
onInput={(e) => props.onLockTimeoutChange(Number((e.currentTarget as HTMLSelectElement).value) as 0 | 1 | 5 | 15 | 30)}
|
||||
>
|
||||
{LOCK_TIMEOUT_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{t(option.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_timeout_action')}</span>
|
||||
<select
|
||||
className="input"
|
||||
value={props.sessionTimeoutAction}
|
||||
onInput={(e) => props.onSessionTimeoutActionChange((e.currentTarget as HTMLSelectElement).value === 'logout' ? 'logout' : 'lock')}
|
||||
>
|
||||
<option value="logout">{t('txt_timeout_action_logout')}</option>
|
||||
<option value="lock">{t('txt_timeout_action_lock')}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'masterPassword' && (
|
||||
<div className="settings-section-stack">
|
||||
<section className="settings-submodule">
|
||||
<h3>{t('txt_change_master_password')}</h3>
|
||||
<label className="field">
|
||||
<span>{t('txt_current_password')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onInput={(e) => setCurrentPassword((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="settings-vertical-fields">
|
||||
<label className="field">
|
||||
<span>{t('txt_new_password')}</span>
|
||||
<input className="input" type="password" value={newPassword} onInput={(e) => setNewPassword((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_confirm_password')}</span>
|
||||
<input className="input" type="password" value={newPassword2} onInput={(e) => setNewPassword2((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
onClick={() => void props.onChangePassword(currentPassword, newPassword, newPassword2)}
|
||||
>
|
||||
<KeyRound size={14} className="btn-icon" />
|
||||
{t('txt_change_password')}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="settings-submodule">
|
||||
<label className="field">
|
||||
<span>{t('txt_password_hint_optional')}</span>
|
||||
<input
|
||||
className="input"
|
||||
maxLength={120}
|
||||
value={passwordHint}
|
||||
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>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => void props.onSavePasswordHint(passwordHint)}
|
||||
>
|
||||
{t('txt_save')}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="settings-submodule account-passkeys-module">
|
||||
<div className="settings-module-head">
|
||||
<h3>{t('txt_account_passkeys')}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small"
|
||||
disabled={accountPasskeysLoading}
|
||||
title={t('txt_refresh')}
|
||||
aria-label={t('txt_refresh')}
|
||||
onClick={() => void refreshAccountPasskeys()}
|
||||
>
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
{t('txt_refresh')}
|
||||
</button>
|
||||
</div>
|
||||
<p className="muted-inline settings-field-note">{t('txt_account_passkey_login_only_help')}</p>
|
||||
<div className="actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={masterPasswordPromptSubmitting}
|
||||
onClick={() => openMasterPasswordPrompt('createPasskey')}
|
||||
>
|
||||
<KeyRound size={14} className="btn-icon" />
|
||||
{t('txt_add_account_passkey')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="account-passkeys-list">
|
||||
{accountPasskeysLoading ? (
|
||||
<div className="settings-module-placeholder">
|
||||
<RefreshCw size={20} />
|
||||
<span>{t('txt_loading')}</span>
|
||||
</div>
|
||||
) : accountPasskeys.length === 0 ? (
|
||||
<div className="settings-module-placeholder">
|
||||
<KeyRound size={20} />
|
||||
<span>{t('txt_no_account_passkeys')}</span>
|
||||
</div>
|
||||
) : (
|
||||
accountPasskeys.map((credential) => (
|
||||
<div key={credential.id} className="account-passkey-row">
|
||||
<div className="account-passkey-main">
|
||||
<strong>{credential.name || t('txt_account_passkey')}</strong>
|
||||
<small>{t('txt_created_value', { value: formatDateTime(credential.creationDate) })}</small>
|
||||
</div>
|
||||
<span className={`account-passkey-status account-passkey-status-${credential.prfStatus}`}>
|
||||
{accountPasskeyStatusText(credential)}
|
||||
</span>
|
||||
<div className="actions account-passkey-actions">
|
||||
{credential.prfStatus === 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small"
|
||||
disabled={masterPasswordPromptSubmitting}
|
||||
onClick={() => openMasterPasswordPrompt('enablePasskeyDirectUnlock', credential.id)}
|
||||
>
|
||||
<ShieldCheck size={14} className="btn-icon" />
|
||||
{t('txt_enable_passkey_direct_unlock')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger small"
|
||||
disabled={masterPasswordPromptSubmitting}
|
||||
onClick={() => openMasterPasswordPrompt('deletePasskey', credential.id)}
|
||||
>
|
||||
<Trash2 size={14} className="btn-icon" />
|
||||
{t('txt_delete')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'twoStep' && (
|
||||
<div className="settings-section-stack">
|
||||
<section className="settings-submodule two-step-recovery-warning">
|
||||
<div className="two-step-warning-head">
|
||||
<ShieldOff size={16} aria-hidden="true" />
|
||||
<strong>{t('txt_warning')}</strong>
|
||||
</div>
|
||||
<p>{t('txt_two_step_recovery_code_warning')}</p>
|
||||
<button type="button" className="btn btn-danger" onClick={() => openMasterPasswordPrompt('recovery')}>
|
||||
{t('txt_view_recovery_code')}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="settings-submodule two-step-providers-module">
|
||||
<h3>{t('txt_providers')}</h3>
|
||||
<div className="two-step-provider-list">
|
||||
<div className="two-step-provider-row">
|
||||
<div className="two-step-provider-icon">
|
||||
<ShieldCheck size={28} />
|
||||
</div>
|
||||
<div className="two-step-provider-copy">
|
||||
<div className="two-step-provider-title">
|
||||
<strong>{t('txt_authenticator_app')}</strong>
|
||||
{totpLocked && <span className="two-step-enabled-badge">{t('txt_enabled')}</span>}
|
||||
</div>
|
||||
<span>{t('txt_authenticator_app_help')}</span>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => openMasterPasswordPrompt('manageTotp')}>
|
||||
{t('txt_manage')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="two-step-provider-row">
|
||||
<div className="two-step-provider-icon">
|
||||
<KeyRound size={28} />
|
||||
</div>
|
||||
<div className="two-step-provider-copy">
|
||||
<strong>{t('txt_passkeys')}</strong>
|
||||
<span>{t('txt_passkey_provider_help')}</span>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" disabled>
|
||||
{t('txt_manage')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="two-step-provider-row">
|
||||
<div className="two-step-provider-icon two-step-provider-yubico">yubico</div>
|
||||
<div className="two-step-provider-copy">
|
||||
<strong>{t('txt_yubico_otp_security_key')}</strong>
|
||||
<span>{t('txt_yubico_otp_security_key_help')}</span>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" disabled>
|
||||
{t('txt_manage')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeSection === 'keys' && (
|
||||
<div className="settings-section-stack">
|
||||
<section className="settings-submodule sensitive-action">
|
||||
<div>
|
||||
<h3>{t('txt_api_key')}</h3>
|
||||
<p className="muted-inline settings-field-note">{t('txt_api_key_dialog_intro')}</p>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => openMasterPasswordPrompt('apiKey')}>
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
<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_current_password')}</span>
|
||||
<span>{t('txt_master_password')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onInput={(e) => setCurrentPassword((e.currentTarget as HTMLInputElement).value)}
|
||||
autoComplete="current-password"
|
||||
value={masterPasswordPromptValue}
|
||||
onInput={(e) => setMasterPasswordPromptValue((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="field-grid">
|
||||
<label className="field">
|
||||
<span>{t('txt_new_password')}</span>
|
||||
<input className="input" type="password" value={newPassword} onInput={(e) => setNewPassword((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_confirm_password')}</span>
|
||||
<input className="input" type="password" value={newPassword2} onInput={(e) => setNewPassword2((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
onClick={() => void props.onChangePassword(currentPassword, newPassword, newPassword2)}
|
||||
>
|
||||
<KeyRound size={14} className="btn-icon" />
|
||||
{t('txt_change_password')}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="card settings-module">
|
||||
<h3>{t('txt_password_hint_optional')}</h3>
|
||||
<label className="field">
|
||||
<span>{t('txt_password_hint')}</span>
|
||||
<input
|
||||
className="input"
|
||||
maxLength={120}
|
||||
value={passwordHint}
|
||||
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>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={() => void props.onSavePasswordHint(passwordHint)}
|
||||
>
|
||||
{t('txt_save_profile')}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="card settings-module">
|
||||
<div className="settings-module-head">
|
||||
<h3>{t('txt_totp')}</h3>
|
||||
{totpLocked && (
|
||||
<span className="totp-status-pill">
|
||||
<ShieldCheck size={14} aria-hidden="true" />
|
||||
{t('txt_enabled')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="totp-grid">
|
||||
<div className="totp-qr">
|
||||
<img src={qrDataUrl} alt="TOTP QR" />
|
||||
</div>
|
||||
<div>
|
||||
</ConfirmDialog>
|
||||
<ConfirmDialog
|
||||
open={totpManageDialogOpen}
|
||||
title={t('txt_totp')}
|
||||
message={totpLocked ? t('txt_totp_enabled') : t('txt_totp_manage_intro')}
|
||||
hideCancel
|
||||
hideConfirm
|
||||
closeButton
|
||||
onConfirm={() => {}}
|
||||
onCancel={closeTotpManageDialog}
|
||||
>
|
||||
<div className="totp-manage-dialog-body">
|
||||
<div className="totp-grid">
|
||||
<div className="totp-qr">
|
||||
<img src={qrDataUrl} alt="TOTP QR" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="field">
|
||||
<span>{t('txt_authenticator_key')}</span>
|
||||
@@ -395,196 +688,90 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
<input className="input" value={token} disabled={totpLocked} onInput={(e) => setToken((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<div className="actions">
|
||||
<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-danger" disabled={!totpLocked} onClick={props.onOpenDisableTotp}>
|
||||
<ShieldOff size={14} className="btn-icon" />
|
||||
{t('txt_disable_totp')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card settings-module account-passkeys-module">
|
||||
<div className="settings-module-head">
|
||||
<h3>{t('txt_account_passkeys')}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small"
|
||||
disabled={accountPasskeysLoading}
|
||||
title={t('txt_refresh')}
|
||||
aria-label={t('txt_refresh')}
|
||||
onClick={() => void refreshAccountPasskeys()}
|
||||
>
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
{t('txt_refresh')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="field-grid">
|
||||
<label className="field">
|
||||
<span>{t('txt_passkey_name')}</span>
|
||||
<input
|
||||
className="input"
|
||||
maxLength={128}
|
||||
value={accountPasskeyName}
|
||||
placeholder={t('txt_account_passkey_name_placeholder')}
|
||||
onInput={(e) => setAccountPasskeyName((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
</label>
|
||||
<div className="field account-passkey-mode-field">
|
||||
<span>{t('txt_account_passkey_mode')}</span>
|
||||
<label className="account-passkey-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={accountPasskeyDirectUnlock}
|
||||
onInput={(e) => setAccountPasskeyDirectUnlock((e.currentTarget as HTMLInputElement).checked)}
|
||||
/>
|
||||
<span>{t('txt_account_passkey_direct_unlock_mode')}</span>
|
||||
</label>
|
||||
<div className="field-help">
|
||||
{accountPasskeyDirectUnlock ? t('txt_account_passkey_direct_unlock_help') : t('txt_account_passkey_login_only_help')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={masterPasswordPromptSubmitting}
|
||||
onClick={() => openMasterPasswordPrompt('createPasskey')}
|
||||
>
|
||||
<KeyRound size={14} className="btn-icon" />
|
||||
{t('txt_add_account_passkey')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="account-passkeys-list">
|
||||
{accountPasskeysLoading ? (
|
||||
<div className="settings-module-placeholder">
|
||||
<RefreshCw size={20} />
|
||||
<span>{t('txt_loading')}</span>
|
||||
</div>
|
||||
) : accountPasskeys.length === 0 ? (
|
||||
<div className="settings-module-placeholder">
|
||||
<KeyRound size={20} />
|
||||
<span>{t('txt_no_account_passkeys')}</span>
|
||||
</div>
|
||||
) : (
|
||||
accountPasskeys.map((credential) => (
|
||||
<div key={credential.id} className="account-passkey-row">
|
||||
<div className="account-passkey-main">
|
||||
<strong>{credential.name || t('txt_account_passkey')}</strong>
|
||||
<small>{t('txt_created_value', { value: formatDateTime(credential.creationDate) })}</small>
|
||||
</div>
|
||||
<span className={`account-passkey-status account-passkey-status-${credential.prfStatus}`}>
|
||||
{accountPasskeyStatusText(credential)}
|
||||
</span>
|
||||
<div className="actions account-passkey-actions">
|
||||
{credential.prfStatus === 1 && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small"
|
||||
disabled={masterPasswordPromptSubmitting}
|
||||
onClick={() => openMasterPasswordPrompt('enablePasskeyDirectUnlock', credential.id)}
|
||||
>
|
||||
<ShieldCheck size={14} className="btn-icon" />
|
||||
{t('txt_enable_passkey_direct_unlock')}
|
||||
</button>
|
||||
)}
|
||||
{totpLocked ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger small"
|
||||
disabled={masterPasswordPromptSubmitting}
|
||||
onClick={() => openMasterPasswordPrompt('deletePasskey', credential.id)}
|
||||
className="btn btn-danger"
|
||||
onClick={() => {
|
||||
closeTotpManageDialog();
|
||||
props.onOpenDisableTotp();
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} className="btn-icon" />
|
||||
{t('txt_delete')}
|
||||
<ShieldOff size={14} className="btn-icon" />
|
||||
{t('txt_disable_totp')}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button type="button" className="btn btn-primary" disabled={!totpManagePassword} onClick={() => void enableTotpFromManageDialog()}>
|
||||
<ShieldCheck size={14} className="btn-icon" />
|
||||
{t('txt_enable_totp')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
<section className="settings-module sensitive-actions-module">
|
||||
<div className="sensitive-actions-grid">
|
||||
<div className="sensitive-action">
|
||||
<div>
|
||||
<h4>{t('txt_recovery_code')}</h4>
|
||||
<p className="muted-inline settings-field-note">
|
||||
{t('txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => openMasterPasswordPrompt('recovery')}>
|
||||
<ShieldCheck size={14} className="btn-icon" />
|
||||
{t('txt_view_recovery_code')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={!recoveryCode}
|
||||
onClick={() => {
|
||||
void copyTextToClipboard(recoveryCode, { successMessage: t('txt_recovery_code_copied') });
|
||||
}}
|
||||
>
|
||||
<Clipboard size={14} className="btn-icon" />
|
||||
{t('txt_copy_code')}
|
||||
</button>
|
||||
</div>
|
||||
{recoveryCode && (
|
||||
<div className="recovery-code-card">
|
||||
<div className="recovery-code-value">{recoveryCode}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="sensitive-action">
|
||||
<div>
|
||||
<h4>{t('txt_api_key')}</h4>
|
||||
<p className="muted-inline settings-field-note">{t('txt_api_key_dialog_intro')}</p>
|
||||
</div>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => openMasterPasswordPrompt('apiKey')}>
|
||||
<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>
|
||||
<ConfirmDialog
|
||||
open={masterPasswordPrompt !== null}
|
||||
title={masterPasswordPromptTitle}
|
||||
message={t('txt_enter_master_password_to_continue')}
|
||||
confirmText={t('txt_continue')}
|
||||
open={recoveryCodeDialogOpen}
|
||||
title={`${t('txt_two_step_login')} ${t('txt_recovery_code')}`}
|
||||
message={t('txt_your_two_step_recovery_code')}
|
||||
hideConfirm
|
||||
closeButton
|
||||
cancelText={t('txt_close')}
|
||||
onConfirm={() => {}}
|
||||
onCancel={() => setRecoveryCodeDialogOpen(false)}
|
||||
afterActions={(
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary dialog-btn"
|
||||
disabled={!recoveryCode}
|
||||
onClick={() => {
|
||||
void copyTextToClipboard(recoveryCode, { successMessage: t('txt_recovery_code_copied') });
|
||||
}}
|
||||
>
|
||||
<Clipboard size={14} className="btn-icon" />
|
||||
{t('txt_copy_code')}
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<div className="two-step-recovery-code-dialog-value">{recoveryCode}</div>
|
||||
</ConfirmDialog>
|
||||
<ConfirmDialog
|
||||
open={createPasskeyDialogOpen}
|
||||
title={t('txt_add_account_passkey')}
|
||||
message={t('txt_name_account_passkey_after_verification')}
|
||||
confirmText={t('txt_save')}
|
||||
cancelText={t('txt_cancel')}
|
||||
confirmDisabled={masterPasswordPromptSubmitting || !masterPasswordPromptValue.trim()}
|
||||
confirmDisabled={masterPasswordPromptSubmitting}
|
||||
cancelDisabled={masterPasswordPromptSubmitting}
|
||||
onConfirm={() => void submitMasterPasswordPrompt()}
|
||||
onCancel={closeMasterPasswordPrompt}
|
||||
onConfirm={() => void submitCreatePasskeyDialog()}
|
||||
onCancel={closeCreatePasskeyDialog}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_master_password')}</span>
|
||||
<span>{t('txt_passkey_name')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={masterPasswordPromptValue}
|
||||
onInput={(e) => setMasterPasswordPromptValue((e.currentTarget as HTMLInputElement).value)}
|
||||
maxLength={128}
|
||||
value={accountPasskeyName}
|
||||
placeholder={t('txt_account_passkey_name_placeholder')}
|
||||
onInput={(e) => setAccountPasskeyName((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
<div className="field-help">{t('txt_account_passkey_name_help')}</div>
|
||||
</label>
|
||||
<div className="field account-passkey-mode-field">
|
||||
<span>{t('txt_account_passkey_mode')}</span>
|
||||
<label className="account-passkey-toggle">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={accountPasskeyDirectUnlock}
|
||||
onInput={(e) => setAccountPasskeyDirectUnlock((e.currentTarget as HTMLInputElement).checked)}
|
||||
/>
|
||||
<span>{t('txt_account_passkey_direct_unlock_mode')}</span>
|
||||
</label>
|
||||
<div className="field-help">
|
||||
{accountPasskeyDirectUnlock ? t('txt_account_passkey_direct_unlock_help') : t('txt_account_passkey_login_only_help')}
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
<ConfirmDialog
|
||||
open={apiKeyDialogOpen}
|
||||
|
||||
@@ -11,6 +11,28 @@ const en: Record<string, string> = {
|
||||
"nav_import_export": "Import & Export",
|
||||
"nav_group_data_backup": "Data & Backup",
|
||||
"nav_group_management": "Management",
|
||||
"txt_settings_appearance": "Appearance",
|
||||
"txt_theme": "Theme",
|
||||
"txt_use_system_theme": "Use system theme",
|
||||
"txt_light_theme": "Light",
|
||||
"txt_dark_theme": "Dark",
|
||||
"txt_theme_saved_locally": "Choose a theme for your web vault.",
|
||||
"txt_display_language_help": "Change the web vault language.",
|
||||
"txt_two_step_login": "Two-step login",
|
||||
"txt_keys": "Keys",
|
||||
"txt_manage": "Manage",
|
||||
"txt_providers": "Providers",
|
||||
"txt_authenticator_app": "Authenticator app",
|
||||
"txt_authenticator_app_help": "Enter a code generated by an authenticator app.",
|
||||
"txt_passkey_provider_help": "Use a FIDO2-compatible security key or biometric authenticator.",
|
||||
"txt_yubico_otp_security_key": "Yubico OTP security key",
|
||||
"txt_yubico_otp_security_key_help": "Use a YubiKey 4, 5, or NEO device.",
|
||||
"txt_setting_coming_soon": "Coming soon.",
|
||||
"txt_totp_manage_intro": "Scan the QR code or enter the key in your authenticator app, then enter the verification code.",
|
||||
"txt_two_step_recovery_code_warning": "If you cannot access your two-step login provider, your one-time recovery code can be used to disable two-step login. Store the recovery code somewhere safe.",
|
||||
"txt_your_two_step_recovery_code": "Your Bitwarden two-step login recovery code:",
|
||||
"txt_name_account_passkey_after_verification": "Passkey created. Name it to help you recognize it.",
|
||||
"txt_account_passkey_name_help": "0 / 50 characters",
|
||||
"txt_page_not_found": "Page Not Found",
|
||||
"txt_page_not_found_hint": "The page may have been removed, expired, or the link is incomplete.",
|
||||
"txt_back_to_home": "Back To Home",
|
||||
|
||||
@@ -11,6 +11,28 @@ const es: Record<string, string> = {
|
||||
"nav_import_export": "Importar y exportar",
|
||||
"nav_group_data_backup": "Datos y copias",
|
||||
"nav_group_management": "Gestión",
|
||||
"txt_settings_appearance": "Apariencia",
|
||||
"txt_theme": "Tema",
|
||||
"txt_use_system_theme": "Usar tema del sistema",
|
||||
"txt_light_theme": "Claro",
|
||||
"txt_dark_theme": "Oscuro",
|
||||
"txt_theme_saved_locally": "Elige un tema para tu bóveda web.",
|
||||
"txt_display_language_help": "Cambia el idioma de la bóveda web.",
|
||||
"txt_two_step_login": "Inicio de sesión en dos pasos",
|
||||
"txt_keys": "Claves",
|
||||
"txt_manage": "Gestionar",
|
||||
"txt_providers": "Proveedores",
|
||||
"txt_authenticator_app": "Aplicación autenticadora",
|
||||
"txt_authenticator_app_help": "Introduce un código generado por una aplicación autenticadora.",
|
||||
"txt_passkey_provider_help": "Usa una llave de seguridad compatible con FIDO2 o autenticación biométrica.",
|
||||
"txt_yubico_otp_security_key": "Llave de seguridad Yubico OTP",
|
||||
"txt_yubico_otp_security_key_help": "Usa un dispositivo YubiKey 4, 5 o NEO.",
|
||||
"txt_setting_coming_soon": "Próximamente.",
|
||||
"txt_totp_manage_intro": "Escanea el código QR o introduce la clave en tu aplicación autenticadora, luego escribe el código de verificación.",
|
||||
"txt_two_step_recovery_code_warning": "Si no puedes acceder a tu proveedor de inicio de sesión en dos pasos, tu código de recuperación de un solo uso puede desactivar el inicio de sesión en dos pasos. Guarda el código en un lugar seguro.",
|
||||
"txt_your_two_step_recovery_code": "Tu código de recuperación de inicio de sesión en dos pasos de Bitwarden:",
|
||||
"txt_name_account_passkey_after_verification": "Passkey creada. Ponle un nombre para reconocerla.",
|
||||
"txt_account_passkey_name_help": "0 / 50 caracteres como máximo",
|
||||
"txt_page_not_found": "Página no encontrada",
|
||||
"txt_page_not_found_hint": "La página pudo haberse eliminado, expirado, o el enlace está incompleto.",
|
||||
"txt_back_to_home": "Volver al inicio",
|
||||
|
||||
@@ -12,6 +12,28 @@ const ru: Record<string, string> = {
|
||||
"nav_import_export": "Импорт и экспорт",
|
||||
"nav_group_data_backup": "Данные и резервные копии",
|
||||
"nav_group_management": "Управление",
|
||||
"txt_settings_appearance": "Внешний вид",
|
||||
"txt_theme": "Тема",
|
||||
"txt_use_system_theme": "Использовать системную тему",
|
||||
"txt_light_theme": "Светлая",
|
||||
"txt_dark_theme": "Темная",
|
||||
"txt_theme_saved_locally": "Выберите тему для веб-хранилища.",
|
||||
"txt_display_language_help": "Изменить язык веб-хранилища.",
|
||||
"txt_two_step_login": "Двухэтапный вход",
|
||||
"txt_keys": "Ключи",
|
||||
"txt_manage": "Управлять",
|
||||
"txt_providers": "Поставщики",
|
||||
"txt_authenticator_app": "Приложение-аутентификатор",
|
||||
"txt_authenticator_app_help": "Введите код, созданный приложением-аутентификатором.",
|
||||
"txt_passkey_provider_help": "Используйте FIDO2-совместимый ключ безопасности или биометрический аутентификатор.",
|
||||
"txt_yubico_otp_security_key": "Ключ безопасности Yubico OTP",
|
||||
"txt_yubico_otp_security_key_help": "Используйте устройство YubiKey 4, 5 или NEO.",
|
||||
"txt_setting_coming_soon": "Скоро появится.",
|
||||
"txt_totp_manage_intro": "Отсканируйте QR-код или введите ключ в приложении-аутентификаторе, затем введите код проверки.",
|
||||
"txt_two_step_recovery_code_warning": "Если вы не можете получить доступ к поставщику двухэтапного входа, одноразовый код восстановления можно использовать для отключения двухэтапного входа. Сохраните код в надежном месте.",
|
||||
"txt_your_two_step_recovery_code": "Ваш код восстановления двухэтапного входа Bitwarden:",
|
||||
"txt_name_account_passkey_after_verification": "Ключ доступа создан. Назовите его, чтобы легче узнавать.",
|
||||
"txt_account_passkey_name_help": "0 / не более 50 символов",
|
||||
"txt_page_not_found": "Страница не найдена",
|
||||
"txt_page_not_found_hint": "Страница могла быть удалена, срок ее действия истек, или ссылка неполная.",
|
||||
"txt_back_to_home": "На главную",
|
||||
|
||||
@@ -11,6 +11,28 @@ const zhCN: Record<string, string> = {
|
||||
"nav_import_export": "导入导出",
|
||||
"nav_group_data_backup": "数据与备份",
|
||||
"nav_group_management": "管理",
|
||||
"txt_settings_appearance": "外观",
|
||||
"txt_theme": "主题",
|
||||
"txt_use_system_theme": "使用系统主题",
|
||||
"txt_light_theme": "浅色",
|
||||
"txt_dark_theme": "深色",
|
||||
"txt_theme_saved_locally": "为您的网页密码库选择一个主题。",
|
||||
"txt_display_language_help": "更改网页密码库的语言。",
|
||||
"txt_two_step_login": "两步登录",
|
||||
"txt_keys": "密钥",
|
||||
"txt_manage": "管理",
|
||||
"txt_providers": "提供程序",
|
||||
"txt_authenticator_app": "验证器 App",
|
||||
"txt_authenticator_app_help": "输入验证器 App 生成的代码。",
|
||||
"txt_passkey_provider_help": "使用兼容 FIDO2 的安全密钥或生物识别验证器。",
|
||||
"txt_yubico_otp_security_key": "Yubico OTP 安全密钥",
|
||||
"txt_yubico_otp_security_key_help": "使用 YubiKey 4、5 或 NEO 设备。",
|
||||
"txt_setting_coming_soon": "即将推出。",
|
||||
"txt_totp_manage_intro": "扫描二维码或在验证器 App 中输入密钥,然后输入验证码。",
|
||||
"txt_two_step_recovery_code_warning": "当您无法访问两步登录提供程序时,您的一次性恢复代码可用于停用两步登录。请将其妥善保管。",
|
||||
"txt_your_two_step_recovery_code": "您的 Bitwarden 两步登录恢复代码:",
|
||||
"txt_name_account_passkey_after_verification": "通行密钥创建成功!为您的通行密钥命名以帮助您识别它。",
|
||||
"txt_account_passkey_name_help": "0 / 最多 50 个字符",
|
||||
"txt_page_not_found": "页面不存在",
|
||||
"txt_page_not_found_hint": "这个页面可能已经删除、过期,或者链接不完整。",
|
||||
"txt_back_to_home": "回到首页",
|
||||
|
||||
@@ -11,6 +11,28 @@ const zhTW: Record<string, string> = {
|
||||
"nav_import_export": "導入導出",
|
||||
"nav_group_data_backup": "資料與備份",
|
||||
"nav_group_management": "管理",
|
||||
"txt_settings_appearance": "外觀",
|
||||
"txt_theme": "主題",
|
||||
"txt_use_system_theme": "使用系統主題",
|
||||
"txt_light_theme": "淺色",
|
||||
"txt_dark_theme": "深色",
|
||||
"txt_theme_saved_locally": "為您的網頁密碼庫選擇一個主題。",
|
||||
"txt_display_language_help": "更改網頁密碼庫的語言。",
|
||||
"txt_two_step_login": "兩步登入",
|
||||
"txt_keys": "密鑰",
|
||||
"txt_manage": "管理",
|
||||
"txt_providers": "提供程序",
|
||||
"txt_authenticator_app": "驗證器 App",
|
||||
"txt_authenticator_app_help": "輸入驗證器 App 生成的代碼。",
|
||||
"txt_passkey_provider_help": "使用兼容 FIDO2 的安全密鑰或生物識別驗證器。",
|
||||
"txt_yubico_otp_security_key": "Yubico OTP 安全密鑰",
|
||||
"txt_yubico_otp_security_key_help": "使用 YubiKey 4、5 或 NEO 裝置。",
|
||||
"txt_setting_coming_soon": "即將推出。",
|
||||
"txt_totp_manage_intro": "掃描二維碼或在驗證器 App 中輸入密鑰,然後輸入驗證碼。",
|
||||
"txt_two_step_recovery_code_warning": "當您無法訪問兩步登入提供程序時,您的一次性恢復代碼可用於停用兩步登入。請將其妥善保管。",
|
||||
"txt_your_two_step_recovery_code": "您的 Bitwarden 兩步登入恢復代碼:",
|
||||
"txt_name_account_passkey_after_verification": "通行密鑰創建成功!為您的通行密鑰命名以幫助您識別它。",
|
||||
"txt_account_passkey_name_help": "0 / 最多 50 個字符",
|
||||
"txt_page_not_found": "頁面不存在",
|
||||
"txt_page_not_found_hint": "這個頁面可能已經刪除、過期,或者連結不完整。",
|
||||
"txt_back_to_home": "回到首頁",
|
||||
|
||||
@@ -297,7 +297,7 @@ h4 {
|
||||
}
|
||||
|
||||
.app-main {
|
||||
grid-template-columns: 212px minmax(0, 1fr);
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
background: var(--panel-soft);
|
||||
}
|
||||
|
||||
|
||||
@@ -537,6 +537,233 @@
|
||||
@apply mb-2;
|
||||
}
|
||||
|
||||
.settings-page-categorized {
|
||||
@apply min-w-0;
|
||||
}
|
||||
|
||||
.settings-category-layout {
|
||||
@apply grid min-w-0 gap-5;
|
||||
}
|
||||
|
||||
.settings-category-tabs {
|
||||
@apply flex min-w-0 items-center gap-1 border;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
padding: 8px;
|
||||
border-radius: 14px;
|
||||
border-color: var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.settings-category-tab {
|
||||
@apply relative flex h-9 cursor-pointer items-center rounded-lg border-0 px-3 text-sm font-bold;
|
||||
background: transparent;
|
||||
color: var(--muted-strong);
|
||||
transition:
|
||||
background var(--dur-fast) var(--ease-smooth),
|
||||
color var(--dur-fast) var(--ease-smooth),
|
||||
box-shadow var(--dur-fast) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.settings-category-tab:hover {
|
||||
color: var(--primary-strong);
|
||||
background: color-mix(in srgb, var(--primary) 6%, transparent);
|
||||
}
|
||||
|
||||
.settings-category-tab.active {
|
||||
color: var(--primary-strong);
|
||||
background: color-mix(in srgb, var(--primary) 12%, var(--panel));
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 22%, var(--line));
|
||||
}
|
||||
|
||||
.settings-category-tab.active::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-category-panel {
|
||||
@apply min-w-0;
|
||||
width: 50%;
|
||||
min-width: 700px;
|
||||
max-width: 50%;
|
||||
padding: 24px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 16px;
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.settings-section-stack {
|
||||
@apply grid min-w-0 gap-0;
|
||||
}
|
||||
|
||||
.settings-submodule {
|
||||
@apply min-w-0 border-0 border-b px-0 py-5;
|
||||
border-color: var(--line);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.settings-submodule:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.settings-submodule:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-submodule h3 {
|
||||
@apply mb-4 mt-0 text-base font-extrabold;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.settings-placeholder-module {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.settings-vertical-fields {
|
||||
@apply grid gap-3;
|
||||
}
|
||||
|
||||
.settings-vertical-fields .field {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
.settings-vertical-fields + .btn {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.settings-category-panel > .settings-section-stack > .settings-submodule:only-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.two-step-recovery-warning {
|
||||
@apply rounded-xl border p-4;
|
||||
border-color: color-mix(in srgb, #f97316 28%, var(--line));
|
||||
background: color-mix(in srgb, #f97316 7%, var(--panel));
|
||||
}
|
||||
|
||||
.settings-submodule.two-step-recovery-warning {
|
||||
border-bottom-color: color-mix(in srgb, #f97316 28%, var(--line));
|
||||
}
|
||||
|
||||
.two-step-recovery-warning + .two-step-providers-module {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.two-step-warning-head {
|
||||
@apply mb-2 flex items-center gap-2 text-sm font-extrabold;
|
||||
color: #c2410c;
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.two-step-recovery-warning p {
|
||||
@apply mb-3 mt-0 text-sm leading-6;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.two-step-recovery-code-dialog-value {
|
||||
@apply my-5 text-center text-base font-extrabold tracking-[0.12em];
|
||||
color: #c2187a;
|
||||
word-spacing: 0.55em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.two-step-provider-list {
|
||||
@apply grid;
|
||||
}
|
||||
|
||||
.two-step-provider-row {
|
||||
@apply grid min-w-0 items-center gap-4 border-b py-5;
|
||||
grid-template-columns: 76px minmax(0, 1fr) auto;
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
.two-step-provider-row:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.two-step-provider-row:last-child {
|
||||
border-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.two-step-provider-icon {
|
||||
@apply flex h-14 w-14 items-center justify-center rounded-xl;
|
||||
color: var(--primary-strong);
|
||||
background: color-mix(in srgb, var(--primary) 9%, var(--panel));
|
||||
}
|
||||
|
||||
.two-step-provider-yubico {
|
||||
@apply text-base font-extrabold lowercase;
|
||||
color: #54a51c;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.two-step-provider-copy {
|
||||
@apply grid min-w-0 gap-1;
|
||||
}
|
||||
|
||||
.two-step-provider-title {
|
||||
@apply flex min-w-0 flex-wrap items-center gap-2;
|
||||
}
|
||||
|
||||
.two-step-provider-title strong {
|
||||
@apply text-base font-bold;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.two-step-provider-copy span {
|
||||
@apply text-sm leading-5;
|
||||
color: var(--muted-strong);
|
||||
}
|
||||
|
||||
.two-step-enabled-badge {
|
||||
@apply inline-flex min-h-6 items-center rounded-md border px-2 text-xs font-extrabold leading-none;
|
||||
border-color: color-mix(in srgb, var(--success) 28%, var(--line));
|
||||
background: color-mix(in srgb, var(--success) 10%, var(--panel));
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.totp-manage-dialog-body {
|
||||
@apply mt-3;
|
||||
}
|
||||
|
||||
.totp-manage-dialog-body .totp-grid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.totp-manage-dialog-body .totp-qr {
|
||||
justify-self: center;
|
||||
width: 220px;
|
||||
}
|
||||
|
||||
.totp-manage-dialog-body .totp-secret-input-wrap {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.totp-manage-dialog-body .totp-secret-input {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.dialog-close-btn {
|
||||
@apply absolute right-3 top-3 flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-0 p-0;
|
||||
background: transparent;
|
||||
color: var(--muted-strong);
|
||||
transition:
|
||||
background-color var(--dur-fast) var(--ease-smooth),
|
||||
color var(--dur-fast) var(--ease-smooth);
|
||||
}
|
||||
|
||||
.dialog-close-btn:hover:not(:disabled) {
|
||||
background: var(--panel-soft);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.dialog-close-btn:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.settings-modules-grid {
|
||||
--settings-grid-gap: 12px;
|
||||
@apply grid;
|
||||
@@ -1196,6 +1423,19 @@
|
||||
background: var(--panel);
|
||||
}
|
||||
|
||||
.settings-category-panel .sensitive-action {
|
||||
border-right: 0;
|
||||
border-left: 0;
|
||||
border-radius: 0;
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.settings-category-panel > .settings-section-stack > .settings-submodule:only-child.sensitive-action {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.sensitive-action h4 {
|
||||
@apply mb-1 mt-0 text-base font-extrabold;
|
||||
color: var(--text);
|
||||
|
||||
@@ -770,11 +770,44 @@
|
||||
}
|
||||
|
||||
.settings-modules-grid,
|
||||
.settings-category-layout,
|
||||
.domain-rules-grid,
|
||||
.password-settings-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.settings-category-tabs {
|
||||
position: static;
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
width: 100%;
|
||||
margin-inline: 0;
|
||||
padding: 4px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.settings-category-tabs::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.settings-category-tab {
|
||||
flex: 0 0 auto;
|
||||
min-height: 38px;
|
||||
padding: 0 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-category-panel {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.settings-category-tab.active {
|
||||
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--primary) 22%, var(--line));
|
||||
}
|
||||
|
||||
.import-export-panel .actions .btn,
|
||||
.settings-subcard .actions .btn,
|
||||
.section-head .actions .btn {
|
||||
@@ -961,6 +994,51 @@
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.settings-category-layout {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.settings-category-panel {
|
||||
padding: 16px;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.settings-page-categorized {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.settings-section-stack {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.settings-submodule {
|
||||
padding: 14px 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.settings-submodule.two-step-recovery-warning {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.two-step-provider-row {
|
||||
grid-template-columns: 42px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.two-step-provider-icon {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
}
|
||||
|
||||
.two-step-provider-row .btn {
|
||||
grid-column: 3;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.totp-manage-dialog-body .totp-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.settings-modules-grid {
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -974,7 +1052,8 @@
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.settings-module h3 {
|
||||
.settings-module h3,
|
||||
.settings-submodule h3 {
|
||||
margin-bottom: 8px;
|
||||
font-size: 15px;
|
||||
line-height: 1.25;
|
||||
@@ -1001,11 +1080,13 @@
|
||||
}
|
||||
|
||||
.settings-module .field,
|
||||
.settings-submodule .field,
|
||||
.auth-card .field {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.settings-module .field > span,
|
||||
.settings-submodule .field > span,
|
||||
.auth-card .field > span {
|
||||
margin-top: 0;
|
||||
margin-bottom: 4px;
|
||||
@@ -1014,19 +1095,22 @@
|
||||
}
|
||||
|
||||
.settings-module .field-grid,
|
||||
.settings-submodule .field-grid,
|
||||
.auth-card .field-grid,
|
||||
.session-timeout-fields {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-module .input {
|
||||
.settings-module .input,
|
||||
.settings-submodule .input {
|
||||
height: 42px;
|
||||
border-radius: 12px;
|
||||
padding: 8px 11px;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.settings-module select.input {
|
||||
.settings-module select.input,
|
||||
.settings-submodule select.input {
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
padding-right: 30px;
|
||||
@@ -1035,18 +1119,21 @@
|
||||
calc(100% - 9px) calc(50% - 3px);
|
||||
}
|
||||
|
||||
.settings-module .field-help {
|
||||
.settings-module .field-help,
|
||||
.settings-submodule .field-help {
|
||||
margin-top: 5px;
|
||||
font-size: 12px;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.settings-module .btn,
|
||||
.settings-submodule .btn,
|
||||
.auth-card .btn:not(.full) {
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.settings-module .actions {
|
||||
.settings-module .actions,
|
||||
.settings-submodule .actions {
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
@@ -1065,18 +1152,22 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.settings-module .totp-grid {
|
||||
.settings-module .totp-grid,
|
||||
.settings-submodule .totp-grid {
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.settings-module .totp-qr {
|
||||
.settings-module .totp-qr,
|
||||
.settings-submodule .totp-qr {
|
||||
min-height: 132px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.settings-module .totp-qr svg,
|
||||
.settings-module .totp-qr img {
|
||||
.settings-module .totp-qr img,
|
||||
.settings-submodule .totp-qr svg,
|
||||
.settings-submodule .totp-qr img {
|
||||
width: 118px;
|
||||
height: 118px;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user