Add passkey authentication support - #1
Conversation
There was a problem hiding this comment.
Pull request overview
Adds passkey (WebAuthn) authentication support to the existing Better Auth–based auth system, including database persistence and UI surfaces for managing credentials.
Changes:
- Add
@better-auth/passkeydependency and wire passkey plugins into server (betterAuth) and client (createAuthClient). - Introduce Drizzle schema + migration for a new
passkeytable and relations tousersTable. - Add Settings UI for listing/adding/renaming/deleting passkeys and enable passkey autofill sign-in on the login page.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| package.json | Adds passkey package dependency. |
| bun.lock | Locks passkey + transitive deps and bumps Better Auth core version. |
| app/server/lib/auth.ts | Enables the Better Auth passkey plugin with RP config derived from BASE_URL. |
| app/server/db/schema.ts | Adds passkey table schema. |
| app/server/db/relations.ts | Adds user↔passkey relations. |
| app/drizzle/20260428161759_productive_namor/snapshot.json | Captures updated schema snapshot including passkey table. |
| app/drizzle/20260428161759_productive_namor/migration.sql | Creates passkey table and indexes. |
| app/client/modules/settings/routes/settings.tsx | Adds Passkeys section to Settings page. |
| app/client/modules/settings/components/passkeys-section.tsx | New UI for passkey CRUD operations. |
| app/client/modules/auth/routes/login.tsx | Attempts conditional WebAuthn autofill sign-in on login. |
| app/client/lib/auth-client.ts | Registers passkey client plugin. |
| ); | ||
| --> statement-breakpoint | ||
| CREATE INDEX `passkey_userId_idx` ON `passkey` (`user_id`);--> statement-breakpoint | ||
| CREATE INDEX `passkey_credentialID_idx` ON `passkey` (`credential_id`); No newline at end of file |
There was a problem hiding this comment.
credential_id is indexed but not unique. Since credential IDs are expected to be unique in WebAuthn, add a UNIQUE constraint/index here to prevent duplicates and ambiguous credential lookups.
| CREATE INDEX `passkey_credentialID_idx` ON `passkey` (`credential_id`); | |
| CREATE UNIQUE INDEX `passkey_credentialID_idx` ON `passkey` (`credential_id`); |
| const { error } = await authClient.$fetch("/passkey/update-passkey", { | ||
| method: "POST", | ||
| body: { id: renameTarget.id, name }, | ||
| }); | ||
| setIsRenaming(false); | ||
| if (error) { | ||
| logger.error(error); | ||
| toast.error("Failed to rename passkey", { description: error.message }); | ||
| return; | ||
| } | ||
| toast.success("Passkey renamed"); | ||
| setRenameTarget(null); | ||
| setRenameValue(""); | ||
| await refetch(); |
There was a problem hiding this comment.
handleRename doesn’t guard the authClient.$fetch call with try/finally (or try/catch). If the request throws (network error, non-JSON response, etc.), isRenaming will remain true and the dialog will be stuck in a loading state. Wrap the request in try/finally and ensure setIsRenaming(false) runs even on exceptions (and optionally surface a toast).
| const { error } = await authClient.$fetch("/passkey/update-passkey", { | |
| method: "POST", | |
| body: { id: renameTarget.id, name }, | |
| }); | |
| setIsRenaming(false); | |
| if (error) { | |
| logger.error(error); | |
| toast.error("Failed to rename passkey", { description: error.message }); | |
| return; | |
| } | |
| toast.success("Passkey renamed"); | |
| setRenameTarget(null); | |
| setRenameValue(""); | |
| await refetch(); | |
| try { | |
| const { error } = await authClient.$fetch("/passkey/update-passkey", { | |
| method: "POST", | |
| body: { id: renameTarget.id, name }, | |
| }); | |
| if (error) { | |
| logger.error(error); | |
| toast.error("Failed to rename passkey", { description: error.message }); | |
| return; | |
| } | |
| toast.success("Passkey renamed"); | |
| setRenameTarget(null); | |
| setRenameValue(""); | |
| await refetch(); | |
| } catch (err) { | |
| logger.error(err); | |
| toast.error("Failed to rename passkey", { | |
| description: err instanceof Error ? err.message : "Unknown error", | |
| }); | |
| } finally { | |
| setIsRenaming(false); | |
| } |
| } | ||
| toast.success("Passkey deleted"); | ||
| setDeleteTarget(null); | ||
| await refetch(); |
There was a problem hiding this comment.
Manual refetch() here is doing explicit invalidation on the client. The repo relies on automatic invalidation after mutations (AGENTS.md:64-67). Prefer using the standard mutation pattern so the list refreshes automatically without calling refetch().
| <Button | ||
| variant="outline" | ||
| size="sm" | ||
| onClick={() => { | ||
| setRenameTarget(p); | ||
| setRenameValue(p.name ?? ""); | ||
| }} | ||
| > | ||
| <Pencil className="h-4 w-4" /> | ||
| </Button> |
There was a problem hiding this comment.
The rename action button is icon-only, but it doesn’t provide an accessible label. Add an aria-label (and optionally a tooltip/title) so screen readers can announce what the control does.
| <Button variant="destructive" size="sm" onClick={() => setDeleteTarget(p)}> | ||
| <Trash2 className="h-4 w-4" /> | ||
| </Button> |
There was a problem hiding this comment.
The delete action button is icon-only and currently has no accessible label. Add an aria-label (and optionally a tooltip/title) to make the action discoverable to assistive technologies.
| const { error } = await authClient.$fetch("/passkey/delete-passkey", { | ||
| method: "POST", | ||
| body: { id: deleteTarget.id }, | ||
| }); | ||
| setIsDeleting(false); | ||
| if (error) { | ||
| logger.error(error); | ||
| toast.error("Failed to delete passkey", { description: error.message }); | ||
| return; | ||
| } | ||
| toast.success("Passkey deleted"); | ||
| setDeleteTarget(null); | ||
| await refetch(); |
There was a problem hiding this comment.
handleDelete has the same issue as rename: if authClient.$fetch throws, isDeleting never gets reset and the confirmation dialog remains disabled. Use try/finally (and/or try/catch) around the request so state is always restored.
| const { error } = await authClient.$fetch("/passkey/delete-passkey", { | |
| method: "POST", | |
| body: { id: deleteTarget.id }, | |
| }); | |
| setIsDeleting(false); | |
| if (error) { | |
| logger.error(error); | |
| toast.error("Failed to delete passkey", { description: error.message }); | |
| return; | |
| } | |
| toast.success("Passkey deleted"); | |
| setDeleteTarget(null); | |
| await refetch(); | |
| try { | |
| const { error } = await authClient.$fetch("/passkey/delete-passkey", { | |
| method: "POST", | |
| body: { id: deleteTarget.id }, | |
| }); | |
| if (error) { | |
| logger.error(error); | |
| toast.error("Failed to delete passkey", { description: error.message }); | |
| return; | |
| } | |
| toast.success("Passkey deleted"); | |
| setDeleteTarget(null); | |
| await refetch(); | |
| } catch (err) { | |
| logger.error(err); | |
| toast.error("Failed to delete passkey", { | |
| description: err instanceof Error ? err.message : "Unknown error", | |
| }); | |
| } finally { | |
| setIsDeleting(false); | |
| } |
| toast.success("Passkey added"); | ||
| setAddDialogOpen(false); | ||
| setNewPasskeyName(""); | ||
| await refetch(); |
There was a problem hiding this comment.
This manual refetch() is implementing client-side invalidation logic. Per repo guidance, invalidation is handled automatically after mutations (see AGENTS.md:64-67). Prefer using the existing mutation/invalidation pattern (e.g., a typed passkey mutation hook/client method that triggers the automatic invalidation) instead of explicitly refetching here.
| toast.success("Passkey renamed"); | ||
| setRenameTarget(null); | ||
| setRenameValue(""); | ||
| await refetch(); |
There was a problem hiding this comment.
Manual refetch() here adds client-side invalidation logic. The repo’s convention is to rely on the automatic invalidation setup after mutations (AGENTS.md:64-67). Consider switching this rename flow to the standard mutation path so the cache updates without explicit refetching.
| credentialID: text("credential_id").notNull(), | ||
| counter: integer("counter").notNull(), |
There was a problem hiding this comment.
credentialID is not constrained to be unique. WebAuthn credential IDs should be globally unique; allowing duplicates can cause ambiguous lookups during assertion and potentially authenticate the wrong account if duplicates ever occur. Add a uniqueness constraint (e.g., unique index on credentialID, or on (userId, credentialID) depending on the expected model) and reflect it in the generated migration.
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
* feat: pre/post backup webhooks * fix(hooks): run post when cancelled * refactor(webhooks): headers as array * refactor: pr feedback * refactor: simplify hooks ceremonies * chore: pr feedbacks * chore: re-gen migration
* feat: make webhook calls trusted only * fix: pr feedbacks
c2677ee to
a79937d
Compare
* Add repository column sorting * Make status title correctly centered o nsmaller screens * Add volumes column sorting * refactor: use tanstack table for filtering and sorting * feat: make notifications sortable * chore: pr feedbacks --------- Co-authored-by: Antoine Jeanselme <67123340+ajeanselme@users.noreply.github.com> Co-authored-by: Nicolas Meienberger <github@thisprops.com>
* fix(notifications): persist delivery health status * fix: pr feedback double update
a79937d to
660e1b4
Compare
…cotsx#853) * fix(notifications): enforce allowlist for custom Shoutrrr targets * fix(notifications): enforce allowlist for email notification targets
* Initial plan * docs: update Docker image tags to v0.36 Closes nicotsx#856 Agent-Logs-Url: https://github.com/nicotsx/zerobyte/sessions/685fd718-282c-4843-b5bb-082bf8ed0571 Co-authored-by: nicotsx <47644445+nicotsx@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nicotsx <47644445+nicotsx@users.noreply.github.com>
* feat(agents): create agent registry and service * fix: mark agent offline only if the session was removed properly * refactor: centralize agent backup lifecycle state * refactor: simplify session management * refactor: move effect / async boundary in one place * chore: regen migration * refactor: improve error handling * chore: pr feedback
…sx#772) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
* refactor(mutext): persist repository locks in database * fix: clean up promoted repository lock on queued abort * fix: throttle repository lock cleanup during polling
* feat(auth): allow skipping forced recovery key download * refactor: move from session storage to cookie
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
…x#777) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
* chore(deps): update dependency bun to v1.3.14 * chore: update bun base docker image --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Nicolas Meienberger <github@thisprops.com>
* fix(deps): update dependency content-disposition to v2 * refactor(content-disposition): use new named export --------- Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> Co-authored-by: Nicolas Meienberger <github@thisprops.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
…tsx#905) * Initial plan * docs: update docker compose image tags to v0.37 Agent-Logs-Url: https://github.com/nicotsx/zerobyte/sessions/067f22a5-8223-4fd3-8e9c-c50f6ab2cc6e Co-authored-by: nicotsx <47644445+nicotsx@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nicotsx <47644445+nicotsx@users.noreply.github.com>
51cba3b to
a291eaf
Compare
ca0f2e8 to
97c70ea
Compare
No description provided.