diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index bf9486c..f2f2661 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -13,6 +13,7 @@ CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, + master_password_hint TEXT, master_password_hash TEXT NOT NULL, key TEXT NOT NULL, private_key TEXT, diff --git a/src/config/limits.ts b/src/config/limits.ts index d1ae40b..b85d275 100644 --- a/src/config/limits.ts +++ b/src/config/limits.ts @@ -44,6 +44,12 @@ // Sensitive public/auth request budget per IP per minute. // 敏感公开/认证接口每 IP 每分钟请求配额。 sensitivePublicRequestsPerMinute: 30, + // Password hint lookup budget per IP per minute. + // 密码提示查询接口每 IP 每分钟请求配额。 + passwordHintRequestsPerMinute: 1, + // Password hint lookup budget per IP per hour. + // 密码提示查询接口每 IP 每小时请求配额。 + passwordHintRequestsPerHour: 3, // Register endpoint budget per IP per minute. // 注册接口每 IP 每分钟请求配额。 registerRequestsPerMinute: 5, diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index 0c53985..cbaed6d 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -62,6 +62,11 @@ function normalizeRecoveryCodeInput(input: string): string { return String(input || '').toUpperCase().replace(/[^A-Z2-7]/g, ''); } +function normalizeMasterPasswordHint(input: string | null | undefined): string | null { + const normalized = String(input || '').trim(); + return normalized ? normalized : null; +} + function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null { const secret = (env.JWT_SECRET || '').trim(); if (!secret) return 'missing'; @@ -80,7 +85,7 @@ function toProfile(user: User, env: Env): ProfileResponse { premium: true, premiumFromOrganization: false, usesKeyConnector: false, - masterPasswordHint: null, + masterPasswordHint: user.masterPasswordHint, culture: 'en-US', twoFactorEnabled: !!user.totpSecret, key: user.key, @@ -125,6 +130,7 @@ export async function handleRegister(request: Request, env: Env): Promise 120) { + return errorResponse('masterPasswordHint must be 120 characters or fewer', 400); + } const kdfErr = validateKdfParams(body.kdf, body.kdfIterations, body.kdfMemory, body.kdfParallelism); if (kdfErr) return errorResponse(kdfErr, 400); @@ -172,6 +182,7 @@ export async function handleRegister(request: Request, env: Env): Promise { + const storage = new StorageService(env.DB); + const clientIdentifier = getClientIdentifier(request); + if (!clientIdentifier) { + return errorResponse('Client IP is required', 403); + } + + let body: { email?: string }; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + const email = String(body.email || '').trim().toLowerCase(); + if (!email) { + return errorResponse('Email is required', 400); + } + + const rateLimit = new RateLimitService(env.DB); + const minuteBudget = await rateLimit.consumeBudgetWithWindow( + `${clientIdentifier}:password-hint`, + LIMITS.rateLimit.passwordHintRequestsPerMinute, + 60 + ); + if (!minuteBudget.allowed) { + return new Response( + JSON.stringify({ + error: 'Too many requests', + error_description: `Rate limit exceeded. Try again in ${minuteBudget.retryAfterSeconds || 60} seconds.`, + }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + 'Retry-After': String(minuteBudget.retryAfterSeconds || 60), + 'X-RateLimit-Remaining': '0', + }, + } + ); + } + + const hourlyBudget = await rateLimit.consumeBudgetWithWindow( + `${clientIdentifier}:password-hint-hour`, + LIMITS.rateLimit.passwordHintRequestsPerHour, + 60 * 60 + ); + if (!hourlyBudget.allowed) { + return new Response( + JSON.stringify({ + error: 'Too many requests', + error_description: `Rate limit exceeded. Try again in ${hourlyBudget.retryAfterSeconds || 3600} seconds.`, + }), + { + status: 429, + headers: { + 'Content-Type': 'application/json', + 'Retry-After': String(hourlyBudget.retryAfterSeconds || 3600), + 'X-RateLimit-Remaining': '0', + }, + } + ); + } + + const user = await storage.getUser(email); + const hint = user?.status === 'active' ? normalizeMasterPasswordHint(user.masterPasswordHint) : null; + return jsonResponse({ + object: 'passwordHint', + hasHint: !!hint, + masterPasswordHint: hint, + }); +} + // GET /api/accounts/profile export async function handleGetProfile(request: Request, env: Env, userId: string): Promise { void request; @@ -251,6 +336,33 @@ export async function handleGetProfile(request: Request, env: Env, userId: strin return jsonResponse(toProfile(user, env)); } +// PUT /api/accounts/profile +export async function handleUpdateProfile(request: Request, env: Env, userId: string): Promise { + const storage = new StorageService(env.DB); + const user = await storage.getUserById(userId); + if (!user) return errorResponse('User not found', 404); + + let body: { + masterPasswordHint?: string | null; + }; + try { + body = await request.json(); + } catch { + return errorResponse('Invalid JSON', 400); + } + + const masterPasswordHint = normalizeMasterPasswordHint(body.masterPasswordHint); + if (masterPasswordHint && masterPasswordHint.length > 120) { + return errorResponse('masterPasswordHint must be 120 characters or fewer', 400); + } + + user.masterPasswordHint = masterPasswordHint; + user.updatedAt = new Date().toISOString(); + await storage.saveUser(user); + + return jsonResponse(toProfile(user, env)); +} + // POST /api/accounts/keys export async function handleSetKeys(request: Request, env: Env, userId: string): Promise { const storage = new StorageService(env.DB); diff --git a/src/handlers/sync.ts b/src/handlers/sync.ts index 353772a..3054184 100644 --- a/src/handlers/sync.ts +++ b/src/handlers/sync.ts @@ -135,7 +135,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr premium: true, premiumFromOrganization: false, usesKeyConnector: false, - masterPasswordHint: null, + masterPasswordHint: user.masterPasswordHint, culture: 'en-US', twoFactorEnabled: !!user.totpSecret, key: user.key, diff --git a/src/index.ts b/src/index.ts index b80b2f7..586dc7e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -32,6 +32,10 @@ function injectBootstrapIntoHtml(html: string, env: Env): string { return `${script}${html}`; } +function responseStatusCannotHaveBody(status: number): boolean { + return status === 101 || status === 204 || status === 205 || status === 304; +} + async function maybeServeAsset(request: Request, env: Env): Promise { if (!env.ASSETS) return null; if (request.method !== 'GET' && request.method !== 'HEAD') return null; @@ -40,7 +44,11 @@ async function maybeServeAsset(request: Request, env: Env): Promise { return this.consumeFixedWindowBudget(identifier, maxRequests, CONFIG.API_WINDOW_SECONDS); } + + async consumeBudgetWithWindow( + identifier: string, + maxRequests: number, + windowSeconds: number + ): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> { + return this.consumeFixedWindowBudget(identifier, maxRequests, windowSeconds); + } } function parseIpv4Octets(input: string): number[] | null { diff --git a/src/services/storage-schema.ts b/src/services/storage-schema.ts index 0be0b6d..f73dd84 100644 --- a/src/services/storage-schema.ts +++ b/src/services/storage-schema.ts @@ -3,10 +3,11 @@ // Any new table/column/index must be added to both places together. const SCHEMA_STATEMENTS: readonly string[] = [ 'CREATE TABLE IF NOT EXISTS users (' + - 'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hash TEXT NOT NULL, ' + + 'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' + 'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' + 'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' + 'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)', + 'ALTER TABLE users ADD COLUMN master_password_hint TEXT', 'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'', 'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'', 'ALTER TABLE users ADD COLUMN totp_secret TEXT', diff --git a/src/services/storage-user-repo.ts b/src/services/storage-user-repo.ts index a91bd59..a01ff89 100644 --- a/src/services/storage-user-repo.ts +++ b/src/services/storage-user-repo.ts @@ -7,6 +7,7 @@ function mapUserRow(row: any): User { id: row.id, email: row.email, name: row.name, + masterPasswordHint: row.master_password_hint ?? null, masterPasswordHash: row.master_password_hash, key: row.key, privateKey: row.private_key, @@ -28,7 +29,7 @@ function mapUserRow(row: any): User { export async function getUser(db: D1Database, email: string): Promise { const row = await db .prepare( - 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE email = ?' + 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE email = ?' ) .bind(email.toLowerCase()) .first(); @@ -39,7 +40,7 @@ export async function getUser(db: D1Database, email: string): Promise { const row = await db .prepare( - 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE id = ?' + 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE id = ?' ) .bind(id) .first(); @@ -55,7 +56,7 @@ export async function getUserCount(db: D1Database): Promise { export async function getAllUsers(db: D1Database): Promise { const res = await db .prepare( - 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC' + 'SELECT id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC' ) .all(); return (res.results || []).map((row) => mapUserRow(row)); @@ -64,10 +65,10 @@ export async function getAllUsers(db: D1Database): Promise { export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): Promise { const email = user.email.toLowerCase(); const stmt = db.prepare( - 'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' + - 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + + 'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' + + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'ON CONFLICT(id) DO UPDATE SET ' + - 'email=excluded.email, name=excluded.name, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' + + 'email=excluded.email, name=excluded.name, master_password_hint=excluded.master_password_hint, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' + 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at' ); await safeBind( @@ -75,6 +76,7 @@ export async function saveUser(db: D1Database, safeBind: SafeBind, user: User): user.id, email, user.name, + user.masterPasswordHint, user.masterPasswordHash, user.key, user.privateKey, @@ -100,8 +102,8 @@ export async function createUser(db: D1Database, safeBind: SafeBind, user: User) export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: User): Promise { const email = user.email.toLowerCase(); const stmt = db.prepare( - 'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' + - 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + + 'INSERT INTO users(id, email, name, master_password_hint, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' + + 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + 'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)' ); const result = await safeBind( @@ -109,6 +111,7 @@ export async function createFirstUser(db: D1Database, safeBind: SafeBind, user: user.id, email, user.name, + user.masterPasswordHint, user.masterPasswordHash, user.key, user.privateKey, diff --git a/src/services/storage.ts b/src/services/storage.ts index f600949..607ce52 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -102,7 +102,7 @@ import { const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const STORAGE_SCHEMA_VERSION_KEY = 'schema.version'; -const STORAGE_SCHEMA_VERSION = '2026-03-18.1'; +const STORAGE_SCHEMA_VERSION = '2026-03-19.1'; // D1-backed storage. // Contract: diff --git a/src/types/index.ts b/src/types/index.ts index 26b58e8..cb3ea63 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -35,6 +35,7 @@ export interface User { id: string; email: string; name: string | null; + masterPasswordHint: string | null; masterPasswordHash: string; key: string; privateKey: string | null; diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 391397c..ce0c62c 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -11,6 +11,7 @@ import { createAuthedFetch, getAuthorizedDevices, getCurrentDeviceIdentifier, + getPasswordHint, getTotpStatus, saveSession, } from '@/lib/api/auth'; @@ -78,8 +79,18 @@ export default function App() { email: '', password: '', password2: '', + passwordHint: '', inviteCode: initialInviteCode, }); + const [loginHintState, setLoginHintState] = useState<{ + email: string; + loading: boolean; + hint: string | null; + }>({ + email: '', + loading: false, + hint: null, + }); const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode); const [unlockPassword, setUnlockPassword] = useState(''); const [pendingTotp, setPendingTotp] = useState(null); @@ -131,6 +142,15 @@ export default function App() { setRegisterValues((prev) => (prev.inviteCode === inviteCodeFromUrl ? prev : { ...prev, inviteCode: inviteCodeFromUrl })); }, [inviteCodeFromUrl]); + useEffect(() => { + const normalizedEmail = loginValues.email.trim().toLowerCase(); + setLoginHintState((prev) => ( + prev.email && prev.email !== normalizedEmail + ? { email: '', loading: false, hint: null } + : prev + )); + }, [loginValues.email]); + useEffect(() => { if (!inviteCodeFromUrl) return; if (phase === 'locked' || phase === 'app') return; @@ -200,7 +220,7 @@ export default function App() { useEffect(() => { let mounted = true; (async () => { - const boot = await bootstrapAppSession(); + const boot = await bootstrapAppSession(initialBootstrap); if (!mounted) return; setDefaultKdfIterations(boot.defaultKdfIterations); setJwtWarning(boot.jwtWarning); @@ -212,7 +232,7 @@ export default function App() { return () => { mounted = false; }; - }, []); + }, [initialBootstrap]); async function finalizeLogin(login: CompletedLogin) { setSession(login.session); @@ -322,6 +342,7 @@ export default function App() { email: registerValues.email, name: registerValues.name, password: registerValues.password, + masterPasswordHint: registerValues.passwordHint, inviteCode: registerValues.inviteCode, fallbackIterations: defaultKdfIterations, }); @@ -338,6 +359,56 @@ export default function App() { } } + function openPasswordHintDialog(hint: string | null) { + setConfirm({ + title: t('txt_password_hint'), + message: hint || t('txt_password_hint_not_set'), + showIcon: false, + confirmText: t('txt_close'), + hideCancel: true, + onConfirm: () => setConfirm(null), + }); + } + + async function handleTogglePasswordHint() { + if (pendingAuthAction) return; + const email = loginValues.email.trim().toLowerCase(); + if (!email) return; + + if (loginHintState.email === email && !loginHintState.loading) { + openPasswordHintDialog(loginHintState.hint); + return; + } + + setLoginHintState({ + email, + loading: true, + hint: null, + }); + + try { + const result = await getPasswordHint(email); + openPasswordHintDialog(result.masterPasswordHint); + setLoginHintState({ + email, + loading: false, + hint: result.masterPasswordHint, + }); + } catch (error) { + setLoginHintState({ + email: '', + loading: false, + hint: null, + }); + pushToast('error', error instanceof Error ? error.message : t('txt_password_hint_load_failed')); + } + } + + function handleShowLockedPasswordHint() { + if (pendingAuthAction) return; + openPasswordHintDialog(profile?.masterPasswordHint ?? null); + } + async function handleUnlock() { if (pendingAuthAction) return; if (!session || !profile) return; @@ -804,6 +875,7 @@ export default function App() { }, onLogoutNow: logoutNow, onNotify: pushToast, + onProfileUpdated: setProfile, onSetConfirm: setConfirm, refetchTotpStatus: totpStatusQuery.refetch, refetchAuthorizedDevices: authorizedDevicesQuery.refetch, @@ -923,6 +995,7 @@ export default function App() { uploadingSendFileName: vaultSendActions.uploadingSendFileName, sendUploadPercent: vaultSendActions.sendUploadPercent, onChangePassword: accountSecurityActions.changePassword, + onSavePasswordHint: accountSecurityActions.savePasswordHint, onEnableTotp: async (secret: string, token: string) => { await accountSecurityActions.enableTotp(secret, token); await totpStatusQuery.refetch(); @@ -992,6 +1065,7 @@ export default function App() { registerValues={registerValues} unlockPassword={unlockPassword} emailForLock={profile?.email || session?.email || ''} + loginHintLoading={loginHintState.loading} onChangeLogin={setLoginValues} onChangeRegister={setRegisterValues} onChangeUnlock={setUnlockPassword} @@ -1010,12 +1084,14 @@ export default function App() { navigate('/register'); }} onLogout={logoutNow} + onTogglePasswordHint={() => void handleTogglePasswordHint()} + onShowLockedPasswordHint={handleShowLockedPasswordHint} /> {}} + confirm={confirm} + onCancelConfirm={() => setConfirm(null)} pendingTotpOpen={!!pendingTotp} totpCode={totpCode} rememberDevice={rememberDevice} diff --git a/webapp/src/components/AppGlobalOverlays.tsx b/webapp/src/components/AppGlobalOverlays.tsx index 6336389..8f8940e 100644 --- a/webapp/src/components/AppGlobalOverlays.tsx +++ b/webapp/src/components/AppGlobalOverlays.tsx @@ -8,6 +8,9 @@ export interface AppConfirmState { message: string; danger?: boolean; showIcon?: boolean; + confirmText?: string; + cancelText?: string; + hideCancel?: boolean; onConfirm: () => void; } @@ -40,6 +43,9 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) { message={props.confirm?.message || ''} danger={props.confirm?.danger} showIcon={props.confirm?.showIcon} + confirmText={props.confirm?.confirmText} + cancelText={props.confirm?.cancelText} + hideCancel={props.confirm?.hideCancel} onConfirm={() => props.confirm?.onConfirm()} onCancel={props.onCancelConfirm} /> diff --git a/webapp/src/components/AppMainRoutes.tsx b/webapp/src/components/AppMainRoutes.tsx index f2b52a1..113abd9 100644 --- a/webapp/src/components/AppMainRoutes.tsx +++ b/webapp/src/components/AppMainRoutes.tsx @@ -77,6 +77,7 @@ export interface AppMainRoutesProps { uploadingSendFileName: string; sendUploadPercent: number | null; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise; + onSavePasswordHint: (masterPasswordHint: string) => Promise; onEnableTotp: (secret: string, token: string) => Promise; onOpenDisableTotp: () => void; onGetRecoveryCode: (masterPassword: string) => Promise; @@ -198,6 +199,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) { profile={props.profile} totpEnabled={props.totpEnabled} onChangePassword={props.onChangePassword} + onSavePasswordHint={props.onSavePasswordHint} onEnableTotp={props.onEnableTotp} onOpenDisableTotp={props.onOpenDisableTotp} onGetRecoveryCode={props.onGetRecoveryCode} diff --git a/webapp/src/components/AuthViews.tsx b/webapp/src/components/AuthViews.tsx index 362b22f..932f979 100644 --- a/webapp/src/components/AuthViews.tsx +++ b/webapp/src/components/AuthViews.tsx @@ -13,6 +13,7 @@ interface RegisterValues { email: string; password: string; password2: string; + passwordHint: string; inviteCode: string; } @@ -24,6 +25,7 @@ interface AuthViewsProps { registerValues: RegisterValues; unlockPassword: string; emailForLock: string; + loginHintLoading: boolean; onChangeLogin: (next: LoginValues) => void; onChangeRegister: (next: RegisterValues) => void; onChangeUnlock: (password: string) => void; @@ -33,6 +35,8 @@ interface AuthViewsProps { onGotoLogin: () => void; onGotoRegister: () => void; onLogout: () => void; + onTogglePasswordHint: () => void; + onShowLockedPasswordHint: () => void; } function PasswordField(props: { @@ -87,6 +91,17 @@ export default function AuthViews(props: AuthViewsProps) { autoComplete="current-password" onInput={props.onChangeUnlock} /> +
+ + +
+ - + {!props.hideCancel && ( + + )} {props.afterActions} diff --git a/webapp/src/components/SettingsPage.tsx b/webapp/src/components/SettingsPage.tsx index b957147..762c82b 100644 --- a/webapp/src/components/SettingsPage.tsx +++ b/webapp/src/components/SettingsPage.tsx @@ -9,6 +9,7 @@ interface SettingsPageProps { profile: Profile; totpEnabled: boolean; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise; + onSavePasswordHint: (masterPasswordHint: string) => Promise; onEnableTotp: (secret: string, token: string) => Promise; onOpenDisableTotp: () => void; onGetRecoveryCode: (masterPassword: string) => Promise; @@ -40,6 +41,7 @@ export default function SettingsPage(props: SettingsPageProps) { const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [newPassword2, setNewPassword2] = useState(''); + const [passwordHint, setPasswordHint] = useState(props.profile.masterPasswordHint || ''); const [secret, setSecret] = useState(() => localStorage.getItem(totpSecretStorageKey) || randomBase32Secret(32)); const [token, setToken] = useState(''); const [totpLocked, setTotpLocked] = useState(props.totpEnabled); @@ -54,6 +56,10 @@ export default function SettingsPage(props: SettingsPageProps) { setTotpLocked(true); }, [props.totpEnabled]); + useEffect(() => { + setPasswordHint(props.profile.masterPasswordHint || ''); + }, [props.profile.masterPasswordHint]); + const qrDataUrl = useMemo(() => { const qr = qrcode(0, 'M'); qr.addData(buildOtpUri(props.profile.email, secret)); @@ -81,6 +87,28 @@ export default function SettingsPage(props: SettingsPageProps) { return (
+
+

{t('txt_profile')}

+ + +
+

{t('txt_change_master_password')}