diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 4b03ded..8c8be1a 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -4142,7 +4142,9 @@ paths: description: > Removes a passkey scoped to the authenticated principal, so a caller can only unbind their OWN credential. An unknown or cross-user id is a 404; it never - silently no-ops as success. + silently no-ops as success. The account's only passkey cannot be removed while + its email is unverified (409 last_passkey): it is then the account's only + durable way in. x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] @@ -4162,6 +4164,11 @@ paths: content: application/json: schema: { $ref: '#/components/schemas/Error' } + '409': + description: last_passkey — this is the only passkey and the email is unverified. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } /api/v1/account/migrate: get: diff --git a/internal/api/api_test.go b/internal/api/api_test.go index ac27b72..cb14bf3 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -539,13 +539,30 @@ func (f *fakeRepo) PasskeyCredentialsForUser(_ context.Context, userID string) ( } // DeletePasskeyCredential mirrors PGRepo: scoped to userID so a caller can only unbind -// their OWN credential; no matching (user, id) row → ErrNotFound. +// their OWN credential; no matching (user, id) row → ErrNotFound; the last passkey of +// a user whose email is unverified (or who has no user row here) → ErrLastPasskey. func (f *fakeRepo) DeletePasskeyCredential(_ context.Context, userID, id string) error { - if c, ok := f.passkeyCreds[id]; ok && c.UserID == userID { - delete(f.passkeyCreds, id) - return nil + c, ok := f.passkeyCreds[id] + if !ok || c.UserID != userID { + return ErrNotFound } - return ErrNotFound + total := 0 + for _, other := range f.passkeyCreds { + if other.UserID == userID { + total++ + } + } + verified := false + for _, u := range f.staff { + if u.ID == userID { + verified = u.EmailVerified + } + } + if total == 1 && !verified { + return ErrLastPasskey + } + delete(f.passkeyCreds, id) + return nil } // DeleteAllPasskeyCredentialsForUser mirrors PGRepo: unbind every passkey the user holds, diff --git a/internal/api/errors.go b/internal/api/errors.go index 9bc3f56..7db33d1 100644 --- a/internal/api/errors.go +++ b/internal/api/errors.go @@ -57,6 +57,12 @@ var ( // finish endpoint exists; the ceremony state is gone (never begun, already // consumed, or expired) — so handlers map it to 400, not 404. ErrPasskeyChallengeInvalid = errors.New("passkey challenge invalid or expired") + // ErrLastPasskey means a passkey delete would remove the account's only one while + // its email is unverified. That passkey is then the account's only durable way + // in (setupRequired: no verified email and no passkey puts it back behind the + // setup gate, and a staff account has no other self-service door at all), so the + // delete is refused; handlers map it to 409 last_passkey. + ErrLastPasskey = errors.New("cannot remove the only passkey of an account without a verified email") // ErrPlayerBindForbidden means a public Bind-Code redemption resolved to a STAFF // account (admin or owner), which the player-console bootstrap refuses // (console-tier access model). Staff authenticate at op.console behind Zero Trust, diff --git a/internal/api/handlers_passkey.go b/internal/api/handlers_passkey.go index f3657b4..806d5e3 100644 --- a/internal/api/handlers_passkey.go +++ b/internal/api/handlers_passkey.go @@ -400,6 +400,8 @@ func (a *API) handlePasskeyList(w http.ResponseWriter, r *http.Request) { // handlePasskeyDelete unbinds one of the caller's passkeys (spec §14, external app // face). The delete is scoped to the principal, so a caller can only remove their OWN // credential; an unknown or cross-user id → 404 (it never silently no-ops as success). +// The last passkey of an account without a verified email → 409 last_passkey: it is +// that account's only durable way in (ErrLastPasskey). func (a *API) handlePasskeyDelete(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) id := r.PathValue("id") @@ -412,6 +414,11 @@ func (a *API) handlePasskeyDelete(w http.ResponseWriter, r *http.Request) { writeError(w, r, newError(http.StatusNotFound, "not_found", "no such passkey")) return } + if errors.Is(err, ErrLastPasskey) { + writeError(w, r, newError(http.StatusConflict, "last_passkey", + "this is your only passkey and your email is not verified; add another passkey or verify an email first")) + return + } writeError(w, r, err) return } diff --git a/internal/api/handlers_passkey_test.go b/internal/api/handlers_passkey_test.go index efb9ae7..eaf3a00 100644 --- a/internal/api/handlers_passkey_test.go +++ b/internal/api/handlers_passkey_test.go @@ -46,6 +46,8 @@ func plantPasskeyChallenge(repo *fakeRepo, id string, expiresAt time.Time, sessi func TestPasskeyRegisterVertical(t *testing.T) { user := &Principal{UserID: "u1", Email: "u1@example.net", Role: "user"} repo := newFakeRepo() + // A verified email keeps a door open, so step 5 may remove the only passkey. + repo.staff["u1"] = &StaffUser{ID: "u1", Username: "u1", Email: "u1@example.net", Role: "user", EmailVerified: true} v := &fakePasskeyVerifier{ options: json.RawMessage(`{"publicKey":{"challenge":"Y2hhbGxlbmdl"}}`), credential: VerifiedCredential{ @@ -294,6 +296,65 @@ func TestPasskeyDeleteScoping(t *testing.T) { } } +// TestPasskeyDeleteLastGuard pins the last-passkey guard: without a verified email +// the only passkey is the account's way in, so its delete is a 409 that leaves it +// bound; a second passkey or a verified email lets the delete through. +func TestPasskeyDeleteLastGuard(t *testing.T) { + cred := func(id string) PasskeyCredential { + return PasskeyCredential{ID: id, UserID: "u1", CredentialID: "c-" + id, CreatedAt: frozenNow} + } + for _, tc := range []struct { + name string + verified bool + creds []string + want int + }{ + {"only passkey, email unverified", false, []string{"a"}, http.StatusConflict}, + {"only passkey, email verified", true, []string{"a"}, http.StatusNoContent}, + {"two passkeys, email unverified", false, []string{"a", "b"}, http.StatusNoContent}, + } { + t.Run(tc.name, func(t *testing.T) { + for _, role := range []string{"user", "admin"} { + user := &Principal{UserID: "u1", Email: "u1@example.net", Role: role} + repo := newFakeRepo() + repo.staff["u1"] = &StaffUser{ID: "u1", Username: "u1", Email: "u1@example.net", Role: role, EmailVerified: tc.verified} + for _, id := range tc.creds { + repo.passkeyCreds[id] = cred(id) + } + eh := newPasskeyAPI(repo, &fakePasskeyVerifier{}, user) + w := do(eh, "DELETE", "/api/v1/account/passkey/credentials/a", "", nil) + if w.Code != tc.want { + t.Fatalf("%s: code = %d body %s, want %d", role, w.Code, w.Body.String(), tc.want) + } + _, kept := repo.passkeyCreds["a"] + if tc.want == http.StatusConflict { + if got := decodeErr(t, w); got != "last_passkey" { + t.Errorf("%s: error code = %q, want last_passkey", role, got) + } + if !kept { + t.Errorf("%s: a refused delete must leave the passkey bound", role) + } + } else if kept { + t.Errorf("%s: an allowed delete must remove the passkey", role) + } + } + }) + } + + // Removing one of two leaves the other as the last one, which is then guarded. + user := &Principal{UserID: "u1", Email: "u1@example.net", Role: "user"} + repo := newFakeRepo() + repo.staff["u1"] = &StaffUser{ID: "u1", Username: "u1", Email: "u1@example.net", Role: "user"} + repo.passkeyCreds["a"], repo.passkeyCreds["b"] = cred("a"), cred("b") + eh := newPasskeyAPI(repo, &fakePasskeyVerifier{}, user) + if w := do(eh, "DELETE", "/api/v1/account/passkey/credentials/a", "", nil); w.Code != http.StatusNoContent { + t.Fatalf("first delete: code = %d, want 204", w.Code) + } + if w := do(eh, "DELETE", "/api/v1/account/passkey/credentials/b", "", nil); w.Code != http.StatusConflict { + t.Fatalf("second delete: code = %d, want 409 (it is now the last one)", w.Code) + } +} + // TestPasskeyDeleteUnknown pins the unknown-id path: deleting an id that does not exist // is a 404, never a silent 204. func TestPasskeyDeleteUnknown(t *testing.T) { diff --git a/internal/api/pgrepo.go b/internal/api/pgrepo.go index b717e73..5f7a68f 100644 --- a/internal/api/pgrepo.go +++ b/internal/api/pgrepo.go @@ -1412,19 +1412,38 @@ func (p *PGRepo) AdvanceCredentialSignCount(ctx context.Context, credentialID st // only unbind their OWN credential. No matching (user, id) row → ErrNotFound via a zero // RowsAffected, so a stale or cross-user id cannot silently no-op as success. func (p *PGRepo) DeletePasskeyCredential(ctx context.Context, userID, id string) error { - res, err := p.db.ExecContext(ctx, - `DELETE FROM webauthn_credentials WHERE id = $1 AND user_id = $2`, id, userID) + tx, err := p.db.BeginTx(ctx, nil) if err != nil { return err } - n, err := res.RowsAffected() - if err != nil { + defer func() { _ = tx.Rollback() }() + // Lock the user row first: every delete for this user queues here, so the count + // below cannot go stale between the check and the DELETE. + var verified bool + switch err := tx.QueryRowContext(ctx, + `SELECT email_verified FROM users WHERE id = $1 FOR UPDATE`, userID).Scan(&verified); { + case errors.Is(err, sql.ErrNoRows): + return ErrNotFound + case err != nil: return err } - if n == 0 { + var mine, total int + if err := tx.QueryRowContext(ctx, + `SELECT count(*) FILTER (WHERE id = $2), count(*) FROM webauthn_credentials WHERE user_id = $1`, + userID, id).Scan(&mine, &total); err != nil { + return err + } + if mine == 0 { return ErrNotFound } - return nil + if total == 1 && !verified { + return ErrLastPasskey + } + if _, err := tx.ExecContext(ctx, + `DELETE FROM webauthn_credentials WHERE id = $1 AND user_id = $2`, id, userID); err != nil { + return err + } + return tx.Commit() } // DeleteAllPasskeyCredentialsForUser unbinds every passkey a user holds. Unlike the diff --git a/internal/api/repo.go b/internal/api/repo.go index 4eb19d3..c5e6b5c 100644 --- a/internal/api/repo.go +++ b/internal/api/repo.go @@ -459,7 +459,10 @@ type Repo interface { PasskeyCredentialsForUser(ctx context.Context, userID string) ([]PasskeyCredential, error) // DeletePasskeyCredential removes the passkey row id, scoped to userID so a caller // can only unbind their OWN credential. No matching (user, id) row → ErrNotFound, - // so a stale or cross-user id cannot silently no-op as success. + // so a stale or cross-user id cannot silently no-op as success. When the row is + // the user's last passkey and their email is unverified it returns ErrLastPasskey + // and deletes nothing; the check and the delete hold the user row locked, so two + // concurrent deletes of a user's last two passkeys cannot both pass. DeletePasskeyCredential(ctx context.Context, userID, id string) error // DeleteAllPasskeyCredentialsForUser unbinds every passkey a user holds — the // remediation that stops a passkey planted via a transiently-hijacked session from diff --git a/internal/pgint/pgint_test.go b/internal/pgint/pgint_test.go index ca211e1..316e714 100644 --- a/internal/pgint/pgint_test.go +++ b/internal/pgint/pgint_test.go @@ -722,6 +722,90 @@ func TestDeadAccountsAreLockedOutInPG(t *testing.T) { } } +// DeletePasskeyCredential keeps the last passkey of an account whose email is +// unverified: removing it would leave no durable way in. The guard reads the count +// under the user-row lock, so two concurrent deletes of an account's last two +// passkeys resolve to exactly one delete and one ErrLastPasskey, never zero left. +func TestLastPasskeyGuardInPG(t *testing.T) { + ctx := context.Background() + seed := func(t *testing.T, userID string) string { + t.Helper() + id := "cred-" + suffix(t) + if _, err := db.ExecContext(ctx, + `INSERT INTO webauthn_credentials (id, user_id, credential_id, public_key) VALUES ($1,$2,$3,'pk')`, + id, userID, "cid-"+suffix(t)); err != nil { + t.Fatalf("seed passkey: %v", err) + } + return id + } + count := func(t *testing.T, userID string) int { + t.Helper() + var n int + if err := db.QueryRowContext(ctx, + `SELECT count(*) FROM webauthn_credentials WHERE user_id = $1`, userID).Scan(&n); err != nil { + t.Fatalf("count: %v", err) + } + return n + } + + for i := 0; i < 5; i++ { + u := newUser(t, "user", "lastpk") + a, b := seed(t, u.ID), seed(t, u.ID) + var wg sync.WaitGroup + errs := make([]error, 2) + for j, id := range []string{a, b} { + wg.Add(1) + go func(j int, id string) { + defer wg.Done() + errs[j] = repo.DeletePasskeyCredential(ctx, u.ID, id) + }(j, id) + } + wg.Wait() + ok, refused := 0, 0 + for _, err := range errs { + switch { + case err == nil: + ok++ + case errors.Is(err, api.ErrLastPasskey): + refused++ + default: + t.Fatalf("concurrent delete: %v", err) + } + } + if ok != 1 || refused != 1 || count(t, u.ID) != 1 { + t.Fatalf("round %d: %d deleted, %d refused, %d left; want 1, 1, 1", i, ok, refused, count(t, u.ID)) + } + } + + u := newUser(t, "admin", "lastpk") + last := seed(t, u.ID) + if err := repo.DeletePasskeyCredential(ctx, u.ID, last); !errors.Is(err, api.ErrLastPasskey) { + t.Fatalf("last passkey, unverified: %v, want ErrLastPasskey", err) + } + if err := repo.DeletePasskeyCredential(ctx, u.ID, "cred-none-"+suffix(t)); !errors.Is(err, api.ErrNotFound) { + t.Fatalf("unknown id: %v, want ErrNotFound", err) + } + other := newUser(t, "user", "lastpk") + if err := repo.DeletePasskeyCredential(ctx, other.ID, last); !errors.Is(err, api.ErrNotFound) { + t.Fatalf("another user's passkey: %v, want ErrNotFound", err) + } + + // A verified email is another door, so the last passkey may go. + now := mustNow() + if err := repo.CreateEmailOTP(ctx, "lp-"+suffix(t), u.ID, "lastpk-"+suffix(t)+"@example.net", "h", "onboard_email", now.Add(5*time.Minute)); err != nil { + t.Fatalf("CreateEmailOTP: %v", err) + } + if _, err := repo.VerifyEmailOTP(ctx, u.ID, "onboard_email", "h", now); err != nil { + t.Fatalf("VerifyEmailOTP: %v", err) + } + if err := repo.DeletePasskeyCredential(ctx, u.ID, last); err != nil { + t.Fatalf("last passkey, verified: %v", err) + } + if n := count(t, u.ID); n != 0 { + t.Fatalf("%d passkeys left after the allowed delete, want 0", n) + } +} + // VerifyLinkCode's takeover rule: a fresh in-game code (proof the caller holds the // UUID) lets a live account take over a link whose account was SOFT-DELETED — the // migrated-source case, whose retire keeps the link but kills the account — while a diff --git a/panel/dev/mockApi.ts b/panel/dev/mockApi.ts index 461eb9c..dd32568 100644 --- a/panel/dev/mockApi.ts +++ b/panel/dev/mockApi.ts @@ -899,12 +899,19 @@ async function handleSession(ctx: SessionContext): Promise { default: if (ctx.method === "DELETE" && ctx.parts[2] === "account" && ctx.parts[3] === "passkey" && ctx.parts[4] === "credentials" && ctx.parts[5]) { const id = ctx.parts[5]; - if (ctx.state.passkeys[ctx.account.id]) { - const idx = ctx.state.passkeys[ctx.account.id].findIndex((k) => k.id === id); - if (idx >= 0) { - ctx.state.passkeys[ctx.account.id].splice(idx, 1); - } + const list = ctx.state.passkeys[ctx.account.id] ?? []; + const idx = list.findIndex((k) => k.id === id); + if (idx < 0) { + sendError(ctx.res, 404, "not_found", "passkey not found"); + return true; } + // Same rule as the real API: the only passkey of an account without a + // verified email stays, or the account would have no way to sign in. + if (list.length === 1 && !ctx.account.emailVerified) { + sendError(ctx.res, 409, "last_passkey", "this is your only passkey and your email is not verified"); + return true; + } + list.splice(idx, 1); ctx.res.statusCode = 204; ctx.res.end(); return true; diff --git a/panel/src/i18n/resources/en-US/account.json b/panel/src/i18n/resources/en-US/account.json index 4bfe177..6eaa20b 100644 --- a/panel/src/i18n/resources/en-US/account.json +++ b/panel/src/i18n/resources/en-US/account.json @@ -44,6 +44,11 @@ "created_at": "Registered at: ", "last_used": "Last used: ", "never": "Never", + "passkey_delete_aria": "Delete passkey “{{name}}”", + "passkey_delete_title": "Delete this passkey?", + "passkey_delete_desc": "“{{name}}” (registered {{created}}) will no longer sign you in. You can register it again later.", + "passkey_delete_confirm": "Delete", + "passkey_last_hint": "This is your only passkey and your email is not verified, so deleting it would lock you out. Verify an email or add another passkey first.", "migration": "Account migration", "migration_desc": "Move everything a retired account owns onto this one. Migration starts in-game and finishes here.", "account_id": "Account ID", diff --git a/panel/src/i18n/resources/en-US/errors.json b/panel/src/i18n/resources/en-US/errors.json index 2fb0e15..ac12111 100644 --- a/panel/src/i18n/resources/en-US/errors.json +++ b/panel/src/i18n/resources/en-US/errors.json @@ -37,6 +37,7 @@ "invalid_attestation": "Could not verify this Passkey, please try again.", "passkey_already_bound": "This Passkey is already bound to another account.", "passkey_unavailable": "Passkey subsystem is not available right now.", + "last_passkey": "This is your only passkey and your email is not verified, so it can't be removed. Verify an email or add another passkey first.", "passkey_not_allowed": "The operation was cancelled by the user or browser.", "passkey_aborted": "Passkey registration was aborted.", "bad_name": "That server name is invalid — use 3–32 lowercase letters, digits or dashes, and avoid reserved names.", diff --git a/panel/src/i18n/resources/zh-CN/account.json b/panel/src/i18n/resources/zh-CN/account.json index 29e051a..7e57bea 100644 --- a/panel/src/i18n/resources/zh-CN/account.json +++ b/panel/src/i18n/resources/zh-CN/account.json @@ -44,6 +44,11 @@ "created_at": "注册时间:", "last_used": "上次使用:", "never": "从未", + "passkey_delete_aria": "删除 Passkey「{{name}}」", + "passkey_delete_title": "删除这个 Passkey?", + "passkey_delete_desc": "「{{name}}」(注册于 {{created}})删除后无法再用它登录,需要时可以重新注册。", + "passkey_delete_confirm": "删除", + "passkey_last_hint": "这是你唯一的 Passkey,邮箱也还没验证,删掉就没法登录了。先验证邮箱或再注册一个 Passkey,才能删除它。", "migration": "账户迁移", "migration_desc": "将被弃用账户名下的所有服务器转移到本账户。迁移在游戏内发起,在此完成。", "account_id": "账户 ID", diff --git a/panel/src/i18n/resources/zh-CN/errors.json b/panel/src/i18n/resources/zh-CN/errors.json index 6ccd812..bad7e32 100644 --- a/panel/src/i18n/resources/zh-CN/errors.json +++ b/panel/src/i18n/resources/zh-CN/errors.json @@ -37,6 +37,7 @@ "invalid_attestation": "无法验证此 Passkey,请重试。", "passkey_already_bound": "此 Passkey 已被其他账户绑定。", "passkey_unavailable": "Passkey 功能当前不可用。", + "last_passkey": "这是你唯一的 Passkey,邮箱也还没验证,不能删除。先验证邮箱或再注册一个 Passkey。", "passkey_not_allowed": "操作已被用户或浏览器取消。", "passkey_aborted": "Passkey 注册已被取消。", "bad_name": "服务器名称不合法:需为 3–32 位小写字母、数字或连字符,且不能使用保留名。", diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index 55631c0..b18ea6e 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -13,6 +13,7 @@ import type { LinkResult, LinkStatus, BindResult, + PasskeyCredential, PatchUserRequest, PlayersResult, QuotaInput, @@ -560,7 +561,7 @@ export const api = rejectingSync({ request("POST", "/account/passkey/register/finish", { name, attestation }), passkeyList: () => - request<{ credentials: any[] }>("GET", "/account/passkey/credentials"), + request<{ credentials: PasskeyCredential[] }>("GET", "/account/passkey/credentials"), passkeyDelete: (id: string) => request("DELETE", urlPath`/account/passkey/credentials/${id}`), @@ -800,6 +801,8 @@ export function humanizeError(e: unknown): string { return t("passkey_already_bound"); case "passkey_unavailable": return t("passkey_unavailable"); + case "last_passkey": + return t("last_passkey"); case "quota_exceeded": return t("quota_exceeded"); case "already_claimed": diff --git a/panel/src/lib/types.ts b/panel/src/lib/types.ts index 2aacf39..85447e5 100644 --- a/panel/src/lib/types.ts +++ b/panel/src/lib/types.ts @@ -392,6 +392,15 @@ export interface QuotaInput { max_storage_gb?: number | null; } +// One registered passkey as GET /account/passkey/credentials lists it. +export interface PasskeyCredential { + id: string; + name: string; + aaguid?: string; + created_at: string; + last_used_at?: string | null; +} + export interface SessionView { token_hash: string; created_at: string; diff --git a/panel/src/pages/Account.tsx b/panel/src/pages/Account.tsx index ccda393..62ad8cc 100644 --- a/panel/src/pages/Account.tsx +++ b/panel/src/pages/Account.tsx @@ -6,8 +6,12 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Loading, ErrorState } from "@/components/States"; +import { ConfirmFooter } from "@/components/ConfirmFooter"; +import { MessageLine } from "@/components/MessageLine"; import { PageHeader } from "@/components/PageHeader"; import { api, humanizeError } from "@/lib/api"; +import { formatAbsolute } from "@/lib/format"; +import type { PasskeyCredential } from "@/lib/types"; import { useAsync } from "@/lib/hooks"; import { useTier } from "@/lib/tier"; import { base64urlToBytes, bytesToBase64url } from "@/lib/utils"; @@ -30,7 +34,7 @@ import { export function Account() { const status = useAsync(() => api.linkStatus(), []); const { identity, refresh } = useTier(); - const { t } = useTranslation("account"); + const { t, i18n } = useTranslation("account"); // Email verification state const [emailInput, setEmailInput] = useState(""); @@ -93,7 +97,15 @@ export function Account() { const [registeringPasskey, setRegisteringPasskey] = useState(false); const [passkeyError, setPasskeyError] = useState(null); const [registerDialogOpen, setRegisterDialogOpen] = useState(false); - const [deletingMap, setDeletingMap] = useState>({}); + // Deleting a passkey goes through a confirm dialog that names it. The API + // refuses to remove the only passkey of an account whose email is unverified + // (it would be left with no way back in); the button mirrors that rule so the + // refusal is explained up front instead of after a round trip. + const [pendingDelete, setPendingDelete] = useState(null); + const [deletingPasskey, setDeletingPasskey] = useState(false); + const [deleteError, setDeleteError] = useState(null); + const credentials = passkeys.data?.credentials ?? []; + const keepLastPasskey = credentials.length === 1 && !identity?.email_verified; const abortControllerRef = useRef(null); @@ -165,16 +177,30 @@ export function Account() { } } - async function handleDeletePasskey(id: string) { - if (deletingMap[id]) return; - setDeletingMap((prev) => ({ ...prev, [id]: true })); + function askDeletePasskey(cred: PasskeyCredential) { + setDeleteError(null); + setPendingDelete(cred); + } + + async function confirmDeletePasskey() { + if (!pendingDelete || deletingPasskey) return; + setDeletingPasskey(true); + setDeleteError(null); try { - await api.passkeyDelete(id); + await api.passkeyDelete(pendingDelete.id); + setPendingDelete(null); await passkeys.reload(); } catch (err) { - alert(humanizeError(err)); + // Another device may have changed the list meanwhile: refresh it. A 404 + // means the passkey is already gone, which is what was asked for. + void passkeys.reload(); + if ((err as { code?: string }).code === "not_found") { + setPendingDelete(null); + } else { + setDeleteError(humanizeError(err)); + } } finally { - setDeletingMap((prev) => ({ ...prev, [id]: false })); + setDeletingPasskey(false); } } @@ -404,47 +430,82 @@ export function Account() { ) : passkeys.error ? ( - ) : !passkeys.data?.credentials || passkeys.data.credentials.length === 0 ? ( + ) : credentials.length === 0 ? (

