From eada2ca678958822783ce4b2163a9833940eb120 Mon Sep 17 00:00:00 2001 From: Gyusun Yeom Date: Sun, 24 Aug 2025 17:07:28 +0900 Subject: [PATCH 1/7] Add alert-dialog component --- web-next/src/components/ui/alert-dialog.tsx | 155 ++++++++++++++++++++ 1 file changed, 155 insertions(+) create mode 100644 web-next/src/components/ui/alert-dialog.tsx diff --git a/web-next/src/components/ui/alert-dialog.tsx b/web-next/src/components/ui/alert-dialog.tsx new file mode 100644 index 00000000..a2e45c05 --- /dev/null +++ b/web-next/src/components/ui/alert-dialog.tsx @@ -0,0 +1,155 @@ +import type { + AlertDialogCloseButtonProps, + AlertDialogContentProps, + AlertDialogDescriptionProps, + AlertDialogTitleProps, +} from "@kobalte/core/alert-dialog"; +import { AlertDialog as AlertDialogPrimitive } from "@kobalte/core/alert-dialog"; +import type { PolymorphicProps } from "@kobalte/core/polymorphic"; +import type { ComponentProps, ParentProps, ValidComponent } from "solid-js"; +import { splitProps } from "solid-js"; +import { cn } from "~/lib/utils.ts"; +import { buttonVariants } from "./button.tsx"; + +export const AlertDialog = AlertDialogPrimitive; +export const AlertDialogTrigger = AlertDialogPrimitive.Trigger; + +type alertDialogContentProps = ParentProps< + AlertDialogContentProps & { + class?: string; + } +>; + +export const AlertDialogContent = ( + props: PolymorphicProps>, +) => { + const [local, rest] = splitProps(props as alertDialogContentProps, [ + "class", + "children", + ]); + + return ( + + + + {local.children} + + + ); +}; + +export const AlertDialogHeader = (props: ComponentProps<"div">) => { + const [local, rest] = splitProps(props, ["class"]); + + return ( +
+ ); +}; + +export const AlertDialogFooter = (props: ComponentProps<"div">) => { + const [local, rest] = splitProps(props, ["class"]); + + return ( +
+ ); +}; + +type alertDialogTitleProps = + & AlertDialogTitleProps + & { + class?: string; + }; + +export const AlertDialogTitle = ( + props: PolymorphicProps>, +) => { + const [local, rest] = splitProps(props as alertDialogTitleProps, ["class"]); + + return ( + + ); +}; + +type alertDialogDescriptionProps = + & AlertDialogDescriptionProps + & { + class?: string; + }; + +export const AlertDialogDescription = ( + props: PolymorphicProps>, +) => { + const [local, rest] = splitProps(props as alertDialogDescriptionProps, [ + "class", + ]); + + return ( + + ); +}; + +type alertDialogCloseProps = + & AlertDialogCloseButtonProps + & { + class?: string; + }; + +export const AlertDialogClose = ( + props: PolymorphicProps>, +) => { + const [local, rest] = splitProps(props as alertDialogCloseProps, ["class"]); + + return ( + + ); +}; + +export const AlertDialogAction = ( + props: PolymorphicProps>, +) => { + const [local, rest] = splitProps(props as alertDialogCloseProps, ["class"]); + + return ( + + ); +}; From 2c56a06a737df450f473db9fbf7f193ea4513f8f Mon Sep 17 00:00:00 2001 From: Gyusun Yeom Date: Sun, 24 Aug 2025 17:11:36 +0900 Subject: [PATCH 2/7] Implement passkey graphql API --- graphql/login.ts | 62 +++++++++++++++++ graphql/mod.ts | 1 + graphql/passkey.ts | 147 +++++++++++++++++++++++++++++++++++++++++ graphql/schema.graphql | 32 +++++++++ 4 files changed, 242 insertions(+) create mode 100644 graphql/passkey.ts diff --git a/graphql/login.ts b/graphql/login.ts index b2dad329..635d2915 100644 --- a/graphql/login.ts +++ b/graphql/login.ts @@ -1,4 +1,8 @@ import { negotiateLocale } from "@hackerspub/models/i18n"; +import { + getAuthenticationOptions, + verifyAuthentication, +} from "@hackerspub/models/passkey"; import { createSession, deleteSession, @@ -13,6 +17,7 @@ import { } from "@hackerspub/models/signin"; import type { Uuid } from "@hackerspub/models/uuid"; import { getLogger } from "@logtape/logtape"; +import type { AuthenticationResponseJSON } from "@simplewebauthn/server"; import { expandGlob } from "@std/fs"; import { join } from "@std/path"; import { createMessage, type Message } from "@upyo/core"; @@ -260,6 +265,63 @@ builder.mutationFields((t) => ({ return null; }, }), + + getPasskeyAuthenticationOptions: t.field({ + type: "JSON", + args: { + sessionId: t.arg({ + type: "UUID", + required: true, + description: "Temporary session ID for passkey authentication.", + }), + }, + async resolve(_, args, ctx) { + const options = await getAuthenticationOptions( + ctx.kv, + ctx.fedCtx.canonicalOrigin, + args.sessionId as Uuid, + ); + return options; + }, + }), + + loginByPasskey: t.field({ + type: SessionRef, + nullable: true, + args: { + sessionId: t.arg({ + type: "UUID", + required: true, + description: "Temporary session ID used for authentication options.", + }), + authenticationResponse: t.arg({ + type: "JSON", + required: true, + description: "WebAuthn authentication response from the client.", + }), + }, + async resolve(_, args, ctx) { + const result = await verifyAuthentication( + ctx.db, + ctx.kv, + ctx.fedCtx.canonicalOrigin, + args.sessionId as Uuid, + args.authenticationResponse as AuthenticationResponseJSON, + ); + if (result == null) return null; + const { response, account } = result; + if (!response.verified) return null; + + const remoteAddr = ctx.connectionInfo?.remoteAddr; + return await createSession(ctx.kv, { + accountId: account.id, + ipAddress: remoteAddr?.transport === "tcp" + ? remoteAddr.hostname + : undefined, + userAgent: ctx.request.headers.get("User-Agent"), + }); + }, + }), })); const LOCALES_DIR = join(import.meta.dirname!, "locales"); diff --git a/graphql/mod.ts b/graphql/mod.ts index 8886c795..9d815348 100644 --- a/graphql/mod.ts +++ b/graphql/mod.ts @@ -6,6 +6,7 @@ import { builder } from "./builder.ts"; import "./doc.ts"; import "./login.ts"; import "./notification.ts"; +import "./passkey.ts"; import "./poll.ts"; import "./post.ts"; import "./reactable.ts"; diff --git a/graphql/passkey.ts b/graphql/passkey.ts new file mode 100644 index 00000000..aa99da46 --- /dev/null +++ b/graphql/passkey.ts @@ -0,0 +1,147 @@ +import { + getRegistrationOptions, + verifyRegistration, +} from "@hackerspub/models/passkey"; +import { passkeyTable } from "@hackerspub/models/schema"; +import { + resolveCursorConnection, + type ResolveCursorConnectionArgs, +} from "@pothos/plugin-relay"; +import type { RegistrationResponseJSON } from "@simplewebauthn/server"; +import { and, desc, eq, gt, lt } from "drizzle-orm"; +import { Account } from "./account.ts"; +import { builder } from "./builder.ts"; + +export const Passkey = builder.drizzleNode("passkeyTable", { + name: "Passkey", + id: { + column: (passkey) => passkey.id, + }, + fields: (t) => ({ + name: t.exposeString("name"), + lastUsed: t.expose("lastUsed", { type: "DateTime", nullable: true }), + created: t.expose("created", { type: "DateTime" }), + }), +}); + +// Add passkeys connection to Account type +builder.objectField(Account, "passkeys", (t) => + t.connection({ + type: Passkey, + authScopes: (parent) => ({ + selfAccount: parent.id, + }), + async resolve(account, args, ctx) { + return resolveCursorConnection( + { + args, + toCursor: (passkey) => passkey.created.valueOf().toString(), + }, + async ( + { before, after, limit, inverted }: ResolveCursorConnectionArgs, + ) => { + const beforeDate = before ? new Date(Number(before)) : undefined; + const afterDate = after ? new Date(Number(after)) : undefined; + + return await ctx.db + .select() + .from(passkeyTable) + .where( + and( + eq(passkeyTable.accountId, account.id), + before + ? inverted + ? lt(passkeyTable.created, beforeDate!) + : gt(passkeyTable.created, beforeDate!) + : undefined, + after + ? inverted + ? gt(passkeyTable.created, afterDate!) + : lt(passkeyTable.created, afterDate!) + : undefined, + ), + ) + .orderBy( + inverted ? passkeyTable.created : desc(passkeyTable.created), + ).limit(limit); + }, + ); + }, + })); + +builder.mutationFields((t) => ({ + getPasskeyRegistrationOptions: t.field({ + type: "JSON", + args: { + accountId: t.arg.globalID({ for: Account, required: true }), + }, + async resolve(_, args, ctx) { + const session = await ctx.session; + if (session == null) throw new Error("Not authenticated."); + if (session.accountId !== args.accountId.id) { + throw new Error("Not authorized."); + } + const account = await ctx.db.query.accountTable.findFirst({ + where: { id: args.accountId.id }, + with: { passkeys: true }, + }); + if (account == null) throw new Error("Account not found."); + const options = await getRegistrationOptions( + ctx.kv, + ctx.fedCtx.canonicalOrigin, + account, + ); + return options; + }, + }), + verifyPasskeyRegistration: t.field({ + type: "JSON", + args: { + accountId: t.arg.globalID({ for: Account, required: true }), + name: t.arg.string({ required: true }), + registrationResponse: t.arg({ type: "JSON", required: true }), + }, + async resolve(_, args, ctx) { + const session = await ctx.session; + if (session == null) throw new Error("Not authenticated."); + if (session.accountId !== args.accountId.id) { + throw new Error("Not authorized."); + } + const account = await ctx.db.query.accountTable.findFirst({ + where: { id: args.accountId.id }, + with: { passkeys: true }, + }); + if (account == null) throw new Error("Account not found."); + const result = await verifyRegistration( + ctx.db, + ctx.kv, + ctx.fedCtx.canonicalOrigin, + account, + args.name, + args.registrationResponse as RegistrationResponseJSON, + ); + return { verified: result.verified }; + }, + }), + revokePasskey: t.field({ + type: "Boolean", + args: { + passkeyId: t.arg.globalID({ for: Passkey, required: true }), + }, + async resolve(_, args, ctx) { + const session = await ctx.session; + if (session == null) throw new Error("Not authenticated."); + const passkey = await ctx.db.query.passkeyTable.findFirst({ + where: { id: args.passkeyId.id }, + }); + if (passkey == null) return false; + if (passkey.accountId !== session.accountId) { + throw new Error("Not authorized."); + } + await ctx.db.delete(passkeyTable).where( + eq(passkeyTable.id, args.passkeyId.id), + ); + return true; + }, + }), +})); diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 71d53ec2..21c71f21 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -15,6 +15,7 @@ type Account implements Node { moderator: Boolean! name: String! notifications(after: String, before: String, first: Int, last: Int): AccountNotificationsConnection! + passkeys(after: String, before: String, first: Int, last: Int): AccountPasskeysConnection! preferAiSummary: Boolean! updated: DateTime! username: String! @@ -95,6 +96,16 @@ type AccountNotificationsConnectionEdge { node: Notification! } +type AccountPasskeysConnection { + edges: [AccountPasskeysConnectionEdge!]! + pageInfo: PageInfo! +} + +type AccountPasskeysConnectionEdge { + cursor: String! + node: Passkey! +} + type Actor implements Node { account: Account articles(after: String, before: String, first: Int, last: Int): ActorArticlesConnection! @@ -425,6 +436,11 @@ type Mutation { token: UUID! ): SignupResult! createNote(input: CreateNoteInput!): CreateNoteResult! + getPasskeyAuthenticationOptions( + """Temporary session ID for passkey authentication.""" + sessionId: UUID! + ): JSON! + getPasskeyRegistrationOptions(accountId: ID!): JSON! loginByEmail( """The email of the account to sign in.""" email: String! @@ -437,6 +453,13 @@ type Mutation { """ verifyUrl: URITemplate! ): LoginResult! + loginByPasskey( + """WebAuthn authentication response from the client.""" + authenticationResponse: JSON! + + """Temporary session ID used for authentication options.""" + sessionId: UUID! + ): Session loginByUsername( """The locale for the sign-in email.""" locale: Locale! @@ -449,6 +472,7 @@ type Mutation { """ verifyUrl: URITemplate! ): LoginResult! + revokePasskey(passkeyId: ID!): Boolean! """Revoke a session by its ID.""" revokeSession( @@ -456,6 +480,7 @@ type Mutation { sessionId: UUID! ): Session updateAccount(input: UpdateAccountInput!): UpdateAccountPayload! + verifyPasskeyRegistration(accountId: ID!, name: String!, registrationResponse: JSON!): JSON! } interface Node { @@ -527,6 +552,13 @@ type PageInfo { startCursor: String } +type Passkey implements Node { + created: DateTime! + id: ID! + lastUsed: DateTime + name: String! +} + type Poll implements Node { ends: DateTime! id: ID! From 1e2e9be82b74b61a1c0d103174e16bd21d3ad1a7 Mon Sep 17 00:00:00 2001 From: Gyusun Yeom Date: Sun, 24 Aug 2025 17:12:12 +0900 Subject: [PATCH 3/7] Implement settings/passkeys --- web-next/src/components/SettingsTabs.tsx | 11 +- .../(root)/[handle]/settings/passkeys.tsx | 471 ++++++++++++++++++ 2 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 web-next/src/routes/(root)/[handle]/settings/passkeys.tsx diff --git a/web-next/src/components/SettingsTabs.tsx b/web-next/src/components/SettingsTabs.tsx index 2118d6d1..cdde5537 100644 --- a/web-next/src/components/SettingsTabs.tsx +++ b/web-next/src/components/SettingsTabs.tsx @@ -6,7 +6,7 @@ import { Tabs, TabsList, TabsTrigger } from "~/components/ui/tabs.tsx"; import { useLingui } from "~/lib/i18n/macro.d.ts"; import type { SettingsTabs_account$key } from "./__generated__/SettingsTabs_account.graphql.ts"; -export type SettingsTab = "profile" | "preferences"; +export type SettingsTab = "profile" | "preferences" | "passkeys"; export interface SettingsTabsProps { selected: SettingsTab; @@ -28,7 +28,7 @@ export function SettingsTabs(props: SettingsTabsProps) { {(account) => ( - + {t`Preferences`} + + {t`Passkeys`} + )} diff --git a/web-next/src/routes/(root)/[handle]/settings/passkeys.tsx b/web-next/src/routes/(root)/[handle]/settings/passkeys.tsx new file mode 100644 index 00000000..80f880cf --- /dev/null +++ b/web-next/src/routes/(root)/[handle]/settings/passkeys.tsx @@ -0,0 +1,471 @@ +import { + type PublicKeyCredentialCreationOptionsJSON, + type RegistrationResponseJSON, + startRegistration, +} from "@simplewebauthn/browser"; +import { + Navigate, + query, + type RouteDefinition, + useLocation, + useParams, +} from "@solidjs/router"; +import { graphql } from "relay-runtime"; +import { createSignal, For, Show } from "solid-js"; +import { + createMutation, + createPreloadedQuery, + loadQuery, + useRelayEnvironment, +} from "solid-relay"; +import { ProfilePageBreadcrumb } from "~/components/ProfilePageBreadcrumb.tsx"; +import { SettingsTabs } from "~/components/SettingsTabs.tsx"; +import { Timestamp } from "~/components/Timestamp.tsx"; +import { Title } from "~/components/Title.tsx"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogClose, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "~/components/ui/alert-dialog.tsx"; +import { + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbSeparator, +} from "~/components/ui/breadcrumb.tsx"; +import { Button } from "~/components/ui/button.tsx"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "~/components/ui/card.tsx"; +import { + TextField, + TextFieldInput, + TextFieldLabel, +} from "~/components/ui/text-field.tsx"; +import { showToast } from "~/components/ui/toast.tsx"; +import { useLingui } from "~/lib/i18n/macro.d.ts"; +import type { passkeysGetPasskeyRegistrationOptionsMutation } from "./__generated__/passkeysGetPasskeyRegistrationOptionsMutation.graphql.ts"; +import type { passkeysPageQuery } from "./__generated__/passkeysPageQuery.graphql.ts"; +import type { passkeysRevokePasskeyMutation } from "./__generated__/passkeysRevokePasskeyMutation.graphql.ts"; +import type { passkeysVerifyPasskeyRegistrationMutation } from "./__generated__/passkeysVerifyPasskeyRegistrationMutation.graphql.ts"; + +export const route = { + matchFilters: { + handle: /^@[^@]+$/, + }, + preload(args) { + void loadPageQuery(args.params.handle); + }, +} satisfies RouteDefinition; + +const passkeysPageQuery = graphql` + query passkeysPageQuery($username: String!) { + viewer { + id + } + accountByUsername(username: $username) { + id + username + ...SettingsTabs_account + passkeys(first: 50) { + edges { + node { + id + name + lastUsed + created + } + } + } + actor { + ...ProfilePageBreadcrumb_actor + } + } + } +`; + +const loadPageQuery = query( + (handle: string) => + loadQuery( + useRelayEnvironment()(), + passkeysPageQuery, + { username: handle.replace(/^@/, "") }, + ), + "loadpasskeysPageQuery", +); + +const getPasskeyRegistrationOptionsMutation = graphql` + mutation passkeysGetPasskeyRegistrationOptionsMutation($accountId: ID!) { + getPasskeyRegistrationOptions(accountId: $accountId) + } +`; + +const verifyPasskeyRegistrationMutation = graphql` + mutation passkeysVerifyPasskeyRegistrationMutation( + $accountId: ID! + $name: String! + $registrationResponse: JSON! + ) { + verifyPasskeyRegistration( + accountId: $accountId + name: $name + registrationResponse: $registrationResponse + ) + } +`; + +const revokePasskeyMutation = graphql` + mutation passkeysRevokePasskeyMutation($passkeyId: ID!) { + revokePasskey(passkeyId: $passkeyId) + } +`; + +export default function passkeysPage() { + const params = useParams(); + const location = useLocation(); + const { t } = useLingui(); + + const data = createPreloadedQuery( + passkeysPageQuery, + () => loadPageQuery(params.handle), + ); + + const refreshData = () => { + // TODO: Fix Relay query refreshing - currently loadQuery doesn't update the UI + // The loadQuery call fetches data but doesn't update the createPreloadedQuery + // Need to investigate proper solid-relay patterns for refreshing queries + window.location.reload(); + }; + + const [getOptions] = createMutation< + passkeysGetPasskeyRegistrationOptionsMutation + >( + getPasskeyRegistrationOptionsMutation, + ); + const [verifyRegistration] = createMutation< + passkeysVerifyPasskeyRegistrationMutation + >( + verifyPasskeyRegistrationMutation, + ); + const [revokePasskey] = createMutation( + revokePasskeyMutation, + ); + + const [registering, setRegistering] = createSignal(false); + const [passkeyName, setPasskeyName] = createSignal(""); + const [passkeyToRevoke, setPasskeyToRevoke] = createSignal< + { id: string; name: string } | null + >(null); + + async function onRegisterPasskey() { + const account = data()?.accountByUsername; + const name = passkeyName().trim(); + if (!account || !name) return; + + setRegistering(true); + + try { + // Get registration options + const optionsResponse = await new Promise< + passkeysGetPasskeyRegistrationOptionsMutation["response"] + >((resolve, reject) => { + getOptions({ + variables: { accountId: account.id }, + onCompleted: resolve, + onError: reject, + }); + }); + + const options = optionsResponse.getPasskeyRegistrationOptions; + if (!options || typeof options !== "object") { + throw new Error("Invalid registration options"); + } + + // Use @simplewebauthn/browser to handle registration + let registrationResponse: RegistrationResponseJSON; + try { + registrationResponse = await startRegistration({ + optionsJSON: options as PublicKeyCredentialCreationOptionsJSON, + }); + } catch (error) { + throw new Error( + error instanceof Error ? error.message : "Registration failed", + ); + } + + // Verify registration + const verifyResponse = await new Promise< + passkeysVerifyPasskeyRegistrationMutation["response"] + >((resolve, reject) => { + verifyRegistration({ + variables: { + accountId: account.id, + name, + registrationResponse, + }, + onCompleted: resolve, + onError: reject, + }); + }); + + const result = verifyResponse.verifyPasskeyRegistration; + if ( + result && typeof result === "object" && "verified" in result && + result.verified + ) { + showToast({ + title: t`Passkey registered successfully`, + description: + t`Your passkey has been registered and can now be used for authentication.`, + variant: "success", + }); + setPasskeyName(""); + // Refresh the data to show the new passkey + refreshData(); + } else { + throw new Error("Passkey verification failed"); + } + } catch (error) { + showToast({ + title: t`Failed to register passkey`, + description: error instanceof Error + ? error.message + : t`An error occurred while registering your passkey.`, + variant: "error", + }); + } finally { + setRegistering(false); + } + } + + function openRevokeDialog(passkeyId: string, passkeyName: string) { + setPasskeyToRevoke({ id: passkeyId, name: passkeyName }); + } + + async function confirmRevokePasskey() { + const passkey = passkeyToRevoke(); + if (!passkey) return; + + try { + const response = await new Promise< + passkeysRevokePasskeyMutation["response"] + >((resolve, reject) => { + revokePasskey({ + variables: { passkeyId: passkey.id }, + onCompleted: resolve, + onError: reject, + }); + }); + + if (response.revokePasskey) { + showToast({ + title: t`Passkey revoked`, + description: t`The passkey has been successfully revoked.`, + variant: "success", + }); + // Refresh the data to remove the revoked passkey + refreshData(); + } else { + showToast({ + title: t`Failed to revoke passkey`, + variant: "error", + }); + } + } catch (error) { + showToast({ + title: t`Failed to revoke passkey`, + description: error instanceof Error + ? error.message + : t`An error occurred while revoking your passkey.`, + variant: "error", + }); + } finally { + setPasskeyToRevoke(null); + } + } + + return ( + + {(data) => ( + <> + + } + > + {(viewer) => ( + + {(account) => ( + + + + )} + + )} + + + {(account) => ( + <> + {t`passkeys`} + + + + + {t`Settings`} + + + + + + {t`passkeys`} + + + +
+
+ + +
+ + + {t`Register a passkey`} + + {t`Register a passkey to sign in to your account. You can use a passkey instead of receiving a sign-in link by email.`} + + + + + + {t`Passkey name`} + + + setPasskeyName(e.currentTarget.value)} + /> + + + + + + + + {t`Registered passkeys`} + + {t`The following passkeys are registered to your account. You can use them to sign in to your account.`} + + + + 0} + fallback={ +

+ {t`You don't have any passkeys registered yet.`} +

+ } + > +
+ + {(edge) => ( +
+
+

+ {edge.node.name} +

+
+
+ {t`Created:`}{" "} + +
+
+ {edge.node.lastUsed + ? ( + <> + {t`Last used:`}{" "} + + + ) + : t`Never used`} +
+
+
+ +
+ )} +
+
+
+
+
+
+
+
+ + )} +
+ setPasskeyToRevoke(null)} + > + + + {t`Revoke passkey`} + + {t`Are you sure you want to revoke passkey ${passkeyToRevoke()?.name}? You won't be able to use it to sign in to your account anymore.`} + + + + {t`Cancel`} + + {t`Revoke`} + + + + + + )} +
+ ); +} From 2966f7b9f0f5e9067e76afaa760cc14ecd684e9b Mon Sep 17 00:00:00 2001 From: Gyusun Yeom Date: Sun, 24 Aug 2025 17:14:09 +0900 Subject: [PATCH 4/7] Implement login by passkey --- web-next/src/routes/(root)/sign/index.tsx | 139 +++++++++++++++++++++- 1 file changed, 138 insertions(+), 1 deletion(-) diff --git a/web-next/src/routes/(root)/sign/index.tsx b/web-next/src/routes/(root)/sign/index.tsx index 7ec12028..d8c17538 100644 --- a/web-next/src/routes/(root)/sign/index.tsx +++ b/web-next/src/routes/(root)/sign/index.tsx @@ -1,6 +1,11 @@ import type { Uuid } from "@hackerspub/models/uuid"; +import { + type AuthenticationResponseJSON, + type PublicKeyCredentialRequestOptionsJSON, + startAuthentication, +} from "@simplewebauthn/browser"; import { graphql } from "relay-runtime"; -import { createSignal, Show } from "solid-js"; +import { createSignal, onMount, Show } from "solid-js"; import { getRequestEvent } from "solid-js/web"; import { createMutation } from "solid-relay"; import { getRequestProtocol, setCookie } from "vinxi/http"; @@ -19,15 +24,18 @@ import { TextFieldInput, TextFieldLabel, } from "~/components/ui/text-field.tsx"; +import { showToast } from "~/components/ui/toast.tsx"; import { useLingui } from "~/lib/i18n/macro.d.ts"; import type { signByEmailMutation, } from "./__generated__/signByEmailMutation.graphql.ts"; +import type { signByPasskeyMutation } from "./__generated__/signByPasskeyMutation.graphql.ts"; import type { signByUsernameMutation, signByUsernameMutation$data, } from "./__generated__/signByUsernameMutation.graphql.ts"; import type { signCompleteMutation } from "./__generated__/signCompleteMutation.graphql.ts"; +import type { signGetPasskeyAuthenticationOptionsMutation } from "./__generated__/signGetPasskeyAuthenticationOptionsMutation.graphql.ts"; const signByEmailMutation = graphql` mutation signByEmailMutation($locale: Locale!, $email: String!, $verifyUrl: URITemplate!) { @@ -75,6 +83,20 @@ const signCompleteMutation = graphql` } `; +const signGetPasskeyAuthenticationOptionsMutation = graphql` + mutation signGetPasskeyAuthenticationOptionsMutation($sessionId: UUID!) { + getPasskeyAuthenticationOptions(sessionId: $sessionId) + } +`; + +const signByPasskeyMutation = graphql` + mutation signByPasskeyMutation($sessionId: UUID!, $authenticationResponse: JSON!) { + loginByPasskey(sessionId: $sessionId, authenticationResponse: $authenticationResponse) { + id + } + } +`; + const setSessionCookie = async (sessionId: Uuid) => { "use server"; const event = getRequestEvent(); @@ -115,7 +137,25 @@ export default function SignPage() { const [complete] = createMutation( signCompleteMutation, ); + const [getPasskeyOptions] = createMutation< + signGetPasskeyAuthenticationOptionsMutation + >( + signGetPasskeyAuthenticationOptionsMutation, + ); + const [loginByPasskey] = createMutation( + signByPasskeyMutation, + ); const [completing, setCompleting] = createSignal(false); + const [passkeyAuthenticating, setPasskeyAuthenticating] = createSignal(false); + const [autoPasskeyAttempted, setAutoPasskeyAttempted] = createSignal(false); + + onMount(() => { + // Automatically attempt passkey authentication when page loads + if (!autoPasskeyAttempted()) { + setAutoPasskeyAttempted(true); + onPasskeyLogin(false); + } + }); function onInput() { if (emailInput == null) return; @@ -238,6 +278,80 @@ export default function SignPage() { } } + async function onPasskeyLogin(showError: boolean) { + setPasskeyAuthenticating(true); + + try { + // Generate a temporary session ID for this authentication attempt + const tempSessionId = crypto.randomUUID(); + + // Get authentication options + const optionsResponse = await new Promise< + signGetPasskeyAuthenticationOptionsMutation["response"] + >((resolve, reject) => { + getPasskeyOptions({ + variables: { sessionId: tempSessionId }, + onCompleted: resolve, + onError: reject, + }); + }); + + const options = optionsResponse.getPasskeyAuthenticationOptions; + if (!options || typeof options !== "object") { + throw new Error("Invalid authentication options"); + } + + // Start WebAuthn authentication + let authenticationResponse: AuthenticationResponseJSON; + try { + authenticationResponse = await startAuthentication({ + optionsJSON: options as PublicKeyCredentialRequestOptionsJSON, + }); + } catch (error) { + throw new Error( + error instanceof Error ? error.message : "Authentication failed", + ); + } + + // Verify authentication and get session + const loginResponse = await new Promise< + signByPasskeyMutation["response"] + >((resolve, reject) => { + loginByPasskey({ + variables: { + sessionId: tempSessionId, + authenticationResponse, + }, + onCompleted: resolve, + onError: reject, + }); + }); + + if (loginResponse.loginByPasskey?.id) { + const success = await setSessionCookie(loginResponse.loginByPasskey.id); + if (success) { + const searchParams = location == null + ? new URLSearchParams() + : new URL(location.href).searchParams; + window.location.href = searchParams.get("next") ?? "/"; + } else { + throw new Error("Failed to set session cookie"); + } + } else { + throw new Error("Authentication verification failed"); + } + } catch (_) { + if (showError) { + showToast({ + title: t`Passkey authentication failed`, + variant: "destructive", + }); + } + } finally { + setPasskeyAuthenticating(false); + } + } + return (
+
+
+ +
+
+ + {t`Or`} + +
+
+
+ +

{t`Do you need an account? Hackers' Pub is invite-only—please ask a friend to invite you.`} From 19866a64545c68442283d1637dac558ef7ef6f04 Mon Sep 17 00:00:00 2001 From: Gyusun Yeom Date: Sun, 24 Aug 2025 22:15:21 +0900 Subject: [PATCH 5/7] make passkeys refetchable with pagination --- graphql/passkey.ts | 42 ++++- graphql/schema.graphql | 9 +- .../(root)/[handle]/settings/passkeys.tsx | 159 ++++++++++++++---- 3 files changed, 168 insertions(+), 42 deletions(-) diff --git a/graphql/passkey.ts b/graphql/passkey.ts index aa99da46..dbe2de09 100644 --- a/graphql/passkey.ts +++ b/graphql/passkey.ts @@ -4,6 +4,7 @@ import { } from "@hackerspub/models/passkey"; import { passkeyTable } from "@hackerspub/models/schema"; import { + encodeGlobalID, resolveCursorConnection, type ResolveCursorConnectionArgs, } from "@pothos/plugin-relay"; @@ -24,6 +25,22 @@ export const Passkey = builder.drizzleNode("passkeyTable", { }), }); +const PasskeyRegistrationResult = builder + .objectRef<{ + verified: boolean; + passkey: typeof Passkey.$inferType | null; + }>("PasskeyRegistrationResult") + .implement({ + fields: (t) => ({ + verified: t.exposeBoolean("verified"), + passkey: t.field({ + type: Passkey, + nullable: true, + resolve: (parent) => parent.passkey, + }), + }), + }); + // Add passkeys connection to Account type builder.objectField(Account, "passkeys", (t) => t.connection({ @@ -95,7 +112,7 @@ builder.mutationFields((t) => ({ }, }), verifyPasskeyRegistration: t.field({ - type: "JSON", + type: PasskeyRegistrationResult, args: { accountId: t.arg.globalID({ for: Account, required: true }), name: t.arg.string({ required: true }), @@ -120,11 +137,26 @@ builder.mutationFields((t) => ({ args.name, args.registrationResponse as RegistrationResponseJSON, ); - return { verified: result.verified }; + + let passkey = null; + if (result.verified && result.registrationInfo != null) { + // Fetch the newly created passkey + passkey = await ctx.db.query.passkeyTable.findFirst({ + where: { + id: result.registrationInfo.credential.id, + }, + }); + } + + return { + verified: result.verified, + passkey: passkey || null, + }; }, }), revokePasskey: t.field({ - type: "Boolean", + type: "ID", + nullable: true, args: { passkeyId: t.arg.globalID({ for: Passkey, required: true }), }, @@ -134,14 +166,14 @@ builder.mutationFields((t) => ({ const passkey = await ctx.db.query.passkeyTable.findFirst({ where: { id: args.passkeyId.id }, }); - if (passkey == null) return false; + if (passkey == null) return null; if (passkey.accountId !== session.accountId) { throw new Error("Not authorized."); } await ctx.db.delete(passkeyTable).where( eq(passkeyTable.id, args.passkeyId.id), ); - return true; + return encodeGlobalID(Passkey.name, args.passkeyId.id); }, }), })); diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 21c71f21..c8c72105 100644 --- a/graphql/schema.graphql +++ b/graphql/schema.graphql @@ -472,7 +472,7 @@ type Mutation { """ verifyUrl: URITemplate! ): LoginResult! - revokePasskey(passkeyId: ID!): Boolean! + revokePasskey(passkeyId: ID!): ID """Revoke a session by its ID.""" revokeSession( @@ -480,7 +480,7 @@ type Mutation { sessionId: UUID! ): Session updateAccount(input: UpdateAccountInput!): UpdateAccountPayload! - verifyPasskeyRegistration(accountId: ID!, name: String!, registrationResponse: JSON!): JSON! + verifyPasskeyRegistration(accountId: ID!, name: String!, registrationResponse: JSON!): PasskeyRegistrationResult! } interface Node { @@ -559,6 +559,11 @@ type Passkey implements Node { name: String! } +type PasskeyRegistrationResult { + passkey: Passkey + verified: Boolean! +} + type Poll implements Node { ends: DateTime! id: ID! diff --git a/web-next/src/routes/(root)/[handle]/settings/passkeys.tsx b/web-next/src/routes/(root)/[handle]/settings/passkeys.tsx index 80f880cf..423ce86a 100644 --- a/web-next/src/routes/(root)/[handle]/settings/passkeys.tsx +++ b/web-next/src/routes/(root)/[handle]/settings/passkeys.tsx @@ -14,6 +14,7 @@ import { graphql } from "relay-runtime"; import { createSignal, For, Show } from "solid-js"; import { createMutation, + createPaginationFragment, createPreloadedQuery, loadQuery, useRelayEnvironment, @@ -52,11 +53,14 @@ import { } from "~/components/ui/text-field.tsx"; import { showToast } from "~/components/ui/toast.tsx"; import { useLingui } from "~/lib/i18n/macro.d.ts"; +import { passkeysFragment_account$key } from "./__generated__/passkeysFragment_account.graphql.ts"; import type { passkeysGetPasskeyRegistrationOptionsMutation } from "./__generated__/passkeysGetPasskeyRegistrationOptionsMutation.graphql.ts"; import type { passkeysPageQuery } from "./__generated__/passkeysPageQuery.graphql.ts"; import type { passkeysRevokePasskeyMutation } from "./__generated__/passkeysRevokePasskeyMutation.graphql.ts"; import type { passkeysVerifyPasskeyRegistrationMutation } from "./__generated__/passkeysVerifyPasskeyRegistrationMutation.graphql.ts"; +const PASSKEYS_PAGE_SIZE = 10 as const; + export const route = { matchFilters: { handle: /^@[^@]+$/, @@ -67,7 +71,7 @@ export const route = { } satisfies RouteDefinition; const passkeysPageQuery = graphql` - query passkeysPageQuery($username: String!) { + query passkeysPageQuery($username: String!, $first: Int, $after: String) { viewer { id } @@ -75,16 +79,7 @@ const passkeysPageQuery = graphql` id username ...SettingsTabs_account - passkeys(first: 50) { - edges { - node { - id - name - lastUsed - created - } - } - } + ...passkeysFragment_account @arguments(first: $first, after: $after) actor { ...ProfilePageBreadcrumb_actor } @@ -92,12 +87,48 @@ const passkeysPageQuery = graphql` } `; +const passkeysFragment = graphql` + fragment passkeysFragment_account on Account + @refetchable(queryName: "PasskeysPaginationQuery") + @argumentDefinitions( + first: { type: "Int" }, + after: { type: "String" } + ) { + passkeys(first: $first, after: $after) + @connection(key: "passkeysFragment_passkeys") { + __id + edges { + node { + id + name + lastUsed + created + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } + } +`; + const loadPageQuery = query( - (handle: string) => + ( + handle: string, + first: number = PASSKEYS_PAGE_SIZE, + after: string | null = null, + ) => loadQuery( useRelayEnvironment()(), passkeysPageQuery, - { username: handle.replace(/^@/, "") }, + { + username: handle.replace(/^@/, ""), + first, + after, + }, ), "loadpasskeysPageQuery", ); @@ -113,18 +144,27 @@ const verifyPasskeyRegistrationMutation = graphql` $accountId: ID! $name: String! $registrationResponse: JSON! + $connections: [ID!]! ) { verifyPasskeyRegistration( accountId: $accountId name: $name registrationResponse: $registrationResponse - ) + ) { + verified + passkey @appendNode(connections: $connections, edgeTypeName: "Passkey") { + id + name + lastUsed + created + } + } } `; const revokePasskeyMutation = graphql` - mutation passkeysRevokePasskeyMutation($passkeyId: ID!) { - revokePasskey(passkeyId: $passkeyId) + mutation passkeysRevokePasskeyMutation($passkeyId: ID!, $connections: [ID!]!) { + revokePasskey(passkeyId: $passkeyId) @deleteEdge(connections: $connections) } `; @@ -138,13 +178,6 @@ export default function passkeysPage() { () => loadPageQuery(params.handle), ); - const refreshData = () => { - // TODO: Fix Relay query refreshing - currently loadQuery doesn't update the UI - // The loadQuery call fetches data but doesn't update the createPreloadedQuery - // Need to investigate proper solid-relay patterns for refreshing queries - window.location.reload(); - }; - const [getOptions] = createMutation< passkeysGetPasskeyRegistrationOptionsMutation >( @@ -164,6 +197,28 @@ export default function passkeysPage() { const [passkeyToRevoke, setPasskeyToRevoke] = createSignal< { id: string; name: string } | null >(null); + const [loadingState, setLoadingState] = createSignal< + "loaded" | "loading" | "errored" + >("loaded"); + + // Use pagination fragment for passkey data + const passkeyData = createPaginationFragment( + passkeysFragment, + () => data()?.accountByUsername as passkeysFragment_account$key, + ); + + const loadMorePasskeys = () => { + setLoadingState("loading"); + passkeyData.loadNext(PASSKEYS_PAGE_SIZE, { + onComplete: (error) => { + if (error) { + setLoadingState("errored"); + } else { + setLoadingState("loaded"); + } + }, + }); + }; async function onRegisterPasskey() { const account = data()?.accountByUsername; @@ -210,6 +265,7 @@ export default function passkeysPage() { accountId: account.id, name, registrationResponse, + connections: [passkeyData()!.passkeys.__id], }, onCompleted: resolve, onError: reject, @@ -217,10 +273,7 @@ export default function passkeysPage() { }); const result = verifyResponse.verifyPasskeyRegistration; - if ( - result && typeof result === "object" && "verified" in result && - result.verified - ) { + if (result && result.verified) { showToast({ title: t`Passkey registered successfully`, description: @@ -228,8 +281,7 @@ export default function passkeysPage() { variant: "success", }); setPasskeyName(""); - // Refresh the data to show the new passkey - refreshData(); + // No need to manually refresh - @appendNode automatically updates the connection } else { throw new Error("Passkey verification failed"); } @@ -259,7 +311,10 @@ export default function passkeysPage() { passkeysRevokePasskeyMutation["response"] >((resolve, reject) => { revokePasskey({ - variables: { passkeyId: passkey.id }, + variables: { + passkeyId: passkey.id, + connections: [passkeyData()!.passkeys.__id], + }, onCompleted: resolve, onError: reject, }); @@ -271,8 +326,7 @@ export default function passkeysPage() { description: t`The passkey has been successfully revoked.`, variant: "success", }); - // Refresh the data to remove the revoked passkey - refreshData(); + // No need to manually refresh - @deleteEdge automatically updates the connection } else { showToast({ title: t`Failed to revoke passkey`, @@ -366,7 +420,7 @@ export default function passkeysPage() { passkeyName().trim() === ""} class="w-full cursor-pointer" > - {registering() ? t`Registering...` : t`Register`} + {registering() ? t`Registering…` : t`Register`} @@ -380,7 +434,11 @@ export default function passkeysPage() { 0} + when={() => { + const paginatedData = passkeyData(); + return paginatedData && + paginatedData.passkeys.edges.length > 0; + }} fallback={

