mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: add passkey-first login and management flow
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user