feat: implement pending authentication actions for login, registration, and unlock flows

This commit is contained in:
shuaiplus
2026-03-15 18:32:30 +08:00
parent 588408ff96
commit a4b45c1b59
4 changed files with 77 additions and 40 deletions
+30 -15
View File
@@ -59,6 +59,7 @@ const SIGNALR_UPDATE_TYPE_LOG_OUT = 11;
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12; const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 12;
export default function App() { export default function App() {
const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'register' | 'unlock' | null>(null);
const [location, navigate] = useLocation(); const [location, navigate] = useLocation();
const [phase, setPhase] = useState<AppPhase>('loading'); const [phase, setPhase] = useState<AppPhase>('loading');
const [session, setSessionState] = useState<SessionState | null>(null); const [session, setSessionState] = useState<SessionState | null>(null);
@@ -210,10 +211,12 @@ export default function App() {
} }
async function handleLogin() { async function handleLogin() {
if (pendingAuthAction) return;
if (!loginValues.email || !loginValues.password) { if (!loginValues.email || !loginValues.password) {
pushToast('error', t('txt_please_input_email_and_password')); pushToast('error', t('txt_please_input_email_and_password'));
return; return;
} }
setPendingAuthAction('login');
try { try {
const result = await performPasswordLogin(loginValues.email, loginValues.password, defaultKdfIterations); const result = await performPasswordLogin(loginValues.email, loginValues.password, defaultKdfIterations);
if (result.kind === 'success') { if (result.kind === 'success') {
@@ -229,6 +232,8 @@ export default function App() {
pushToast('error', result.message || t('txt_login_failed')); pushToast('error', result.message || t('txt_login_failed'));
} catch (error) { } catch (error) {
pushToast('error', error instanceof Error ? error.message : t('txt_login_failed')); pushToast('error', error instanceof Error ? error.message : t('txt_login_failed'));
} finally {
setPendingAuthAction(null);
} }
} }
@@ -273,6 +278,7 @@ export default function App() {
} }
async function handleRegister() { async function handleRegister() {
if (pendingAuthAction) return;
if (!registerValues.email || !registerValues.password) { if (!registerValues.email || !registerValues.password) {
pushToast('error', t('txt_please_input_email_and_password')); pushToast('error', t('txt_please_input_email_and_password'));
return; return;
@@ -285,29 +291,36 @@ export default function App() {
pushToast('error', t('txt_passwords_do_not_match')); pushToast('error', t('txt_passwords_do_not_match'));
return; return;
} }
const resp = await performRegistration({ setPendingAuthAction('register');
email: registerValues.email, try {
name: registerValues.name, const resp = await performRegistration({
password: registerValues.password, email: registerValues.email,
inviteCode: registerValues.inviteCode, name: registerValues.name,
fallbackIterations: defaultKdfIterations, password: registerValues.password,
}); inviteCode: registerValues.inviteCode,
if (!resp.ok) { fallbackIterations: defaultKdfIterations,
pushToast('error', resp.message); });
return; if (!resp.ok) {
pushToast('error', resp.message);
return;
}
setLoginValues({ email: registerValues.email.toLowerCase(), password: '' });
setPhase('login');
navigate('/login');
pushToast('success', t('txt_registration_succeeded_please_sign_in'));
} finally {
setPendingAuthAction(null);
} }
setLoginValues({ email: registerValues.email.toLowerCase(), password: '' });
setPhase('login');
navigate('/login');
pushToast('success', t('txt_registration_succeeded_please_sign_in'));
} }
async function handleUnlock() { async function handleUnlock() {
if (pendingAuthAction) return;
if (!session || !profile) return; if (!session || !profile) return;
if (!unlockPassword) { if (!unlockPassword) {
pushToast('error', t('txt_please_input_master_password')); pushToast('error', t('txt_please_input_master_password'));
return; return;
} }
setPendingAuthAction('unlock');
try { try {
const nextSession = await performUnlock(session, profile, unlockPassword, defaultKdfIterations); const nextSession = await performUnlock(session, profile, unlockPassword, defaultKdfIterations);
setSession(nextSession); setSession(nextSession);
@@ -318,6 +331,8 @@ export default function App() {
pushToast('success', t('txt_unlocked')); pushToast('success', t('txt_unlocked'));
} catch { } catch {
pushToast('error', t('txt_unlock_failed_master_password_is_incorrect')); pushToast('error', t('txt_unlock_failed_master_password_is_incorrect'));
} finally {
setPendingAuthAction(null);
} }
} }
@@ -749,7 +764,6 @@ export default function App() {
setDisableTotpOpen(false); setDisableTotpOpen(false);
setDisableTotpPassword(''); setDisableTotpPassword('');
}, },
onPromptLogout: handleLogout,
onLogoutNow: logoutNow, onLogoutNow: logoutNow,
onNotify: pushToast, onNotify: pushToast,
onSetConfirm: setConfirm, onSetConfirm: setConfirm,
@@ -937,6 +951,7 @@ export default function App() {
<> <>
<AuthViews <AuthViews
mode={phase} mode={phase}
pendingAction={pendingAuthAction}
loginValues={loginValues} loginValues={loginValues}
registerValues={registerValues} registerValues={registerValues}
unlockPassword={unlockPassword} unlockPassword={unlockPassword}
+14 -9
View File
@@ -18,6 +18,7 @@ interface RegisterValues {
interface AuthViewsProps { interface AuthViewsProps {
mode: 'login' | 'register' | 'locked'; mode: 'login' | 'register' | 'locked';
pendingAction: 'login' | 'register' | 'unlock' | null;
loginValues: LoginValues; loginValues: LoginValues;
registerValues: RegisterValues; registerValues: RegisterValues;
unlockPassword: string; unlockPassword: string;
@@ -60,6 +61,10 @@ function PasswordField(props: {
} }
export default function AuthViews(props: AuthViewsProps) { export default function AuthViews(props: AuthViewsProps) {
const loginBusy = props.pendingAction === 'login';
const registerBusy = props.pendingAction === 'register';
const unlockBusy = props.pendingAction === 'unlock';
if (props.mode === 'locked') { if (props.mode === 'locked') {
return ( return (
<div className="auth-page"> <div className="auth-page">
@@ -77,12 +82,12 @@ export default function AuthViews(props: AuthViewsProps) {
autoFocus autoFocus
onInput={props.onChangeUnlock} onInput={props.onChangeUnlock}
/> />
<button type="submit" className="btn btn-primary full"> <button type="submit" className="btn btn-primary full" disabled={unlockBusy}>
<Unlock size={16} className="btn-icon" /> <Unlock size={16} className="btn-icon" />
{t('txt_unlock')} {unlockBusy ? t('txt_unlocking') : t('txt_unlock')}
</button> </button>
<div className="or">{t('txt_or')}</div> <div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onLogout}> <button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}>
<LogOut size={16} className="btn-icon" /> <LogOut size={16} className="btn-icon" />
{t('txt_log_out')} {t('txt_log_out')}
</button> </button>
@@ -143,12 +148,12 @@ export default function AuthViews(props: AuthViewsProps) {
} }
/> />
</label> </label>
<button type="submit" className="btn btn-primary full"> <button type="submit" className="btn btn-primary full" disabled={registerBusy}>
<UserPlus size={16} className="btn-icon" /> <UserPlus size={16} className="btn-icon" />
{t('txt_create_account')} {registerBusy ? t('txt_registering') : t('txt_create_account')}
</button> </button>
<div className="or">{t('txt_or')}</div> <div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onGotoLogin}> <button type="button" className="btn btn-secondary full" onClick={props.onGotoLogin} disabled={registerBusy}>
<ArrowLeft size={16} className="btn-icon" /> <ArrowLeft size={16} className="btn-icon" />
{t('txt_back_to_login')} {t('txt_back_to_login')}
</button> </button>
@@ -182,12 +187,12 @@ export default function AuthViews(props: AuthViewsProps) {
onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })} onInput={(v) => props.onChangeLogin({ ...props.loginValues, password: v })}
autoFocus autoFocus
/> />
<button type="submit" className="btn btn-primary full"> <button type="submit" className="btn btn-primary full" disabled={loginBusy}>
<LogIn size={16} className="btn-icon" /> <LogIn size={16} className="btn-icon" />
{t('txt_log_in')} {loginBusy ? t('txt_logging_in') : t('txt_log_in')}
</button> </button>
<div className="or">{t('txt_or')}</div> <div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onGotoRegister}> <button type="button" className="btn btn-secondary full" onClick={props.onGotoRegister} disabled={loginBusy}>
<UserPlus size={16} className="btn-icon" /> <UserPlus size={16} className="btn-icon" />
{t('txt_create_account')} {t('txt_create_account')}
</button> </button>
+23 -16
View File
@@ -23,7 +23,6 @@ interface UseAccountSecurityActionsOptions {
defaultKdfIterations: number; defaultKdfIterations: number;
disableTotpPassword: string; disableTotpPassword: string;
clearDisableTotpDialog: () => void; clearDisableTotpDialog: () => void;
onPromptLogout: () => void;
onLogoutNow: () => void; onLogoutNow: () => void;
onNotify: Notify; onNotify: Notify;
onSetConfirm: (next: AppConfirmState | null) => void; onSetConfirm: (next: AppConfirmState | null) => void;
@@ -38,7 +37,6 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
defaultKdfIterations, defaultKdfIterations,
disableTotpPassword, disableTotpPassword,
clearDisableTotpDialog, clearDisableTotpDialog,
onPromptLogout,
onLogoutNow, onLogoutNow,
onNotify, onNotify,
onSetConfirm, onSetConfirm,
@@ -62,19 +60,29 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
onNotify('error', t('txt_new_passwords_do_not_match')); onNotify('error', t('txt_new_passwords_do_not_match'));
return; return;
} }
try { onSetConfirm({
await changeMasterPassword(authedFetch, { title: t('txt_change_master_password'),
email: profile.email, message: t('txt_change_password_confirm_and_sign_out_all_devices'),
currentPassword, danger: true,
newPassword: nextPassword, onConfirm: () => {
currentIterations: defaultKdfIterations, onSetConfirm(null);
profileKey: profile.key, void (async () => {
}); try {
onPromptLogout(); await changeMasterPassword(authedFetch, {
onNotify('success', t('txt_master_password_changed_please_login_again')); email: profile.email,
} catch (error) { currentPassword,
onNotify('error', error instanceof Error ? error.message : t('txt_change_password_failed')); newPassword: nextPassword,
} currentIterations: defaultKdfIterations,
profileKey: profile.key,
});
onNotify('success', t('txt_master_password_changed_signing_out_everywhere'));
onLogoutNow();
} catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_change_password_failed'));
}
})();
},
});
}, },
async enableTotp(secret: string, token: string) { async enableTotp(secret: string, token: string) {
@@ -200,7 +208,6 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
disableTotpPassword, disableTotpPassword,
onLogoutNow, onLogoutNow,
onNotify, onNotify,
onPromptLogout,
onSetConfirm, onSetConfirm,
profile, profile,
refetchAuthorizedDevices, refetchAuthorizedDevices,
+10
View File
@@ -229,6 +229,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_change_master_password: "Change Master Password", txt_change_master_password: "Change Master Password",
txt_change_password: "Change Password", txt_change_password: "Change Password",
txt_change_password_failed: "Change password failed", txt_change_password_failed: "Change password failed",
txt_change_password_confirm_and_sign_out_all_devices: "Changing the master password will sign out all devices, including this web session. Continue?",
txt_checked: "Checked", txt_checked: "Checked",
txt_choose_destination_folder: "Choose destination folder.", txt_choose_destination_folder: "Choose destination folder.",
txt_chrome_browser: "Chrome Browser", txt_chrome_browser: "Chrome Browser",
@@ -248,6 +249,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_country: "Country", txt_country: "Country",
txt_create: "Create", txt_create: "Create",
txt_create_account: "Create Account", txt_create_account: "Create Account",
txt_registering: "Creating account...",
txt_create_folder: "Create Folder", txt_create_folder: "Create Folder",
txt_create_folder_failed: "Create folder failed", txt_create_folder_failed: "Create folder failed",
txt_create_item_failed: "Create item failed", txt_create_item_failed: "Create item failed",
@@ -380,6 +382,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_random_secret_generator: "Random Secret Generator", txt_random_secret_generator: "Random Secret Generator",
txt_copied: "Copied", txt_copied: "Copied",
txt_log_in: "Log In", txt_log_in: "Log In",
txt_logging_in: "Logging in...",
txt_log_out: "Log Out", txt_log_out: "Log Out",
txt_lock: "Lock", txt_lock: "Lock",
txt_menu: "Menu", txt_menu: "Menu",
@@ -394,6 +397,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_manage_device_sessions_and_30_day_totp_trusted_sessions: "Manage device sessions and 30-day TOTP trusted sessions.", txt_manage_device_sessions_and_30_day_totp_trusted_sessions: "Manage device sessions and 30-day TOTP trusted sessions.",
txt_master_password: "Master Password", txt_master_password: "Master Password",
txt_master_password_changed_please_login_again: "Master password changed. Please login again.", txt_master_password_changed_please_login_again: "Master password changed. Please login again.",
txt_master_password_changed_signing_out_everywhere: "Master password changed. Signing out all devices.",
txt_master_password_is_required: "Master password is required", txt_master_password_is_required: "Master password is required",
txt_master_password_is_required_2: "Master password is required.", txt_master_password_is_required_2: "Master password is required.",
txt_master_password_must_be_at_least_12_chars: "Master password must be at least 12 chars", txt_master_password_must_be_at_least_12_chars: "Master password must be at least 12 chars",
@@ -544,6 +548,7 @@ const messages: Record<Locale, Record<string, string>> = {
txt_unchecked: "Unchecked", txt_unchecked: "Unchecked",
txt_unknown_device: "Unknown device", txt_unknown_device: "Unknown device",
txt_unlock: "Unlock", txt_unlock: "Unlock",
txt_unlocking: "Unlocking...",
txt_unlock_details: "Unlock Details", txt_unlock_details: "Unlock Details",
txt_unlock_failed: "Unlock failed", txt_unlock_failed: "Unlock failed",
txt_unlock_failed_master_password_is_incorrect: "Unlock failed. Master password is incorrect.", txt_unlock_failed_master_password_is_incorrect: "Unlock failed. Master password is incorrect.",
@@ -762,10 +767,13 @@ const zhCNOverrides: Record<string, string> = {
txt_backup_clear_and_restore: '清空后还原', txt_backup_clear_and_restore: '清空后还原',
txt_sign_out: '退出登录', txt_sign_out: '退出登录',
txt_log_in: '登录', txt_log_in: '登录',
txt_logging_in: '正在登录...',
txt_log_out: '退出', txt_log_out: '退出',
txt_create_account: '创建账户', txt_create_account: '创建账户',
txt_registering: '正在注册...',
txt_back_to_login: '返回登录', txt_back_to_login: '返回登录',
txt_unlock: '解锁', txt_unlock: '解锁',
txt_unlocking: '正在解锁...',
txt_unlock_vault: '解锁密码库', txt_unlock_vault: '解锁密码库',
txt_master_password: '主密码', txt_master_password: '主密码',
txt_email: '邮箱', txt_email: '邮箱',
@@ -845,6 +853,7 @@ const zhCNOverrides: Record<string, string> = {
txt_current_password: '当前密码', txt_current_password: '当前密码',
txt_new_password: '新密码', txt_new_password: '新密码',
txt_change_password: '修改密码', txt_change_password: '修改密码',
txt_change_password_confirm_and_sign_out_all_devices: '修改主密码后会强制退出所有设备,包括当前网页端。确认继续吗',
txt_totp: 'TOTP', txt_totp: 'TOTP',
txt_enable_totp: '启用 TOTP', txt_enable_totp: '启用 TOTP',
txt_disable_totp: '停用 TOTP', txt_disable_totp: '停用 TOTP',
@@ -1037,6 +1046,7 @@ const zhCNOverrides: Record<string, string> = {
txt_login_success: '登录成功', txt_login_success: '登录成功',
txt_macos_desktop: 'macOS 桌面端', txt_macos_desktop: 'macOS 桌面端',
txt_master_password_changed_please_login_again: '主密码已修改,请重新登录', txt_master_password_changed_please_login_again: '主密码已修改,请重新登录',
txt_master_password_changed_signing_out_everywhere: '主密码已修改,正在退出所有设备',
txt_master_password_is_required: '主密码不能为空', txt_master_password_is_required: '主密码不能为空',
txt_master_password_is_required_2: '请输入主密码', txt_master_password_is_required_2: '请输入主密码',
txt_master_password_must_be_at_least_12_chars: '主密码至少需要 12 个字符', txt_master_password_must_be_at_least_12_chars: '主密码至少需要 12 个字符',