mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
Refactor: Remove passkey-related functionality and types
- Deleted passkey-related interfaces and types from index.ts and types.ts. - Removed passkey handling from App component, including related state and functions. - Cleaned up API calls in auth.ts, removing passkey registration and login functions. - Updated vault and import formats to eliminate passkey references. - Removed passkey support checks and UI elements from AuthViews and SettingsPage. - Cleaned up unused passkey helper functions and constants. - Adjusted related components and hooks to ensure consistent functionality without passkey support.
This commit is contained in:
+3
-80
@@ -12,10 +12,6 @@ import {
|
||||
getAuthorizedDevices,
|
||||
getCurrentDeviceIdentifier,
|
||||
getPasswordHint,
|
||||
listAccountPasskeys,
|
||||
registerAccountPasskey,
|
||||
renameAccountPasskey,
|
||||
deleteAccountPasskey,
|
||||
getTotpStatus,
|
||||
saveSession,
|
||||
} from '@/lib/api/auth';
|
||||
@@ -40,7 +36,6 @@ import {
|
||||
type CompletedLogin,
|
||||
readInitialAppBootstrapState,
|
||||
performPasswordLogin,
|
||||
performPasskeyLogin,
|
||||
performRecoverTwoFactorLogin,
|
||||
performRegistration,
|
||||
performTotpLogin,
|
||||
@@ -48,7 +43,6 @@ 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';
|
||||
@@ -159,7 +153,6 @@ 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);
|
||||
|
||||
@@ -341,7 +334,6 @@ 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') {
|
||||
@@ -387,53 +379,19 @@ export default function App() {
|
||||
}
|
||||
|
||||
async function handleTotpVerify() {
|
||||
if (!pendingTotp && !pendingPasskeyTotp) return;
|
||||
if (!pendingTotp) return;
|
||||
if (!totpCode.trim()) {
|
||||
pushToast('error', t('txt_please_input_totp_code'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
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;
|
||||
})());
|
||||
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
|
||||
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;
|
||||
@@ -569,24 +527,6 @@ 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 };
|
||||
@@ -602,7 +542,6 @@ export default function App() {
|
||||
setSession(null);
|
||||
setProfile(null);
|
||||
setPendingTotp(null);
|
||||
setPendingPasskeyTotp(false);
|
||||
setPhase('login');
|
||||
navigate('/login');
|
||||
}
|
||||
@@ -677,11 +616,6 @@ 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;
|
||||
@@ -757,9 +691,6 @@ export default function App() {
|
||||
decUsername: await decryptField(cipher.login.username || '', itemEnc, itemMac),
|
||||
decPassword: await decryptField(cipher.login.password || '', itemEnc, itemMac),
|
||||
decTotp: await decryptField(cipher.login.totp || '', itemEnc, itemMac),
|
||||
fido2Credentials: Array.isArray(cipher.login.fido2Credentials)
|
||||
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
|
||||
: null,
|
||||
uris: await Promise.all(
|
||||
(cipher.login.uris || []).map(async (u) => ({
|
||||
...u,
|
||||
@@ -1205,10 +1136,6 @@ 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,
|
||||
@@ -1280,7 +1207,6 @@ export default function App() {
|
||||
onChangeRegister={setRegisterValues}
|
||||
onChangeUnlock={setUnlockPassword}
|
||||
onSubmitLogin={() => void handleLogin()}
|
||||
onSubmitPasskey={() => void handlePasskeyLogin()}
|
||||
onSubmitRegister={() => void handleRegister()}
|
||||
onSubmitUnlock={() => void handleUnlock()}
|
||||
onGotoLogin={() => {
|
||||
@@ -1297,14 +1223,13 @@ 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 || pendingPasskeyTotp}
|
||||
pendingTotpOpen={!!pendingTotp}
|
||||
totpCode={totpCode}
|
||||
rememberDevice={rememberDevice}
|
||||
onTotpCodeChange={setTotpCode}
|
||||
@@ -1312,13 +1237,11 @@ 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');
|
||||
|
||||
@@ -94,10 +94,6 @@ 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;
|
||||
@@ -229,10 +225,6 @@ 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, Fingerprint, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
|
||||
import { ArrowLeft, Eye, EyeOff, LogIn, LogOut, Unlock, UserPlus } from 'lucide-preact';
|
||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||
import { t } from '@/lib/i18n';
|
||||
|
||||
@@ -30,7 +30,6 @@ interface AuthViewsProps {
|
||||
onChangeRegister: (next: RegisterValues) => void;
|
||||
onChangeUnlock: (password: string) => void;
|
||||
onSubmitLogin: () => void;
|
||||
onSubmitPasskey: () => void;
|
||||
onSubmitRegister: () => void;
|
||||
onSubmitUnlock: () => void;
|
||||
onGotoLogin: () => void;
|
||||
@@ -38,7 +37,6 @@ interface AuthViewsProps {
|
||||
onLogout: () => void;
|
||||
onTogglePasswordHint: () => void;
|
||||
onShowLockedPasswordHint: () => void;
|
||||
passkeySupported: boolean;
|
||||
}
|
||||
|
||||
function PasswordField(props: {
|
||||
@@ -108,12 +106,6 @@ 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" />
|
||||
@@ -251,12 +243,6 @@ 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" />
|
||||
|
||||
@@ -15,10 +15,6 @@ 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 {
|
||||
@@ -52,10 +48,6 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
|
||||
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
|
||||
const [recoveryCode, setRecoveryCode] = useState('');
|
||||
const [passkeyName, setPasskeyName] = useState('');
|
||||
const [renamePasskey, setRenamePasskey] = useState<{ id: string; name: string } | null>(null);
|
||||
const [renamePasskeyName, setRenamePasskeyName] = useState('');
|
||||
const [deletePasskey, setDeletePasskey] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.totpEnabled) {
|
||||
@@ -102,21 +94,6 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
return parsed.toLocaleString();
|
||||
}
|
||||
|
||||
async function confirmRenamePasskey(): Promise<void> {
|
||||
if (!renamePasskey) return;
|
||||
const nextName = renamePasskeyName.trim();
|
||||
if (!nextName) return;
|
||||
await props.onRenamePasskey(renamePasskey.id, nextName);
|
||||
setRenamePasskey(null);
|
||||
setRenamePasskeyName('');
|
||||
}
|
||||
|
||||
async function confirmDeletePasskey(): Promise<void> {
|
||||
if (!deletePasskey) return;
|
||||
await props.onDeletePasskey(deletePasskey.id);
|
||||
setDeletePasskey(null);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<section className="card">
|
||||
@@ -172,91 +149,6 @@ 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" style={{ gap: 6 }}>
|
||||
{props.passkeys.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
alignItems: 'center',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: 10,
|
||||
padding: '10px 12px',
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<strong>{item.name}</strong>
|
||||
<span style={{ marginLeft: 'auto', fontSize: 12, opacity: 0.72 }}>
|
||||
创建于 {formatDateTime(item.creationDate)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small"
|
||||
onClick={() => {
|
||||
setRenamePasskey({ id: item.id, name: item.name });
|
||||
setRenamePasskeyName(item.name);
|
||||
}}
|
||||
>
|
||||
{t('txt_edit')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger small"
|
||||
onClick={() => setDeletePasskey({ id: item.id, name: item.name })}
|
||||
>
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
{!props.passkeys.length && <div className="empty">暂无 Passkey</div>}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!renamePasskey}
|
||||
title={t('txt_edit')}
|
||||
message={t('txt_enter_a_folder_name')}
|
||||
confirmText={t('txt_save')}
|
||||
cancelText={t('txt_cancel')}
|
||||
onConfirm={() => void confirmRenamePasskey()}
|
||||
onCancel={() => {
|
||||
setRenamePasskey(null);
|
||||
setRenamePasskeyName('');
|
||||
}}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_name')}</span>
|
||||
<input className="input" value={renamePasskeyName} onInput={(e) => setRenamePasskeyName((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
</ConfirmDialog>
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!deletePasskey}
|
||||
title={t('txt_delete')}
|
||||
message={deletePasskey ? `确认删除 Passkey「${deletePasskey.name}」吗?` : ''}
|
||||
variant="warning"
|
||||
danger
|
||||
confirmText={t('txt_delete')}
|
||||
cancelText={t('txt_cancel')}
|
||||
onConfirm={() => void confirmDeletePasskey()}
|
||||
onCancel={() => setDeletePasskey(null)}
|
||||
/>
|
||||
|
||||
<section className="card">
|
||||
<div className="settings-twofactor-grid">
|
||||
<div className="settings-subcard">
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
draftFromCipher,
|
||||
buildCipherDuplicateSignature,
|
||||
firstCipherUri,
|
||||
firstPasskeyCreationTime,
|
||||
isCipherVisibleInArchive,
|
||||
isCipherVisibleInNormalVault,
|
||||
isCipherVisibleInTrash,
|
||||
@@ -352,7 +351,6 @@ export default function VaultPage(props: VaultPageProps) {
|
||||
() => filteredCiphers.slice(virtualRange.start, virtualRange.end),
|
||||
[filteredCiphers, virtualRange.start, virtualRange.end]
|
||||
);
|
||||
const passkeyCreatedAt = firstPasskeyCreationTime(selectedCipher);
|
||||
const selectedAttachments = useMemo(
|
||||
() => (Array.isArray(selectedCipher?.attachments) ? selectedCipher.attachments : []),
|
||||
[selectedCipher]
|
||||
@@ -973,7 +971,6 @@ function folderName(id: string | null | undefined): string {
|
||||
repromptApprovedCipherId={repromptApprovedCipherId}
|
||||
showPassword={showPassword}
|
||||
totpLive={totpLive}
|
||||
passkeyCreatedAt={passkeyCreatedAt}
|
||||
hiddenFieldVisibleMap={hiddenFieldVisibleMap}
|
||||
folderName={folderName}
|
||||
onOpenReprompt={() => setRepromptOpen(true)}
|
||||
|
||||
@@ -20,7 +20,6 @@ interface VaultDetailViewProps {
|
||||
repromptApprovedCipherId: string | null;
|
||||
showPassword: boolean;
|
||||
totpLive: { code: string; remain: number } | null;
|
||||
passkeyCreatedAt: string | null;
|
||||
hiddenFieldVisibleMap: Record<number, boolean>;
|
||||
folderName: (id: string | null | undefined) => string;
|
||||
downloadingAttachmentKey: string;
|
||||
@@ -136,15 +135,6 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!!props.passkeyCreatedAt && (
|
||||
<div className="kv-row">
|
||||
<span className="kv-label">{t('txt_passkey')}</span>
|
||||
<div className="kv-main">
|
||||
<strong>{t('txt_passkey_created_at_value', { value: formatHistoryTime(props.passkeyCreatedAt) })}</strong>
|
||||
</div>
|
||||
<div className="kv-actions" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -194,9 +194,6 @@ export function buildCipherDuplicateSignature(cipher: Cipher): string {
|
||||
uri: valueOrFallback(uri.decUri ?? uri.uri),
|
||||
match: uri.match ?? null,
|
||||
})),
|
||||
fido2Credentials: (cipher.login.fido2Credentials || []).map((credential) => ({
|
||||
creationDate: valueOrFallback(credential.creationDate),
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
card: cipher.card
|
||||
@@ -265,7 +262,6 @@ export function createEmptyDraft(type: number): VaultDraft {
|
||||
loginPassword: '',
|
||||
loginTotp: '',
|
||||
loginUris: [createEmptyLoginUri()],
|
||||
loginFido2Credentials: [],
|
||||
cardholderName: '',
|
||||
cardNumber: '',
|
||||
cardBrand: '',
|
||||
@@ -314,9 +310,6 @@ export function draftFromCipher(cipher: Cipher): VaultDraft {
|
||||
uri: x.decUri || x.uri || '',
|
||||
match: x.match ?? null,
|
||||
}));
|
||||
draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials)
|
||||
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
|
||||
: [];
|
||||
if (!draft.loginUris.length) draft.loginUris = [createEmptyLoginUri()];
|
||||
}
|
||||
if (cipher.card) {
|
||||
@@ -413,16 +406,6 @@ export function creationTimeValue(cipher: Cipher): number {
|
||||
return Number.isFinite(time) ? time : 0;
|
||||
}
|
||||
|
||||
export function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
|
||||
const credentials = cipher?.login?.fido2Credentials;
|
||||
if (!Array.isArray(credentials) || credentials.length === 0) return null;
|
||||
for (const credential of credentials) {
|
||||
const raw = String(credential?.creationDate || '').trim();
|
||||
if (raw) return raw;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const failedIconHosts = new Set<string>();
|
||||
|
||||
export function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
TokenSuccess,
|
||||
} from '../types';
|
||||
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
||||
import { createPasskeyCredential, requestPasskeyAssertion } from '../passkey';
|
||||
|
||||
const SESSION_KEY = 'nodewarden.web.session.v4';
|
||||
const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1';
|
||||
@@ -27,14 +26,6 @@ export interface PreloginKdfConfig {
|
||||
kdfParallelism: number | null;
|
||||
}
|
||||
|
||||
export interface AccountPasskey {
|
||||
id: string;
|
||||
name: string;
|
||||
creationDate: string;
|
||||
revisionDate: string;
|
||||
lastUsedDate: string | null;
|
||||
}
|
||||
|
||||
function randomHex(length: number): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
|
||||
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
|
||||
@@ -206,84 +197,6 @@ export async function refreshAccessToken(refreshToken: string): Promise<TokenSuc
|
||||
return json || null;
|
||||
}
|
||||
|
||||
export async function listAccountPasskeys(authedFetch: AuthedFetch): Promise<AccountPasskey[]> {
|
||||
const resp = await authedFetch('/api/accounts/passkeys');
|
||||
if (!resp.ok) throw new Error('Failed to load passkeys');
|
||||
const body = (await parseJson<{ data?: AccountPasskey[] }>(resp)) || {};
|
||||
return Array.isArray(body.data) ? body.data : [];
|
||||
}
|
||||
|
||||
export async function registerAccountPasskey(authedFetch: AuthedFetch, name: string, session: SessionState): Promise<void> {
|
||||
const beginResp = await authedFetch('/api/accounts/passkeys/begin-registration', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: '{}',
|
||||
});
|
||||
if (!beginResp.ok) throw new Error('Failed to start passkey registration');
|
||||
const begin = (await parseJson<{ challengeId: string; publicKey: Record<string, any> }>(beginResp)) || {};
|
||||
if (!begin.challengeId || !begin.publicKey) throw new Error('Invalid registration challenge');
|
||||
|
||||
const credential = await createPasskeyCredential(begin.publicKey);
|
||||
const finishResp = await authedFetch('/api/accounts/passkeys/finish-registration', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
challengeId: begin.challengeId,
|
||||
name,
|
||||
wrappedVaultKeys: JSON.stringify({
|
||||
symEncKey: session.symEncKey || '',
|
||||
symMacKey: session.symMacKey || '',
|
||||
}),
|
||||
credential,
|
||||
}),
|
||||
});
|
||||
if (!finishResp.ok) {
|
||||
const err = await parseJson<TokenError>(finishResp);
|
||||
throw new Error(err?.error_description || err?.error || 'Failed to finish passkey registration');
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameAccountPasskey(authedFetch: AuthedFetch, passkeyId: string, name: string): Promise<void> {
|
||||
const resp = await authedFetch(`/api/accounts/passkeys/${passkeyId}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
if (!resp.ok) throw new Error('Failed to rename passkey');
|
||||
}
|
||||
|
||||
export async function deleteAccountPasskey(authedFetch: AuthedFetch, passkeyId: string): Promise<void> {
|
||||
const resp = await authedFetch(`/api/accounts/passkeys/${passkeyId}`, { method: 'DELETE' });
|
||||
if (!resp.ok && resp.status !== 204) throw new Error('Failed to delete passkey');
|
||||
}
|
||||
|
||||
export async function loginWithPasskey(email?: string, totpCode?: string): Promise<TokenSuccess | TokenError> {
|
||||
const beginResp = await fetch('/identity/passkeys/begin-login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email: String(email || '').trim().toLowerCase() || undefined }),
|
||||
});
|
||||
if (!beginResp.ok) return ((await parseJson<TokenError>(beginResp)) || {});
|
||||
const begin = (await parseJson<{ challengeId: string; publicKey: Record<string, any> }>(beginResp)) || {};
|
||||
if (!begin.challengeId || !begin.publicKey) return { error: 'Passkey challenge missing' };
|
||||
|
||||
const credential = await requestPasskeyAssertion(begin.publicKey);
|
||||
const finishResp = await fetch('/identity/passkeys/finish-login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
challengeId: begin.challengeId,
|
||||
credential,
|
||||
deviceIdentifier: getOrCreateDeviceIdentifier(),
|
||||
deviceName: guessDeviceName(),
|
||||
deviceType: '14',
|
||||
twoFactorToken: totpCode || undefined,
|
||||
}),
|
||||
});
|
||||
const result = (await parseJson<TokenSuccess & TokenError>(finishResp)) || {};
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function registerAccount(args: {
|
||||
email: string;
|
||||
name: string;
|
||||
|
||||
@@ -392,56 +392,6 @@ function toIsoDateOrNow(value: unknown): string {
|
||||
return parsed.toISOString();
|
||||
}
|
||||
|
||||
async function encryptMaybeFidoValue(
|
||||
value: unknown,
|
||||
enc: Uint8Array,
|
||||
mac: Uint8Array,
|
||||
fallback = ''
|
||||
): Promise<string> {
|
||||
const normalized = String(value ?? '').trim() || fallback;
|
||||
if (looksLikeCipherString(normalized)) return normalized;
|
||||
return encryptBw(new TextEncoder().encode(normalized), enc, mac);
|
||||
}
|
||||
|
||||
async function encryptMaybeNullableFidoValue(
|
||||
value: unknown,
|
||||
enc: Uint8Array,
|
||||
mac: Uint8Array
|
||||
): Promise<string | null> {
|
||||
const normalized = String(value ?? '').trim();
|
||||
if (!normalized) return null;
|
||||
if (looksLikeCipherString(normalized)) return normalized;
|
||||
return encryptBw(new TextEncoder().encode(normalized), enc, mac);
|
||||
}
|
||||
|
||||
async function normalizeFido2Credentials(
|
||||
credentials: Array<Record<string, unknown>> | null | undefined,
|
||||
enc: Uint8Array,
|
||||
mac: Uint8Array
|
||||
): Promise<Array<Record<string, unknown>> | null> {
|
||||
if (!Array.isArray(credentials) || credentials.length === 0) return null;
|
||||
const out: Array<Record<string, unknown>> = [];
|
||||
for (const credential of credentials) {
|
||||
if (!credential || typeof credential !== 'object') continue;
|
||||
out.push({
|
||||
credentialId: await encryptMaybeFidoValue(credential.credentialId, enc, mac),
|
||||
keyType: await encryptMaybeFidoValue(credential.keyType, enc, mac, 'public-key'),
|
||||
keyAlgorithm: await encryptMaybeFidoValue(credential.keyAlgorithm, enc, mac, 'ECDSA'),
|
||||
keyCurve: await encryptMaybeFidoValue(credential.keyCurve, enc, mac, 'P-256'),
|
||||
keyValue: await encryptMaybeFidoValue(credential.keyValue, enc, mac),
|
||||
rpId: await encryptMaybeFidoValue(credential.rpId, enc, mac),
|
||||
rpName: await encryptMaybeNullableFidoValue(credential.rpName, enc, mac),
|
||||
userHandle: await encryptMaybeNullableFidoValue(credential.userHandle, enc, mac),
|
||||
userName: await encryptMaybeNullableFidoValue(credential.userName, enc, mac),
|
||||
userDisplayName: await encryptMaybeNullableFidoValue(credential.userDisplayName, enc, mac),
|
||||
counter: await encryptMaybeFidoValue(credential.counter, enc, mac, '0'),
|
||||
discoverable: await encryptMaybeFidoValue(credential.discoverable, enc, mac, 'false'),
|
||||
creationDate: toIsoDateOrNow(credential.creationDate),
|
||||
});
|
||||
}
|
||||
return out.length ? out : null;
|
||||
}
|
||||
|
||||
async function getCipherKeys(
|
||||
cipher: Cipher | null,
|
||||
userEnc: Uint8Array,
|
||||
@@ -490,15 +440,10 @@ async function buildCipherPayload(
|
||||
}
|
||||
|
||||
if (type === 1) {
|
||||
const existingFido2 =
|
||||
cipher?.login && Array.isArray((cipher.login as any).fido2Credentials)
|
||||
? (cipher.login as any).fido2Credentials
|
||||
: draft.loginFido2Credentials;
|
||||
payload.login = {
|
||||
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
|
||||
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
|
||||
totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac),
|
||||
fido2Credentials: await normalizeFido2Credentials(existingFido2, keys.enc, keys.mac),
|
||||
uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac),
|
||||
};
|
||||
} else if (type === 3) {
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
getProfile,
|
||||
loadSession,
|
||||
loginWithPassword,
|
||||
loginWithPasskey,
|
||||
refreshAccessToken,
|
||||
recoverTwoFactor,
|
||||
registerAccount,
|
||||
@@ -47,11 +46,6 @@ export type PasswordLoginResult =
|
||||
| { kind: 'totp'; pendingTotp: PendingTotp }
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
export type PasskeyLoginResult =
|
||||
| { kind: 'success'; login: CompletedLogin }
|
||||
| { kind: 'totp' }
|
||||
| { kind: 'error'; message: string };
|
||||
|
||||
export interface RecoverTwoFactorResult {
|
||||
login: CompletedLogin | null;
|
||||
newRecoveryCode: string | null;
|
||||
@@ -366,29 +360,3 @@ export async function performUnlock(
|
||||
return { ...refreshedSession, ...keys };
|
||||
}
|
||||
|
||||
export async function performPasskeyLogin(email: string, totpCode?: string): Promise<PasskeyLoginResult> {
|
||||
const token = await loginWithPasskey(email, totpCode);
|
||||
if ('access_token' in token && token.access_token) {
|
||||
const normalizedEmail = String(email || '').trim().toLowerCase();
|
||||
const baseSession: SessionState = {
|
||||
accessToken: token.access_token,
|
||||
refreshToken: token.refresh_token,
|
||||
email: normalizedEmail,
|
||||
symEncKey: token.VaultKeys?.symEncKey,
|
||||
symMacKey: token.VaultKeys?.symMacKey,
|
||||
};
|
||||
const tempFetch = createAuthedFetch(() => baseSession, () => {});
|
||||
const profile = buildTransientProfile(token, normalizedEmail);
|
||||
return {
|
||||
kind: 'success',
|
||||
login: {
|
||||
session: baseSession,
|
||||
profile,
|
||||
profilePromise: getProfile(tempFetch),
|
||||
},
|
||||
};
|
||||
}
|
||||
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||
if (tokenError.TwoFactorProviders) return { kind: 'totp' };
|
||||
return { kind: 'error', message: tokenError.error_description || tokenError.error || 'Passkey login failed' };
|
||||
}
|
||||
|
||||
@@ -99,7 +99,6 @@ export function buildEmptyImportDraft(type: number): VaultDraft {
|
||||
loginPassword: '',
|
||||
loginTotp: '',
|
||||
loginUris: [{ uri: '', match: null }],
|
||||
loginFido2Credentials: [],
|
||||
cardholderName: '',
|
||||
cardNumber: '',
|
||||
cardBrand: '',
|
||||
@@ -161,11 +160,6 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
|
||||
draft.loginUsername = asText(login.username);
|
||||
draft.loginPassword = asText(login.password);
|
||||
draft.loginTotp = asText(login.totp);
|
||||
draft.loginFido2Credentials = Array.isArray(login.fido2Credentials)
|
||||
? login.fido2Credentials
|
||||
.filter((credential): credential is Record<string, unknown> => !!credential && typeof credential === 'object')
|
||||
.map((credential) => ({ ...credential }))
|
||||
: [];
|
||||
const urisRaw = Array.isArray(login.uris) ? login.uris : [];
|
||||
const uris = urisRaw
|
||||
.map((u) => {
|
||||
|
||||
@@ -198,7 +198,6 @@ function mapCipherEncrypted(cipher: Cipher): Record<string, unknown> {
|
||||
match: (uri as { match?: unknown })?.match ?? null,
|
||||
}))
|
||||
: [],
|
||||
fido2Credentials: Array.isArray(login.fido2Credentials) ? cloneValue(login.fido2Credentials) : [],
|
||||
}
|
||||
: null;
|
||||
|
||||
@@ -292,9 +291,6 @@ async function mapCipherPlain(cipher: Cipher, userEnc: Uint8Array, userMac: Uint
|
||||
}))
|
||||
)
|
||||
: [],
|
||||
fido2Credentials: Array.isArray(cipher.login.fido2Credentials)
|
||||
? await Promise.all(cipher.login.fido2Credentials.map((credential) => deepDecryptUnknown(credential, keyParts.enc, keyParts.mac)))
|
||||
: [],
|
||||
};
|
||||
} else {
|
||||
out.login = null;
|
||||
|
||||
@@ -671,8 +671,6 @@ const messages: Record<Locale, Record<string, string>> = {
|
||||
txt_total_items_count: "{count} items",
|
||||
txt_totp_secret: "TOTP Secret",
|
||||
txt_totp_verify_failed: "TOTP verify failed",
|
||||
txt_passkey: "Passkey",
|
||||
txt_passkey_created_at_value: "Created at {value}",
|
||||
txt_attachments: "Attachments",
|
||||
txt_upload_attachments: "Upload attachments",
|
||||
txt_new_attachments: "New attachments",
|
||||
@@ -1431,8 +1429,6 @@ zhCNOverrides.txt_lock = '锁定';
|
||||
zhCNOverrides.txt_menu = '菜单';
|
||||
zhCNOverrides.txt_settings = '设置';
|
||||
zhCNOverrides.txt_back = '返回';
|
||||
zhCNOverrides.txt_passkey = 'Passkey';
|
||||
zhCNOverrides.txt_passkey_created_at_value = '创建于 {value}';
|
||||
zhCNOverrides.txt_attachments = '附件';
|
||||
zhCNOverrides.txt_upload_attachments = '上传附件';
|
||||
zhCNOverrides.txt_new_attachments = '待上传附件';
|
||||
|
||||
@@ -223,7 +223,7 @@ export function makeLoginCipher(): Record<string, unknown> {
|
||||
favorite: false,
|
||||
reprompt: 0,
|
||||
key: null,
|
||||
login: { username: null, password: null, totp: null, fido2Credentials: null, uris: null },
|
||||
login: { username: null, password: null, totp: null, uris: null },
|
||||
card: null,
|
||||
identity: null,
|
||||
secureNote: null,
|
||||
|
||||
@@ -31,7 +31,6 @@ export interface BitwardenCipherInput {
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
totp?: string | null;
|
||||
fido2Credentials?: Array<Record<string, unknown>> | null;
|
||||
} | null;
|
||||
card?: Record<string, unknown> | null;
|
||||
identity?: Record<string, unknown> | null;
|
||||
@@ -90,7 +89,6 @@ export function normalizeBitwardenImport(raw: unknown): CiphersImportPayload {
|
||||
username: item.login.username ?? null,
|
||||
password: item.login.password ?? null,
|
||||
totp: item.login.totp ?? null,
|
||||
fido2Credentials: Array.isArray(item.login.fido2Credentials) ? item.login.fido2Credentials : null,
|
||||
uris: Array.isArray(item.login.uris)
|
||||
? item.login.uris.map((u) => ({ uri: u?.uri ?? null, match: u?.match ?? null }))
|
||||
: null,
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
function base64UrlToBytes(input: string): Uint8Array {
|
||||
const normalized = String(input || '').replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = normalized + '='.repeat((4 - (normalized.length % 4 || 4)) % 4);
|
||||
const binary = atob(padded);
|
||||
const out = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i += 1) out[i] = binary.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function bytesToBase64Url(bytes: ArrayBuffer | Uint8Array): string {
|
||||
const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
|
||||
let binary = '';
|
||||
for (const b of view) binary += String.fromCharCode(b);
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
export function passkeySupported(): boolean {
|
||||
return typeof window !== 'undefined' && !!window.PublicKeyCredential;
|
||||
}
|
||||
|
||||
export async function createPasskeyCredential(publicKey: Record<string, any>): Promise<any> {
|
||||
const options: PublicKeyCredentialCreationOptions = {
|
||||
...(publicKey as PublicKeyCredentialCreationOptions),
|
||||
challenge: base64UrlToBytes(publicKey.challenge),
|
||||
user: {
|
||||
...publicKey.user,
|
||||
id: base64UrlToBytes(publicKey.user.id),
|
||||
},
|
||||
excludeCredentials: Array.isArray(publicKey.excludeCredentials)
|
||||
? publicKey.excludeCredentials.map((item: any) => ({ ...item, id: base64UrlToBytes(item.id) }))
|
||||
: [],
|
||||
};
|
||||
|
||||
const credential = (await navigator.credentials.create({ publicKey: options })) as PublicKeyCredential | null;
|
||||
if (!credential) throw new Error('Passkey creation was cancelled');
|
||||
const response = credential.response as AuthenticatorAttestationResponse;
|
||||
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: bytesToBase64Url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: bytesToBase64Url(response.clientDataJSON),
|
||||
attestationObject: bytesToBase64Url(response.attestationObject),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function requestPasskeyAssertion(publicKey: Record<string, any>): Promise<any> {
|
||||
const options: PublicKeyCredentialRequestOptions = {
|
||||
...(publicKey as PublicKeyCredentialRequestOptions),
|
||||
challenge: base64UrlToBytes(publicKey.challenge),
|
||||
allowCredentials: Array.isArray(publicKey.allowCredentials)
|
||||
? publicKey.allowCredentials.map((item: any) => ({ ...item, id: base64UrlToBytes(item.id) }))
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const credential = (await navigator.credentials.get({ publicKey: options })) as PublicKeyCredential | null;
|
||||
if (!credential) throw new Error('Passkey login was cancelled');
|
||||
const response = credential.response as AuthenticatorAssertionResponse;
|
||||
return {
|
||||
id: credential.id,
|
||||
rawId: bytesToBase64Url(credential.rawId),
|
||||
type: credential.type,
|
||||
response: {
|
||||
clientDataJSON: bytesToBase64Url(response.clientDataJSON),
|
||||
authenticatorData: bytesToBase64Url(response.authenticatorData),
|
||||
signature: bytesToBase64Url(response.signature),
|
||||
userHandle: response.userHandle ? bytesToBase64Url(response.userHandle) : null,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -48,17 +48,11 @@ export interface CipherAttachment {
|
||||
object?: string;
|
||||
}
|
||||
|
||||
export interface CipherLoginPasskey {
|
||||
creationDate?: string | null;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CipherLogin {
|
||||
username?: string | null;
|
||||
password?: string | null;
|
||||
totp?: string | null;
|
||||
uris?: CipherLoginUri[] | null;
|
||||
fido2Credentials?: CipherLoginPasskey[] | null;
|
||||
decUsername?: string;
|
||||
decPassword?: string;
|
||||
decTotp?: string;
|
||||
@@ -228,7 +222,6 @@ export interface VaultDraft {
|
||||
loginPassword: string;
|
||||
loginTotp: string;
|
||||
loginUris: VaultDraftLoginUri[];
|
||||
loginFido2Credentials: Array<Record<string, unknown>>;
|
||||
cardholderName: string;
|
||||
cardNumber: string;
|
||||
cardBrand: string;
|
||||
|
||||
Reference in New Issue
Block a user