mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: add passkey-first login and management flow
This commit is contained in:
+77
-3
@@ -12,6 +12,10 @@ import {
|
||||
getAuthorizedDevices,
|
||||
getCurrentDeviceIdentifier,
|
||||
getPasswordHint,
|
||||
listAccountPasskeys,
|
||||
registerAccountPasskey,
|
||||
renameAccountPasskey,
|
||||
deleteAccountPasskey,
|
||||
getTotpStatus,
|
||||
saveSession,
|
||||
} from '@/lib/api/auth';
|
||||
@@ -36,6 +40,7 @@ import {
|
||||
type CompletedLogin,
|
||||
readInitialAppBootstrapState,
|
||||
performPasswordLogin,
|
||||
performPasskeyLogin,
|
||||
performRecoverTwoFactorLogin,
|
||||
performRegistration,
|
||||
performTotpLogin,
|
||||
@@ -43,6 +48,7 @@ import {
|
||||
type JwtUnsafeReason,
|
||||
type PendingTotp,
|
||||
} from '@/lib/app-auth';
|
||||
import { passkeySupported } from '@/lib/passkey';
|
||||
import useAccountSecurityActions from '@/hooks/useAccountSecurityActions';
|
||||
import useAdminActions from '@/hooks/useAdminActions';
|
||||
import useBackupActions from '@/hooks/useBackupActions';
|
||||
@@ -153,6 +159,7 @@ export default function App() {
|
||||
const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode);
|
||||
const [unlockPassword, setUnlockPassword] = useState('');
|
||||
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
|
||||
const [pendingPasskeyTotp, setPendingPasskeyTotp] = useState(false);
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [rememberDevice, setRememberDevice] = useState(true);
|
||||
|
||||
@@ -334,6 +341,7 @@ export default function App() {
|
||||
setSession(login.session);
|
||||
setProfile(login.profile);
|
||||
setPendingTotp(null);
|
||||
setPendingPasskeyTotp(false);
|
||||
setTotpCode('');
|
||||
setPhase('app');
|
||||
if (location === '/' || location === '/login' || location === '/register' || location === '/lock') {
|
||||
@@ -379,19 +387,53 @@ export default function App() {
|
||||
}
|
||||
|
||||
async function handleTotpVerify() {
|
||||
if (!pendingTotp) return;
|
||||
if (!pendingTotp && !pendingPasskeyTotp) return;
|
||||
if (!totpCode.trim()) {
|
||||
pushToast('error', t('txt_please_input_totp_code'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
|
||||
const login = pendingTotp
|
||||
? await performTotpLogin(pendingTotp, totpCode, rememberDevice)
|
||||
: (await (async () => {
|
||||
const passkeyResult = await performPasskeyLogin(loginValues.email, totpCode);
|
||||
if (passkeyResult.kind !== 'success') throw new Error(t('txt_totp_verify_failed'));
|
||||
return passkeyResult.login;
|
||||
})());
|
||||
await finalizeLogin(login);
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePasskeyLogin() {
|
||||
if (pendingAuthAction) return;
|
||||
if (!passkeySupported()) {
|
||||
pushToast('error', '当前浏览器不支持 Passkey');
|
||||
return;
|
||||
}
|
||||
setPendingAuthAction('login');
|
||||
try {
|
||||
const result = await performPasskeyLogin(loginValues.email);
|
||||
if (result.kind === 'success') {
|
||||
await finalizeLogin(result.login);
|
||||
return;
|
||||
}
|
||||
if (result.kind === 'totp') {
|
||||
setPendingPasskeyTotp(true);
|
||||
setPendingTotp(null);
|
||||
setTotpCode('');
|
||||
setRememberDevice(false);
|
||||
return;
|
||||
}
|
||||
pushToast('error', result.message || t('txt_login_failed'));
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : t('txt_login_failed'));
|
||||
} finally {
|
||||
setPendingAuthAction(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRecoverTwoFactorSubmit() {
|
||||
const email = recoverValues.email.trim().toLowerCase();
|
||||
const password = recoverValues.password;
|
||||
@@ -527,6 +569,24 @@ export default function App() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreatePasskey(name: string) {
|
||||
if (!session?.symEncKey || !session?.symMacKey) throw new Error('请先解锁后再创建 Passkey');
|
||||
await registerAccountPasskey(authedFetch, name, session);
|
||||
await passkeysQuery.refetch();
|
||||
pushToast('success', 'Passkey 已创建');
|
||||
}
|
||||
|
||||
async function handleRenamePasskey(id: string, name: string) {
|
||||
await renameAccountPasskey(authedFetch, id, name);
|
||||
await passkeysQuery.refetch();
|
||||
}
|
||||
|
||||
async function handleDeletePasskey(id: string) {
|
||||
await deleteAccountPasskey(authedFetch, id);
|
||||
await passkeysQuery.refetch();
|
||||
pushToast('success', 'Passkey 已删除');
|
||||
}
|
||||
|
||||
function handleLock() {
|
||||
if (!session) return;
|
||||
const nextSession = { ...session };
|
||||
@@ -542,6 +602,7 @@ export default function App() {
|
||||
setSession(null);
|
||||
setProfile(null);
|
||||
setPendingTotp(null);
|
||||
setPendingPasskeyTotp(false);
|
||||
setPhase('login');
|
||||
navigate('/login');
|
||||
}
|
||||
@@ -616,6 +677,11 @@ export default function App() {
|
||||
queryFn: () => getAuthorizedDevices(authedFetch),
|
||||
enabled: phase === 'app' && !!session?.accessToken,
|
||||
});
|
||||
const passkeysQuery = useQuery({
|
||||
queryKey: ['account-passkeys', session?.accessToken],
|
||||
queryFn: () => listAccountPasskeys(authedFetch),
|
||||
enabled: phase === 'app' && !!session?.accessToken,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return;
|
||||
@@ -1139,6 +1205,10 @@ export default function App() {
|
||||
},
|
||||
onOpenDisableTotp: () => setDisableTotpOpen(true),
|
||||
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
|
||||
passkeys: passkeysQuery.data || [],
|
||||
onCreatePasskey: handleCreatePasskey,
|
||||
onRenamePasskey: handleRenamePasskey,
|
||||
onDeletePasskey: handleDeletePasskey,
|
||||
onRefreshAuthorizedDevices: accountSecurityActions.refreshAuthorizedDevices,
|
||||
onRevokeDeviceTrust: accountSecurityActions.openRevokeDeviceTrust,
|
||||
onRemoveDevice: accountSecurityActions.openRemoveDevice,
|
||||
@@ -1210,6 +1280,7 @@ export default function App() {
|
||||
onChangeRegister={setRegisterValues}
|
||||
onChangeUnlock={setUnlockPassword}
|
||||
onSubmitLogin={() => void handleLogin()}
|
||||
onSubmitPasskey={() => void handlePasskeyLogin()}
|
||||
onSubmitRegister={() => void handleRegister()}
|
||||
onSubmitUnlock={() => void handleUnlock()}
|
||||
onGotoLogin={() => {
|
||||
@@ -1226,13 +1297,14 @@ export default function App() {
|
||||
onLogout={logoutNow}
|
||||
onTogglePasswordHint={() => void handleTogglePasswordHint()}
|
||||
onShowLockedPasswordHint={handleShowLockedPasswordHint}
|
||||
passkeySupported={passkeySupported()}
|
||||
/>
|
||||
<AppGlobalOverlays
|
||||
toasts={toasts}
|
||||
onCloseToast={removeToast}
|
||||
confirm={confirm}
|
||||
onCancelConfirm={() => setConfirm(null)}
|
||||
pendingTotpOpen={!!pendingTotp}
|
||||
pendingTotpOpen={!!pendingTotp || pendingPasskeyTotp}
|
||||
totpCode={totpCode}
|
||||
rememberDevice={rememberDevice}
|
||||
onTotpCodeChange={setTotpCode}
|
||||
@@ -1240,11 +1312,13 @@ export default function App() {
|
||||
onConfirmTotp={() => void handleTotpVerify()}
|
||||
onCancelTotp={() => {
|
||||
setPendingTotp(null);
|
||||
setPendingPasskeyTotp(false);
|
||||
setTotpCode('');
|
||||
setRememberDevice(true);
|
||||
}}
|
||||
onUseRecoveryCode={() => {
|
||||
setPendingTotp(null);
|
||||
setPendingPasskeyTotp(false);
|
||||
setTotpCode('');
|
||||
setRememberDevice(true);
|
||||
navigate('/recover-2fa');
|
||||
|
||||
Reference in New Issue
Block a user