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
@@ -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}
/>
+2
View File
@@ -77,6 +77,7 @@ export interface AppMainRoutesProps {
uploadingSendFileName: string;
sendUploadPercent: number | null;
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
onEnableTotp: (secret: string, token: string) => Promise<void>;
onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
@@ -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}
+40
View File
@@ -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}
/>
<div className="auth-support-row">
<span />
<button
type="button"
className="auth-link-btn"
onClick={props.onShowLockedPasswordHint}
disabled={unlockBusy}
>
{t('txt_show_password_hint')}
</button>
</div>
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || !props.unlockReady}>
<Unlock size={16} className="btn-icon" />
{unlockBusy ? t('txt_unlocking') : t('txt_unlock')}
@@ -147,6 +162,18 @@ export default function AuthViews(props: AuthViewsProps) {
autoComplete="new-password"
onInput={(v) => props.onChangeRegister({ ...props.registerValues, password2: v })}
/>
<label className="field">
<span>{t('txt_password_hint_optional')}</span>
<input
className="input"
maxLength={120}
value={props.registerValues.passwordHint}
placeholder={t('txt_password_hint_register_placeholder')}
onInput={(e) =>
props.onChangeRegister({ ...props.registerValues, passwordHint: (e.currentTarget as HTMLInputElement).value })
}
/>
</label>
<label className="field">
<span>{t('txt_invite_code_optional')}</span>
<input
@@ -199,6 +226,19 @@ export default function AuthViews(props: AuthViewsProps) {
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
autoFocus
/>
<div className="auth-support-row">
<span />
<button
type="button"
className="auth-link-btn"
onClick={props.onTogglePasswordHint}
disabled={loginBusy || !props.loginValues.email.trim()}
>
{props.loginHintLoading
? t('txt_loading_password_hint')
: t('txt_show_password_hint')}
</button>
</div>
<button type="submit" className="btn btn-primary full" disabled={loginBusy}>
<LogIn size={16} className="btn-icon" />
{loginBusy ? t('txt_logging_in') : t('txt_log_in')}
+7 -4
View File
@@ -10,6 +10,7 @@ interface ConfirmDialogProps {
confirmText?: string;
cancelText?: string;
danger?: boolean;
hideCancel?: boolean;
onConfirm: () => void;
onCancel: () => void;
children?: ComponentChildren;
@@ -37,10 +38,12 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
<Check size={14} className="btn-icon" />
{props.confirmText || t('txt_yes')}
</button>
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onCancel}>
<X size={14} className="btn-icon" />
{props.cancelText || t('txt_no')}
</button>
{!props.hideCancel && (
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onCancel}>
<X size={14} className="btn-icon" />
{props.cancelText || t('txt_no')}
</button>
)}
{props.afterActions}
</form>
</div>
+28
View File
@@ -9,6 +9,7 @@ interface SettingsPageProps {
profile: Profile;
totpEnabled: boolean;
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
onEnableTotp: (secret: string, token: string) => Promise<void>;
onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
@@ -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 (
<div className="stack">
<section className="card">
<h3>{t('txt_profile')}</h3>
<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_profile')}
</button>
</section>
<section className="card">
<h3>{t('txt_change_master_password')}</h3>
<label className="field">