feat: add master password hint functionality

- Updated user model to include masterPasswordHint.
- Modified sync handler to return masterPasswordHint.
- Implemented password hint retrieval in public API.
- Enhanced user profile management to allow updating of password hint.
- Added UI components for displaying and editing password hint.
- Updated localization files for new password hint strings.
- Improved rate limiting for sensitive public requests.
- Adjusted database schema to accommodate master password hint.
This commit is contained in:
shuaiplus
2026-03-19 00:38:56 +08:00
parent 8bc43b8f0c
commit facd0ea5f7
26 changed files with 460 additions and 26 deletions
+80 -4
View File
@@ -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<PendingTotp | null>(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}
/>
<AppGlobalOverlays
toasts={toasts}
onCloseToast={removeToast}
confirm={null}
onCancelConfirm={() => {}}
confirm={confirm}
onCancelConfirm={() => setConfirm(null)}
pendingTotpOpen={!!pendingTotp}
totpCode={totpCode}
rememberDevice={rememberDevice}