feat: add passkey-first login and management flow

This commit is contained in:
Shuai
2026-03-31 00:59:50 +08:00
parent 1184cb8d9a
commit 0f6da7d147
16 changed files with 799 additions and 6 deletions
+8
View File
@@ -94,6 +94,10 @@ export interface AppMainRoutesProps {
onEnableTotp: (secret: string, token: string) => Promise<void>;
onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
passkeys: Array<{ id: string; name: string; creationDate: string; lastUsedDate: string | null }>;
onCreatePasskey: (name: string) => Promise<void>;
onRenamePasskey: (id: string, name: string) => Promise<void>;
onDeletePasskey: (id: string) => Promise<void>;
onRefreshAuthorizedDevices: () => Promise<void>;
onRevokeDeviceTrust: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void;
@@ -225,6 +229,10 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onOpenDisableTotp={props.onOpenDisableTotp}
onGetRecoveryCode={props.onGetRecoveryCode}
onNotify={props.onNotify}
passkeys={props.passkeys}
onCreatePasskey={props.onCreatePasskey}
onRenamePasskey={props.onRenamePasskey}
onDeletePasskey={props.onDeletePasskey}
/>
</Suspense>
</div>
+15 -1
View File
@@ -1,5 +1,5 @@
import { useState } from 'preact/hooks';
import { ArrowLeft, Eye, EyeOff, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
import { ArrowLeft, Eye, EyeOff, Fingerprint, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
import StandalonePageFrame from '@/components/StandalonePageFrame';
import { t } from '@/lib/i18n';
@@ -30,6 +30,7 @@ interface AuthViewsProps {
onChangeRegister: (next: RegisterValues) => void;
onChangeUnlock: (password: string) => void;
onSubmitLogin: () => void;
onSubmitPasskey: () => void;
onSubmitRegister: () => void;
onSubmitUnlock: () => void;
onGotoLogin: () => void;
@@ -37,6 +38,7 @@ interface AuthViewsProps {
onLogout: () => void;
onTogglePasswordHint: () => void;
onShowLockedPasswordHint: () => void;
passkeySupported: boolean;
}
function PasswordField(props: {
@@ -106,6 +108,12 @@ export default function AuthViews(props: AuthViewsProps) {
<Unlock size={16} className="btn-icon" />
{unlockBusy ? t('txt_unlocking') : t('txt_unlock')}
</button>
{props.passkeySupported && (
<button type="button" className="btn btn-secondary full" onClick={props.onSubmitPasskey} disabled={unlockBusy}>
<Fingerprint size={16} className="btn-icon" />
Passkey
</button>
)}
<div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}>
<LogOut size={16} className="btn-icon" />
@@ -243,6 +251,12 @@ export default function AuthViews(props: AuthViewsProps) {
<LogIn size={16} className="btn-icon" />
{loginBusy ? t('txt_logging_in') : t('txt_log_in')}
</button>
{props.passkeySupported && (
<button type="button" className="btn btn-secondary full" onClick={props.onSubmitPasskey} disabled={loginBusy}>
<Fingerprint size={16} className="btn-icon" />
Passkey
</button>
)}
<div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onGotoRegister} disabled={loginBusy}>
<UserPlus size={16} className="btn-icon" />
+32
View File
@@ -14,6 +14,10 @@ interface SettingsPageProps {
onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onNotify?: (type: 'success' | 'error', text: string) => void;
passkeys: Array<{ id: string; name: string; creationDate: string; lastUsedDate: string | null }>;
onCreatePasskey: (name: string) => Promise<void>;
onRenamePasskey: (id: string, name: string) => Promise<void>;
onDeletePasskey: (id: string) => Promise<void>;
}
function randomBase32Secret(length: number): string {
@@ -47,6 +51,7 @@ export default function SettingsPage(props: SettingsPageProps) {
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
const [recoveryCode, setRecoveryCode] = useState('');
const [passkeyName, setPasskeyName] = useState('');
useEffect(() => {
if (!props.totpEnabled) {
@@ -140,6 +145,33 @@ export default function SettingsPage(props: SettingsPageProps) {
</button>
</section>
<section className="card">
<h3>Passkey</h3>
<div className="field-grid">
<label className="field">
<span></span>
<input className="input" value={passkeyName} onInput={(e) => setPasskeyName((e.currentTarget as HTMLInputElement).value)} placeholder="例如:MacBook Touch ID" />
</label>
<div className="field" style={{ alignSelf: 'end' }}>
<button type="button" className="btn btn-primary" disabled={!passkeyName.trim()} onClick={() => void props.onCreatePasskey(passkeyName.trim()).then(() => setPasskeyName(''))}>
Passkey
</button>
</div>
</div>
<p className="muted-inline" style={{ marginBottom: 8 }}> 5 </p>
<div className="stack">
{props.passkeys.map((item) => (
<div key={item.id} className="card" style={{ marginBottom: 0 }}>
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' }}>
<input className="input" style={{ flex: 1, minWidth: 180 }} value={item.name} onInput={(e) => void props.onRenamePasskey(item.id, (e.currentTarget as HTMLInputElement).value)} />
<button type="button" className="btn btn-danger" onClick={() => void props.onDeletePasskey(item.id)}></button>
</div>
</div>
))}
{!props.passkeys.length && <div className="empty"> Passkey</div>}
</div>
</section>
<section className="card">
<div className="settings-twofactor-grid">
<div className="settings-subcard">