{t("no_passkeys")}

) : ( -
- {passkeys.data.credentials.map((cred: any) => ( -
-
-

- - {cred.name} -

-
- - {t("created_at")} - {new Date(cred.created_at).toLocaleString()} - - - {t("last_used")} - {cred.last_used_at ? new Date(cred.last_used_at).toLocaleString() : t("never")} - + <> +
    + {credentials.map((cred) => ( +
  • +
    +

    + + {cred.name} +

    +
    + + {t("created_at")} + {formatAbsolute(cred.created_at, i18n.language)} + + + {t("last_used")} + {cred.last_used_at ? formatAbsolute(cred.last_used_at, i18n.language) : t("never")} + +
    -
- -
- ))} -
+ + + ))} + + {keepLastPasskey && ( +

{t("passkey_last_hint")}

+ )} + )} + { + if (!open && !deletingPasskey) setPendingDelete(null); + }} + > + + + {t("passkey_delete_title")} + + {pendingDelete && + t("passkey_delete_desc", { + name: pendingDelete.name, + created: formatAbsolute(pendingDelete.created_at, i18n.language), + })} + + + {deleteError && } + setPendingDelete(null)} + onConfirm={() => void confirmDeletePasskey()} + loading={deletingPasskey} + disabled={deletingPasskey || keepLastPasskey} + cancelLabel={t("common:cancel")} + confirmLabel={t("passkey_delete_confirm")} + /> + + 0} + hasPasskey={credentials.length > 0} />