diff --git a/src/components/settings/security-section/passkey-list-section.tsx b/src/components/settings/security-section/passkey-list-section.tsx index eda11e875..20232f55d 100644 --- a/src/components/settings/security-section/passkey-list-section.tsx +++ b/src/components/settings/security-section/passkey-list-section.tsx @@ -50,6 +50,17 @@ interface PasskeyInfo { lastUsedAt: string | null; } +type ReauthMethod = "password" | "passkey" | "totp" | "webauthn"; + +type ExistingFactorProof = + | { method: "password"; password: string } + | { method: "totp"; code: string } + | { + method: "passkey" | "webauthn"; + challengeId: string; + credential: unknown; + }; + /** * Primary-passkey management: list (name, device, backup, created, last used), * add (registration ceremony), rename, and delete. The passwordless-primary @@ -65,6 +76,10 @@ export function PasskeyListSection({ const [msg, setMsg] = useState(null); const [editingId, setEditingId] = useState(null); const [editName, setEditName] = useState(""); + const [reauthOpen, setReauthOpen] = useState(false); + const [reauthMethod, setReauthMethod] = + useState("password"); + const [reauthSecret, setReauthSecret] = useState(""); const { data: passkeys } = useQuery({ queryKey: queryKeys.passkeys(), @@ -73,13 +88,46 @@ export function PasskeyListSection({ }); const add = useMutation({ - mutationFn: async () => { - const { startRegistration } = await import("@simplewebauthn/browser"); + mutationFn: async ({ + method, + secret, + }: { + method: ReauthMethod; + secret: string; + }) => { + const webauthn = await import("@simplewebauthn/browser"); + let freshFactor: ExistingFactorProof; + + if (method === "password") { + freshFactor = { method, password: secret }; + } else if (method === "totp") { + freshFactor = { method, code: secret }; + } else { + const assertionChallenge = await apiPost<{ + options: Parameters< + typeof webauthn.startAuthentication + >[0]["optionsJSON"]; + challengeId: string; + }>("/api/auth/passkey/register-options", { method }); + const credential = await webauthn.startAuthentication({ + optionsJSON: assertionChallenge.options, + }); + freshFactor = { + method, + challengeId: assertionChallenge.challengeId, + credential, + }; + } + const { options, challengeId } = await apiPost<{ - options: Parameters[0]["optionsJSON"]; + options: Parameters< + typeof webauthn.startRegistration + >[0]["optionsJSON"]; challengeId: string; - }>("/api/auth/passkey/register-options"); - const credential = await startRegistration({ optionsJSON: options }); + }>("/api/auth/passkey/register-options", freshFactor); + const credential = await webauthn.startRegistration({ + optionsJSON: options, + }); await apiPost("/api/auth/passkey/register-verify", { challengeId, credential, @@ -87,6 +135,9 @@ export function PasskeyListSection({ }, onSuccess: () => { setMsg(null); + setReauthOpen(false); + setReauthSecret(""); + setReauthMethod("password"); queryClient.invalidateQueries({ queryKey: queryKeys.passkeys() }); }, onError: (err) => { @@ -283,24 +334,139 @@ export function PasskeyListSection({ )} -
- -
+ { + if (add.isPending) return; + setReauthOpen(open); + if (open) setMsg(null); + }} + > +
+ + + +
+ + + Confirm it's you + + Verify an existing sign-in method before adding a new passkey. + + - {msg && ( +
+ {( + [ + ["password", "Password"], + ["totp", "Authenticator code"], + ["passkey", "Passkey"], + ["webauthn", "Security key"], + ] satisfies Array<[ReauthMethod, string]> + ).map(([method, label]) => ( + + ))} +
+ + {reauthMethod === "password" && ( + setReauthSecret(event.target.value)} + placeholder="Current password" + aria-label="Current password" + autoFocus + /> + )} + {reauthMethod === "totp" && ( + + setReauthSecret(event.target.value.replace(/\D/g, "")) + } + placeholder="6-digit authenticator code" + aria-label="Authenticator code" + autoFocus + /> + )} + + {msg && ( +
+ + {msg} +
+ )} + + + + {t("common.cancel")} + + { + event.preventDefault(); + add.mutate({ + method: reauthMethod, + secret: reauthSecret, + }); + }} + > + {add.isPending && ( + + )} + Verify and add + + +
+
+ + {msg && !reauthOpen && (