{t`You don't have any passkeys registered yet.`} @@ -388,7 +446,14 @@ export default function passkeysPage() { } >

- + { + const paginatedData = passkeyData(); + return paginatedData + ? paginatedData.passkeys.edges + : []; + })()} + > {(edge) => (
@@ -432,6 +497,30 @@ export default function passkeysPage() {
)} + + { + const paginatedData = passkeyData(); + return paginatedData && + paginatedData.passkeys.pageInfo.hasNextPage; + }} + > +
+ +
+
From 5712c829915976f96a8a46e7408f752ef59eebad Mon Sep 17 00:00:00 2001 From: Gyusun Yeom Date: Mon, 25 Aug 2025 00:03:01 +0900 Subject: [PATCH 6/7] workaround: Use ref & disable interactive validation --- .../routes/(root)/[handle]/settings/passkeys.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/web-next/src/routes/(root)/[handle]/settings/passkeys.tsx b/web-next/src/routes/(root)/[handle]/settings/passkeys.tsx index 423ce86a..eed42a86 100644 --- a/web-next/src/routes/(root)/[handle]/settings/passkeys.tsx +++ b/web-next/src/routes/(root)/[handle]/settings/passkeys.tsx @@ -193,7 +193,7 @@ export default function passkeysPage() { ); const [registering, setRegistering] = createSignal(false); - const [passkeyName, setPasskeyName] = createSignal(""); + let passkeyNameRef: HTMLInputElement | undefined; const [passkeyToRevoke, setPasskeyToRevoke] = createSignal< { id: string; name: string } | null >(null); @@ -222,7 +222,7 @@ export default function passkeysPage() { async function onRegisterPasskey() { const account = data()?.accountByUsername; - const name = passkeyName().trim(); + const name = passkeyNameRef?.value?.trim() ?? ""; if (!account || !name) return; setRegistering(true); @@ -280,7 +280,7 @@ export default function passkeysPage() { t`Your passkey has been registered and can now be used for authentication.`, variant: "success", }); - setPasskeyName(""); + if (passkeyNameRef) passkeyNameRef.value = ""; // No need to manually refresh - @appendNode automatically updates the connection } else { throw new Error("Passkey verification failed"); @@ -408,16 +408,13 @@ export default function passkeysPage() { id="passkey-name" placeholder={t`ex) My key`} required - value={passkeyName()} - onInput={(e) => - setPasskeyName(e.currentTarget.value)} + ref={passkeyNameRef} />