mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
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:
+80
-4
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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')}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
revokeAuthorizedDeviceTrust,
|
||||
revokeAllAuthorizedDeviceTrust,
|
||||
setTotp,
|
||||
updateProfile,
|
||||
} from '@/lib/api/auth';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { AppConfirmState } from '@/components/AppGlobalOverlays';
|
||||
@@ -25,6 +26,7 @@ interface UseAccountSecurityActionsOptions {
|
||||
clearDisableTotpDialog: () => void;
|
||||
onLogoutNow: () => void;
|
||||
onNotify: Notify;
|
||||
onProfileUpdated: (profile: Profile) => void;
|
||||
onSetConfirm: (next: AppConfirmState | null) => void;
|
||||
refetchTotpStatus: () => Promise<unknown>;
|
||||
refetchAuthorizedDevices: () => Promise<unknown>;
|
||||
@@ -39,6 +41,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
clearDisableTotpDialog,
|
||||
onLogoutNow,
|
||||
onNotify,
|
||||
onProfileUpdated,
|
||||
onSetConfirm,
|
||||
refetchTotpStatus,
|
||||
refetchAuthorizedDevices,
|
||||
@@ -85,6 +88,22 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
});
|
||||
},
|
||||
|
||||
async savePasswordHint(masterPasswordHint: string) {
|
||||
if (!profile) return;
|
||||
const normalized = String(masterPasswordHint || '').trim();
|
||||
if (normalized.length > 120) {
|
||||
onNotify('error', t('txt_password_hint_too_long'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const nextProfile = await updateProfile(authedFetch, { masterPasswordHint: normalized });
|
||||
onProfileUpdated(nextProfile);
|
||||
onNotify('success', t('txt_profile_updated'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_save_profile_failed'));
|
||||
}
|
||||
},
|
||||
|
||||
async enableTotp(secret: string, token: string) {
|
||||
if (!secret.trim() || !token.trim()) {
|
||||
const error = new Error(t('txt_secret_and_code_are_required'));
|
||||
@@ -208,6 +227,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
disableTotpPassword,
|
||||
onLogoutNow,
|
||||
onNotify,
|
||||
onProfileUpdated,
|
||||
onSetConfirm,
|
||||
profile,
|
||||
refetchAuthorizedDevices,
|
||||
|
||||
@@ -201,11 +201,12 @@ export async function registerAccount(args: {
|
||||
email: string;
|
||||
name: string;
|
||||
password: string;
|
||||
masterPasswordHint?: string;
|
||||
inviteCode?: string;
|
||||
fallbackIterations: number;
|
||||
}): Promise<{ ok: true } | { ok: false; message: string }> {
|
||||
try {
|
||||
const { email, name, password, inviteCode, fallbackIterations } = args;
|
||||
const { email, name, password, masterPasswordHint, inviteCode, fallbackIterations } = args;
|
||||
const masterKey = await pbkdf2(password, email, fallbackIterations, 32);
|
||||
const masterHash = await pbkdf2(masterKey, password, 1, 32);
|
||||
const encKey = await hkdfExpand(masterKey, 'enc', 32);
|
||||
@@ -233,6 +234,7 @@ export async function registerAccount(args: {
|
||||
body: JSON.stringify({
|
||||
email: email.toLowerCase(),
|
||||
name,
|
||||
masterPasswordHint: String(masterPasswordHint || '').trim() || undefined,
|
||||
masterPasswordHash: bytesToBase64(masterHash),
|
||||
key: encryptedVaultKey,
|
||||
kdf: 0,
|
||||
@@ -255,6 +257,20 @@ export async function registerAccount(args: {
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPasswordHint(email: string): Promise<{ masterPasswordHint: string | null }> {
|
||||
const resp = await fetch('/api/accounts/password-hint', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: email.trim().toLowerCase() }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(body?.error_description || body?.error || 'Failed to load password hint');
|
||||
}
|
||||
const body = (await parseJson<{ masterPasswordHint?: string | null }>(resp)) || {};
|
||||
return { masterPasswordHint: body.masterPasswordHint ?? null };
|
||||
}
|
||||
|
||||
export function createAuthedFetch(getSession: () => SessionState | null, setSession: SessionSetter) {
|
||||
return async function authedFetch(input: string, init: RequestInit = {}): Promise<Response> {
|
||||
const session = getSession();
|
||||
@@ -294,6 +310,26 @@ export async function getProfile(authedFetch: AuthedFetch): Promise<Profile> {
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function updateProfile(
|
||||
authedFetch: AuthedFetch,
|
||||
payload: { masterPasswordHint: string }
|
||||
): Promise<Profile> {
|
||||
const resp = await authedFetch('/api/accounts/profile', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
masterPasswordHint: String(payload.masterPasswordHint || '').trim() || null,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(body?.error_description || body?.error || 'Save profile failed');
|
||||
}
|
||||
const body = await parseJson<Profile>(resp);
|
||||
if (!body) throw new Error('Invalid profile');
|
||||
return body;
|
||||
}
|
||||
|
||||
export async function unlockVaultKey(profileKey: string, masterKey: Uint8Array): Promise<{ symEncKey: string; symMacKey: string }> {
|
||||
const encKey = await hkdfExpand(masterKey, 'enc', 32);
|
||||
const macKey = await hkdfExpand(masterKey, 'mac', 32);
|
||||
|
||||
@@ -149,8 +149,7 @@ export function readInitialAppBootstrapState(): InitialAppBootstrapState {
|
||||
};
|
||||
}
|
||||
|
||||
export async function bootstrapAppSession(): Promise<BootstrapAppResult> {
|
||||
const initial = readInitialAppBootstrapState();
|
||||
export async function bootstrapAppSession(initial: InitialAppBootstrapState = readInitialAppBootstrapState()): Promise<BootstrapAppResult> {
|
||||
const defaultKdfIterations = initial.defaultKdfIterations;
|
||||
const jwtWarning = initial.jwtWarning;
|
||||
|
||||
@@ -309,6 +308,7 @@ export async function performRegistration(args: {
|
||||
email: string;
|
||||
name: string;
|
||||
password: string;
|
||||
masterPasswordHint: string;
|
||||
inviteCode: string;
|
||||
fallbackIterations: number;
|
||||
}) {
|
||||
@@ -316,6 +316,7 @@ export async function performRegistration(args: {
|
||||
email: args.email.trim().toLowerCase(),
|
||||
name: args.name.trim(),
|
||||
password: args.password,
|
||||
masterPasswordHint: args.masterPasswordHint.trim(),
|
||||
inviteCode: args.inviteCode.trim(),
|
||||
fallbackIterations: args.fallbackIterations,
|
||||
});
|
||||
|
||||
@@ -458,6 +458,19 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_password: "Password",
|
||||
txt_password_is_already_verified: "Password is already verified.",
|
||||
txt_passwords_do_not_match: "Passwords do not match",
|
||||
txt_password_hint: "Password Hint",
|
||||
txt_password_hint_optional: "Password Hint (optional)",
|
||||
txt_password_hint_placeholder: "A clue only you would understand",
|
||||
txt_password_hint_register_placeholder: "This hint can be shown directly on the web login page.",
|
||||
txt_password_hint_register_help: "This hint can be shown directly on the web login page. Do not include your master password, recovery code, or anything that can reveal it outright.",
|
||||
txt_password_hint_login_help: "Forgot the master password? Reveal the hint you saved during registration.",
|
||||
txt_password_hint_login_note: "Only a hint is shown here. It should help you remember the password, not expose it.",
|
||||
txt_show_password_hint: "Show Password Hint",
|
||||
txt_hide_password_hint: "Hide Password Hint",
|
||||
txt_loading_password_hint: "Loading hint...",
|
||||
txt_password_hint_not_set: "No password hint is available for this email.",
|
||||
txt_password_hint_load_failed: "Failed to load password hint",
|
||||
txt_password_hint_too_long: "Password hint must be 120 characters or fewer",
|
||||
txt_phone: "Phone",
|
||||
txt_please_input_email_and_password: "Please input email and password",
|
||||
txt_please_input_master_password: "Please input master password",
|
||||
@@ -1095,6 +1108,19 @@ const zhCNOverrides: Record<string, string> = {
|
||||
txt_opera_extension: 'Opera 扩展',
|
||||
txt_password_is_already_verified: '密码已验证',
|
||||
txt_passwords_do_not_match: '两次输入的密码不一致',
|
||||
txt_password_hint: '密码提示',
|
||||
txt_password_hint_optional: '密码提示(可选)',
|
||||
txt_password_hint_placeholder: '写一句只有你自己看得懂的线索',
|
||||
txt_password_hint_register_placeholder: '这个提示可以在网页登录页直接显示。',
|
||||
txt_password_hint_register_help: '这个提示可以在网页登录页直接显示。不要填写主密码、恢复代码,或任何能直接暴露密码的信息。',
|
||||
txt_password_hint_login_help: '忘记主密码时,可以查看注册时保存的提示。',
|
||||
txt_password_hint_login_note: '这里只会显示提示语,不会显示你的主密码本身。',
|
||||
txt_show_password_hint: '查看密码提示',
|
||||
txt_hide_password_hint: '隐藏密码提示',
|
||||
txt_loading_password_hint: '正在加载提示...',
|
||||
txt_password_hint_not_set: '这个邮箱没有可显示的密码提示。',
|
||||
txt_password_hint_load_failed: '加载密码提示失败',
|
||||
txt_password_hint_too_long: '密码提示最多只能输入 120 个字符',
|
||||
txt_phone: '电话',
|
||||
txt_please_input_email_and_password: '请输入邮箱和密码',
|
||||
txt_please_input_master_password: '请输入主密码',
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface Profile {
|
||||
email: string;
|
||||
name: string;
|
||||
key: string;
|
||||
masterPasswordHint?: string | null;
|
||||
privateKey?: string | null;
|
||||
publicKey?: string | null;
|
||||
role: 'admin' | 'user';
|
||||
|
||||
@@ -402,6 +402,41 @@ input[type='file'].input::file-selector-button:hover {
|
||||
color: #334155;
|
||||
}
|
||||
|
||||
.field-help {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
color: #667085;
|
||||
}
|
||||
|
||||
.auth-support-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin: -2px 0 12px;
|
||||
}
|
||||
|
||||
.auth-link-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
color: #1d4ed8;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.auth-link-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.auth-link-btn:disabled {
|
||||
color: #94a3b8;
|
||||
cursor: not-allowed;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-page {
|
||||
min-height: 100%;
|
||||
padding: 20px;
|
||||
@@ -2417,6 +2452,11 @@ input[type='file'].input::file-selector-button:hover {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.auth-support-row {
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.app-page {
|
||||
padding: 0;
|
||||
background: #f5f7fb;
|
||||
|
||||
Reference in New Issue
Block a user