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..dbe2de09 --- /dev/null +++ b/graphql/passkey.ts @@ -0,0 +1,179 @@ +import { + getRegistrationOptions, + verifyRegistration, +} from "@hackerspub/models/passkey"; +import { passkeyTable } from "@hackerspub/models/schema"; +import { + encodeGlobalID, + 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" }), + }), +}); + +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({ + 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: PasskeyRegistrationResult, + 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, + ); + + 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: "ID", + nullable: true, + 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 null; + if (passkey.accountId !== session.accountId) { + throw new Error("Not authorized."); + } + await ctx.db.delete(passkeyTable).where( + eq(passkeyTable.id, args.passkeyId.id), + ); + return encodeGlobalID(Passkey.name, args.passkeyId.id); + }, + }), +})); diff --git a/graphql/schema.graphql b/graphql/schema.graphql index 71d53ec2..c8c72105 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!): ID """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!): PasskeyRegistrationResult! } interface Node { @@ -527,6 +552,18 @@ type PageInfo { startCursor: String } +type Passkey implements Node { + created: DateTime! + id: ID! + lastUsed: DateTime + name: String! +} + +type PasskeyRegistrationResult { + passkey: Passkey + verified: Boolean! +} + type Poll implements Node { ends: DateTime! id: ID! 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/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 ( + + ); +}; diff --git a/web-next/src/locales/en-US/messages.po b/web-next/src/locales/en-US/messages.po index 7c80f7ac..58b59a75 100644 --- a/web-next/src/locales/en-US/messages.po +++ b/web-next/src/locales/en-US/messages.po @@ -128,7 +128,7 @@ msgstr "{0}'s notes" msgid "{0}'s shares" msgstr "{0}'s shares" -#: src/routes/(root)/sign/index.tsx:193 +#: src/routes/(root)/sign/index.tsx:233 msgid "A sign-in link has been sent to your email. Please check your inbox (or spam folder)." msgstr "A sign-in link has been sent to your email. Please check your inbox (or spam folder)." @@ -140,6 +140,14 @@ msgstr "Account" msgid "An error occurred during signup. Please try again." msgstr "An error occurred during signup. Please try again." +#: src/routes/(root)/[handle]/settings/passkeys.tsx:293 +msgid "An error occurred while registering your passkey." +msgstr "An error occurred while registering your passkey." + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:341 +msgid "An error occurred while revoking your passkey." +msgstr "An error occurred while revoking your passkey." + #: src/routes/(root)/[handle]/settings/preferences.tsx:144 msgid "An error occurred while saving your preferences. Please try again, or contact support if the problem persists." msgstr "An error occurred while saving your preferences. Please try again, or contact support if the problem persists." @@ -148,6 +156,11 @@ msgstr "An error occurred while saving your preferences. Please try again, or co msgid "An error occurred while saving your settings. Please try again, or contact support if the problem persists." msgstr "An error occurred while saving your settings. Please try again, or contact support if the problem persists." +#. placeholder {0}: passkeyToRevoke()?.name +#: src/routes/(root)/[handle]/settings/passkeys.tsx:539 +msgid "Are you sure you want to revoke passkey {0}? You won't be able to use it to sign in to your account anymore." +msgstr "Are you sure you want to revoke passkey {0}? You won't be able to use it to sign in to your account anymore." + #: src/components/ProfileTabs.tsx:48 #: src/routes/(root)/[handle]/(profile)/articles.tsx:84 msgid "Articles" @@ -162,6 +175,10 @@ msgstr "Articles only" msgid "As you have already changed it {0}, you can't change it again." msgstr "As you have already changed it {0}, you can't change it again." +#: src/routes/(root)/sign/index.tsx:419 +msgid "Authenticating…" +msgstr "Authenticating…" + #: src/routes/(root)/[handle]/settings/index.tsx:275 msgid "Avatar" msgstr "Avatar" @@ -176,6 +193,7 @@ msgid "Bio is too long. Maximum length is 512 characters." msgstr "Bio is too long. Maximum length is 512 characters." #: src/routes/(root)/[handle]/settings/index.tsx:323 +#: src/routes/(root)/[handle]/settings/passkeys.tsx:543 msgid "Cancel" msgstr "Cancel" @@ -186,6 +204,10 @@ msgstr "Cancel" msgid "Code of conduct" msgstr "Code of conduct" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:462 +msgid "Created:" +msgstr "Created:" + #: src/routes/(root)/sign/up/[token].tsx:485 msgid "Creating account…" msgstr "Creating account…" @@ -211,7 +233,7 @@ msgstr "Default share privacy" msgid "Display name" msgstr "Display name" -#: src/routes/(root)/sign/index.tsx:288 +#: src/routes/(root)/sign/index.tsx:425 msgid "Do you need an account? Hackers' Pub is invite-only—please ask a friend to invite you." msgstr "Do you need an account? Hackers' Pub is invite-only—please ask a friend to invite you." @@ -223,11 +245,11 @@ msgstr "Drag to select the area you want to keep, then click “Crop” to updat msgid "Email address" msgstr "Email address" -#: src/routes/(root)/sign/index.tsx:266 +#: src/routes/(root)/sign/index.tsx:380 msgid "Email or username" msgstr "Email or username" -#: src/routes/(root)/sign/index.tsx:201 +#: src/routes/(root)/sign/index.tsx:241 msgid "Enter your email or username below to sign in." msgstr "Enter your email or username below to sign in." @@ -235,6 +257,10 @@ msgstr "Enter your email or username below to sign in." msgid "Error" msgstr "Error" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:409 +msgid "ex) My key" +msgstr "ex) My key" + #: src/components/ActorArticleList.tsx:75 msgid "Failed to load more articles; click to retry" msgstr "Failed to load more articles; click to retry" @@ -251,11 +277,24 @@ msgstr "Failed to load more notes; click to retry" msgid "Failed to load more notifications; click to retry" msgstr "Failed to load more notifications; click to retry" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:516 +msgid "Failed to load more passkeys; click to retry" +msgstr "Failed to load more passkeys; click to retry" + #: src/components/ActorPostList.tsx:73 #: src/components/ActorSharedPostList.tsx:75 msgid "Failed to load more posts; click to retry" msgstr "Failed to load more posts; click to retry" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:290 +msgid "Failed to register passkey" +msgstr "Failed to register passkey" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:332 +#: src/routes/(root)/[handle]/settings/passkeys.tsx:338 +msgid "Failed to revoke passkey" +msgstr "Failed to revoke passkey" + #: src/routes/(root)/[handle]/settings/preferences.tsx:142 msgid "Failed to save preferences" msgstr "Failed to save preferences" @@ -321,6 +360,10 @@ msgstr "If enabled, the AI will generate a summary of the article for you. Other msgid "John Doe" msgstr "John Doe" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:471 +msgid "Last used:" +msgstr "Last used:" + #: src/components/ActorArticleList.tsx:78 msgid "Load more articles" msgstr "Load more articles" @@ -337,6 +380,10 @@ msgstr "Load more notes" msgid "Load more notifications" msgstr "Load more notifications" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:517 +msgid "Load more passkeys" +msgstr "Load more passkeys" + #: src/components/ActorPostList.tsx:76 #: src/components/ActorSharedPostList.tsx:78 msgid "Load more posts" @@ -358,6 +405,10 @@ msgstr "Loading more notes…" msgid "Loading more notifications" msgstr "Loading more notifications" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:514 +msgid "Loading more passkeys…" +msgstr "Loading more passkeys…" + #: src/components/ActorPostList.tsx:70 #: src/components/ActorSharedPostList.tsx:72 msgid "Loading more posts…" @@ -385,6 +436,10 @@ msgstr "Name is required." msgid "Name is too long. Maximum length is 50 characters." msgstr "Name is too long. Maximum length is 50 characters." +#: src/routes/(root)/[handle]/settings/passkeys.tsx:477 +msgid "Never used" +msgstr "Never used" + #: src/components/ActorFollowerList.tsx:90 msgid "No followers found" msgstr "No followers found" @@ -406,7 +461,7 @@ msgstr "No posts found" #~ msgid "No posts found." #~ msgstr "No posts found." -#: src/routes/(root)/sign/index.tsx:198 +#: src/routes/(root)/sign/index.tsx:238 msgid "No such account in Hackers' Pub—please try again." msgstr "No such account in Hackers' Pub—please try again." @@ -415,7 +470,11 @@ msgstr "No such account in Hackers' Pub—please try again." msgid "Notes" msgstr "Notes" -#: src/routes/(root)/sign/index.tsx:299 +#: src/routes/(root)/sign/index.tsx:406 +msgid "Or" +msgstr "Or" + +#: src/routes/(root)/sign/index.tsx:436 msgid "Or enter the code from the email" msgstr "Or enter the code from the email" @@ -423,6 +482,31 @@ msgstr "Or enter the code from the email" msgid "Page Not Found" msgstr "Page Not Found" +#: src/routes/(root)/sign/index.tsx:346 +msgid "Passkey authentication failed" +msgstr "Passkey authentication failed" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:404 +msgid "Passkey name" +msgstr "Passkey name" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:278 +msgid "Passkey registered successfully" +msgstr "Passkey registered successfully" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:325 +msgid "Passkey revoked" +msgstr "Passkey revoked" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:374 +#: src/routes/(root)/[handle]/settings/passkeys.tsx:385 +msgid "passkeys" +msgstr "passkeys" + +#: src/components/SettingsTabs.tsx:51 +msgid "Passkeys" +msgstr "Passkeys" + #: src/routes/(root)/[handle]/settings/index.tsx:143 msgid "Please choose an image file smaller than 5 MiB." msgstr "Please choose an image file smaller than 5 MiB." @@ -464,6 +548,35 @@ msgstr "Read full article" msgid "Read the full Code of conduct" msgstr "Read the full Code of conduct" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:420 +msgid "Register" +msgstr "Register" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:396 +msgid "Register a passkey" +msgstr "Register a passkey" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:398 +msgid "Register a passkey to sign in to your account. You can use a passkey instead of receiving a sign-in link by email." +msgstr "Register a passkey to sign in to your account. You can use a passkey instead of receiving a sign-in link by email." + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:427 +msgid "Registered passkeys" +msgstr "Registered passkeys" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:420 +msgid "Registering…" +msgstr "Registering…" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:492 +#: src/routes/(root)/[handle]/settings/passkeys.tsx:548 +msgid "Revoke" +msgstr "Revoke" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:537 +msgid "Revoke passkey" +msgstr "Revoke passkey" + #: src/routes/(root)/[handle]/settings/index.tsx:408 #: src/routes/(root)/[handle]/settings/preferences.tsx:243 msgid "Save" @@ -476,6 +589,7 @@ msgstr "Saving…" #: src/components/AppSidebar.tsx:288 #: src/routes/(root)/[handle]/settings/index.tsx:261 +#: src/routes/(root)/[handle]/settings/passkeys.tsx:379 #: src/routes/(root)/[handle]/settings/preferences.tsx:181 msgid "Settings" msgstr "Settings" @@ -486,10 +600,14 @@ msgid "Shares" msgstr "Shares" #: src/components/AppSidebar.tsx:241 -#: src/routes/(root)/sign/index.tsx:282 +#: src/routes/(root)/sign/index.tsx:396 msgid "Sign in" msgstr "Sign in" +#: src/routes/(root)/sign/index.tsx:420 +msgid "Sign in with passkey" +msgstr "Sign in with passkey" + #: src/components/AppSidebar.tsx:310 msgid "Sign out" msgstr "Sign out" @@ -498,11 +616,11 @@ msgstr "Sign out" msgid "Sign up" msgstr "Sign up" -#: src/routes/(root)/sign/index.tsx:249 +#: src/routes/(root)/sign/index.tsx:363 msgid "Signing in Hackers' Pub" msgstr "Signing in Hackers' Pub" -#: src/routes/(root)/sign/index.tsx:282 +#: src/routes/(root)/sign/index.tsx:396 msgid "Signing in…" msgstr "Signing in…" @@ -510,7 +628,7 @@ msgstr "Signing in…" msgid "Signing up for Hackers' Pub" msgstr "Signing up for Hackers' Pub" -#: src/routes/(root)/sign/index.tsx:203 +#: src/routes/(root)/sign/index.tsx:243 msgid "Something went wrong—please try again." msgstr "Something went wrong—please try again." @@ -542,6 +660,14 @@ msgstr "The default privacy setting for your notes." msgid "The default privacy setting for your shares." msgstr "The default privacy setting for your shares." +#: src/routes/(root)/[handle]/settings/passkeys.tsx:429 +msgid "The following passkeys are registered to your account. You can use them to sign in to your account." +msgstr "The following passkeys are registered to your account. You can use them to sign in to your account." + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:326 +msgid "The passkey has been successfully revoked." +msgstr "The passkey has been successfully revoked." + #: src/routes/(root)/sign/up/[token].tsx:322 msgid "The sign-up link is invalid. Please make sure you're using the correct link from the email you received." msgstr "The sign-up link is invalid. Please make sure you're using the correct link from the email you received." @@ -620,6 +746,10 @@ msgstr "Without shares" msgid "You can change it only once, and the old username will become available to others." msgstr "You can change it only once, and the old username will become available to others." +#: src/routes/(root)/[handle]/settings/passkeys.tsx:441 +msgid "You don't have any passkeys registered yet." +msgstr "You don't have any passkeys registered yet." + #: src/routes/(root)/sign/up/[token].tsx:433 msgid "You were invited by" msgstr "You were invited by" @@ -649,6 +779,10 @@ msgstr "Your email address will be used to sign in to your account." msgid "Your name will be displayed on your profile and in your posts." msgstr "Your name will be displayed on your profile and in your posts." +#: src/routes/(root)/[handle]/settings/passkeys.tsx:280 +msgid "Your passkey has been registered and can now be used for authentication." +msgstr "Your passkey has been registered and can now be used for authentication." + #: src/routes/(root)/[handle]/settings/preferences.tsx:135 msgid "Your preferences have been updated successfully." msgstr "Your preferences have been updated successfully." diff --git a/web-next/src/locales/ja-JP/messages.po b/web-next/src/locales/ja-JP/messages.po index b8aa5b4b..3fc8ae75 100644 --- a/web-next/src/locales/ja-JP/messages.po +++ b/web-next/src/locales/ja-JP/messages.po @@ -128,7 +128,7 @@ msgstr "{0}さんの投稿" msgid "{0}'s shares" msgstr "{0}さんの共有" -#: src/routes/(root)/sign/index.tsx:193 +#: src/routes/(root)/sign/index.tsx:233 msgid "A sign-in link has been sent to your email. Please check your inbox (or spam folder)." msgstr "ログインリンクがメールに送信されました。受信トレイ(または迷惑メールフォルダ)を確認してください。" @@ -140,6 +140,14 @@ msgstr "アカウント" msgid "An error occurred during signup. Please try again." msgstr "登録中にエラーが発生しました。もう一度お試しください。" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:293 +msgid "An error occurred while registering your passkey." +msgstr "パスキーの登録中にエラーが発生しました。" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:341 +msgid "An error occurred while revoking your passkey." +msgstr "パスキーの取消中にエラーが発生しました。" + #: src/routes/(root)/[handle]/settings/preferences.tsx:144 msgid "An error occurred while saving your preferences. Please try again, or contact support if the problem persists." msgstr "環境設定の保存中にエラーが発生しました。再度お試しいただくか、問題が解決しない場合はサポートにお問い合わせください。" @@ -148,6 +156,11 @@ msgstr "環境設定の保存中にエラーが発生しました。再度お試 msgid "An error occurred while saving your settings. Please try again, or contact support if the problem persists." msgstr "設定の保存中にエラーが発生しました。再度お試しいただくか、問題が解決しない場合はサポートにお問い合わせください。" +#. placeholder {0}: passkeyToRevoke()?.name +#: src/routes/(root)/[handle]/settings/passkeys.tsx:539 +msgid "Are you sure you want to revoke passkey {0}? You won't be able to use it to sign in to your account anymore." +msgstr "パスキー{0}を取り消しますか?このパスキーを使用してアカウントにログインできなくなります。" + #: src/components/ProfileTabs.tsx:48 #: src/routes/(root)/[handle]/(profile)/articles.tsx:84 msgid "Articles" @@ -162,6 +175,10 @@ msgstr "記事のみ" msgid "As you have already changed it {0}, you can't change it again." msgstr "すでに{0}に変更済みのため、再度変更することはできません。" +#: src/routes/(root)/sign/index.tsx:419 +msgid "Authenticating…" +msgstr "認証中…" + #: src/routes/(root)/[handle]/settings/index.tsx:275 msgid "Avatar" msgstr "アイコン" @@ -176,6 +193,7 @@ msgid "Bio is too long. Maximum length is 512 characters." msgstr "自己紹介が長すぎます。最大512文字です。" #: src/routes/(root)/[handle]/settings/index.tsx:323 +#: src/routes/(root)/[handle]/settings/passkeys.tsx:543 msgid "Cancel" msgstr "キャンセル" @@ -186,6 +204,10 @@ msgstr "キャンセル" msgid "Code of conduct" msgstr "行動規範" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:462 +msgid "Created:" +msgstr "作成日:" + #: src/routes/(root)/sign/up/[token].tsx:485 msgid "Creating account…" msgstr "アカウントを作成中…" @@ -211,7 +233,7 @@ msgstr "共有のデフォルト公開範囲" msgid "Display name" msgstr "名前" -#: src/routes/(root)/sign/index.tsx:288 +#: src/routes/(root)/sign/index.tsx:425 msgid "Do you need an account? Hackers' Pub is invite-only—please ask a friend to invite you." msgstr "アカウントが必要ですか?Hackers' Pubは招待制ですので、友人に招待をお願いしてください。" @@ -223,11 +245,11 @@ msgstr "保持したい領域をドラッグして選択し、「切り抜き」 msgid "Email address" msgstr "メールアドレス" -#: src/routes/(root)/sign/index.tsx:266 +#: src/routes/(root)/sign/index.tsx:380 msgid "Email or username" msgstr "メールアドレスまたはユーザー名" -#: src/routes/(root)/sign/index.tsx:201 +#: src/routes/(root)/sign/index.tsx:241 msgid "Enter your email or username below to sign in." msgstr "以下にメールアドレスまたはユーザー名を入力してログインしてください。" @@ -235,6 +257,10 @@ msgstr "以下にメールアドレスまたはユーザー名を入力してロ msgid "Error" msgstr "エラー" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:409 +msgid "ex) My key" +msgstr "例) 私のキー" + #: src/components/ActorArticleList.tsx:75 msgid "Failed to load more articles; click to retry" msgstr "記事の読み込みに失敗しました。クリックして再試行してください" @@ -251,11 +277,24 @@ msgstr "投稿の読み込みに失敗しました。クリックして再試行 msgid "Failed to load more notifications; click to retry" msgstr "通知の読み込みに失敗しました。クリックして再試行してください" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:516 +msgid "Failed to load more passkeys; click to retry" +msgstr "パスキーの読み込みに失敗しました。クリックして再試行してください" + #: src/components/ActorPostList.tsx:73 #: src/components/ActorSharedPostList.tsx:75 msgid "Failed to load more posts; click to retry" msgstr "コンテンツの読み込みに失敗しました。クリックして再試行してください" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:290 +msgid "Failed to register passkey" +msgstr "パスキーの登録に失敗しました" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:332 +#: src/routes/(root)/[handle]/settings/passkeys.tsx:338 +msgid "Failed to revoke passkey" +msgstr "パスキーの取り消しに失敗しました" + #: src/routes/(root)/[handle]/settings/preferences.tsx:142 msgid "Failed to save preferences" msgstr "環境設定の保存に失敗しました" @@ -321,6 +360,10 @@ msgstr "有効にすると、AIが記事の要約を生成します。無効の msgid "John Doe" msgstr "田中太郎" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:471 +msgid "Last used:" +msgstr "最終使用:" + #: src/components/ActorArticleList.tsx:78 msgid "Load more articles" msgstr "記事をもっと読み込む" @@ -337,6 +380,10 @@ msgstr "投稿をもっと読み込む" msgid "Load more notifications" msgstr "通知をもっと読み込む" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:517 +msgid "Load more passkeys" +msgstr "パスキーを読み込む" + #: src/components/ActorPostList.tsx:76 #: src/components/ActorSharedPostList.tsx:78 msgid "Load more posts" @@ -358,6 +405,10 @@ msgstr "投稿を読み込み中…" msgid "Loading more notifications" msgstr "通知を読み込み中…" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:514 +msgid "Loading more passkeys…" +msgstr "パスキーを読み込み中…" + #: src/components/ActorPostList.tsx:70 #: src/components/ActorSharedPostList.tsx:72 msgid "Loading more posts…" @@ -385,6 +436,10 @@ msgstr "名前は必須です。" msgid "Name is too long. Maximum length is 50 characters." msgstr "名前が長すぎます。最大50文字です。" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:477 +msgid "Never used" +msgstr "使用履歴なし" + #: src/components/ActorFollowerList.tsx:90 msgid "No followers found" msgstr "フォロワーはいません" @@ -406,7 +461,7 @@ msgstr "コンテンツはありません" #~ msgid "No posts found." #~ msgstr "コンテンツが見つかりませんでした。" -#: src/routes/(root)/sign/index.tsx:198 +#: src/routes/(root)/sign/index.tsx:238 msgid "No such account in Hackers' Pub—please try again." msgstr "Hackers' Pubにそのようなアカウントはありません。もう一度お試しください。" @@ -415,7 +470,11 @@ msgstr "Hackers' Pubにそのようなアカウントはありません。もう msgid "Notes" msgstr "投稿" -#: src/routes/(root)/sign/index.tsx:299 +#: src/routes/(root)/sign/index.tsx:406 +msgid "Or" +msgstr "または" + +#: src/routes/(root)/sign/index.tsx:436 msgid "Or enter the code from the email" msgstr "またはメールのコードを入力してください" @@ -423,6 +482,31 @@ msgstr "またはメールのコードを入力してください" msgid "Page Not Found" msgstr "ページが見つかりません" +#: src/routes/(root)/sign/index.tsx:346 +msgid "Passkey authentication failed" +msgstr "パスキー認証に失敗しました" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:404 +msgid "Passkey name" +msgstr "パスキー名" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:278 +msgid "Passkey registered successfully" +msgstr "パスキーの登録に成功しました" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:325 +msgid "Passkey revoked" +msgstr "パスキーを取り消しました" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:374 +#: src/routes/(root)/[handle]/settings/passkeys.tsx:385 +msgid "passkeys" +msgstr "パスキー" + +#: src/components/SettingsTabs.tsx:51 +msgid "Passkeys" +msgstr "パスキー" + #: src/routes/(root)/[handle]/settings/index.tsx:143 msgid "Please choose an image file smaller than 5 MiB." msgstr "5 MiB未満の画像ファイルを選択してください。" @@ -464,6 +548,35 @@ msgstr "記事全文を読む" msgid "Read the full Code of conduct" msgstr "行動規範の全文を読む" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:420 +msgid "Register" +msgstr "登録" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:396 +msgid "Register a passkey" +msgstr "パスキーを登録" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:398 +msgid "Register a passkey to sign in to your account. You can use a passkey instead of receiving a sign-in link by email." +msgstr "アカウントにパスキーを登録してください。メールでログインリンクを受け取る代わりにパスキーを使用できます。" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:427 +msgid "Registered passkeys" +msgstr "登録済みパスキー" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:420 +msgid "Registering…" +msgstr "登録中…" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:492 +#: src/routes/(root)/[handle]/settings/passkeys.tsx:548 +msgid "Revoke" +msgstr "取り消す" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:537 +msgid "Revoke passkey" +msgstr "パスキーを取り消す" + #: src/routes/(root)/[handle]/settings/index.tsx:408 #: src/routes/(root)/[handle]/settings/preferences.tsx:243 msgid "Save" @@ -476,6 +589,7 @@ msgstr "保存中…" #: src/components/AppSidebar.tsx:288 #: src/routes/(root)/[handle]/settings/index.tsx:261 +#: src/routes/(root)/[handle]/settings/passkeys.tsx:379 #: src/routes/(root)/[handle]/settings/preferences.tsx:181 msgid "Settings" msgstr "設定" @@ -486,10 +600,14 @@ msgid "Shares" msgstr "共有" #: src/components/AppSidebar.tsx:241 -#: src/routes/(root)/sign/index.tsx:282 +#: src/routes/(root)/sign/index.tsx:396 msgid "Sign in" msgstr "ログイン" +#: src/routes/(root)/sign/index.tsx:420 +msgid "Sign in with passkey" +msgstr "パスキーでサインイン" + #: src/components/AppSidebar.tsx:310 msgid "Sign out" msgstr "ログアウト" @@ -498,11 +616,11 @@ msgstr "ログアウト" msgid "Sign up" msgstr "登録" -#: src/routes/(root)/sign/index.tsx:249 +#: src/routes/(root)/sign/index.tsx:363 msgid "Signing in Hackers' Pub" msgstr "Hackers' Pubにログイン" -#: src/routes/(root)/sign/index.tsx:282 +#: src/routes/(root)/sign/index.tsx:396 msgid "Signing in…" msgstr "ログイン中…" @@ -510,7 +628,7 @@ msgstr "ログイン中…" msgid "Signing up for Hackers' Pub" msgstr "Hackers' Pubに登録" -#: src/routes/(root)/sign/index.tsx:203 +#: src/routes/(root)/sign/index.tsx:243 msgid "Something went wrong—please try again." msgstr "問題が発生しました。再度お試しください。" @@ -542,6 +660,14 @@ msgstr "投稿のデフォルト公開設定です。" msgid "The default privacy setting for your shares." msgstr "共有のデフォルト公開設定です。" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:429 +msgid "The following passkeys are registered to your account. You can use them to sign in to your account." +msgstr "以下のパスキーがあなたのアカウントに登録されています。これらを使用してアカウントにログインできます。" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:326 +msgid "The passkey has been successfully revoked." +msgstr "パスキーを正常に取り消しました。" + #: src/routes/(root)/sign/up/[token].tsx:322 msgid "The sign-up link is invalid. Please make sure you're using the correct link from the email you received." msgstr "登録リンクが無効です。受信したメールのリンクを正しく使用しているかご確認ください。" @@ -620,6 +746,10 @@ msgstr "共有除外" msgid "You can change it only once, and the old username will become available to others." msgstr "変更は1回のみ可能で、変更前のユーザー名は他のユーザーが使用できるようになります。" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:441 +msgid "You don't have any passkeys registered yet." +msgstr "まだパスキーが登録されていません。" + #: src/routes/(root)/sign/up/[token].tsx:433 msgid "You were invited by" msgstr "あなたを招待した方" @@ -649,6 +779,10 @@ msgstr "メールアドレスはアカウントへのログインに使用され msgid "Your name will be displayed on your profile and in your posts." msgstr "名前はプロフィールとコンテンツに表示されます。" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:280 +msgid "Your passkey has been registered and can now be used for authentication." +msgstr "パスキーが登録され、認証に使用できるようになりました。" + #: src/routes/(root)/[handle]/settings/preferences.tsx:135 msgid "Your preferences have been updated successfully." msgstr "環境設定が正常に更新されました。" diff --git a/web-next/src/locales/ko-KR/messages.po b/web-next/src/locales/ko-KR/messages.po index 71a192cb..edf7b8a3 100644 --- a/web-next/src/locales/ko-KR/messages.po +++ b/web-next/src/locales/ko-KR/messages.po @@ -128,7 +128,7 @@ msgstr "{0} 님의 단문" msgid "{0}'s shares" msgstr "{0} 님의 공유" -#: src/routes/(root)/sign/index.tsx:193 +#: src/routes/(root)/sign/index.tsx:233 msgid "A sign-in link has been sent to your email. Please check your inbox (or spam folder)." msgstr "로그인 링크가 이메일로 전송되었습니다. 받은 편지함(또는 스팸 폴더)을 확인해주세요." @@ -140,6 +140,14 @@ msgstr "계정" msgid "An error occurred during signup. Please try again." msgstr "가입중에 오류가 발생했습니다. 다시 시도해 주시기 바랍니다." +#: src/routes/(root)/[handle]/settings/passkeys.tsx:293 +msgid "An error occurred while registering your passkey." +msgstr "패스키를 등록하는 중에 오류가 발생했습니다." + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:341 +msgid "An error occurred while revoking your passkey." +msgstr "패스키를 취소하는 중에 오류가 발생했습니다." + #: src/routes/(root)/[handle]/settings/preferences.tsx:144 msgid "An error occurred while saving your preferences. Please try again, or contact support if the problem persists." msgstr "환경 설정 저장 중 오류가 발생했습니다. 다시 시도하거나 문제가 지속되면 지원팀에 문의해 주세요." @@ -148,6 +156,11 @@ msgstr "환경 설정 저장 중 오류가 발생했습니다. 다시 시도하 msgid "An error occurred while saving your settings. Please try again, or contact support if the problem persists." msgstr "설정 저장 중 오류가 발생했습니다. 다시 시도하거나 문제가 지속되면 지원팀에 문의하세요." +#. placeholder {0}: passkeyToRevoke()?.name +#: src/routes/(root)/[handle]/settings/passkeys.tsx:539 +msgid "Are you sure you want to revoke passkey {0}? You won't be able to use it to sign in to your account anymore." +msgstr "{0} 패스키를 취소하시겠습니까? 이 패스키를 사용하여 계정에 로그인할 수 없게 됩니다." + #: src/components/ProfileTabs.tsx:48 #: src/routes/(root)/[handle]/(profile)/articles.tsx:84 msgid "Articles" @@ -162,6 +175,10 @@ msgstr "게시글만" msgid "As you have already changed it {0}, you can't change it again." msgstr "이미 {0} 변경하였기 때문에 다시 변경할 수 없습니다." +#: src/routes/(root)/sign/index.tsx:419 +msgid "Authenticating…" +msgstr "인증중…" + #: src/routes/(root)/[handle]/settings/index.tsx:275 msgid "Avatar" msgstr "프로필 사진" @@ -176,6 +193,7 @@ msgid "Bio is too long. Maximum length is 512 characters." msgstr "약력이 너무 깁니다. 최대 길이는 512자입니다." #: src/routes/(root)/[handle]/settings/index.tsx:323 +#: src/routes/(root)/[handle]/settings/passkeys.tsx:543 msgid "Cancel" msgstr "취소" @@ -186,6 +204,10 @@ msgstr "취소" msgid "Code of conduct" msgstr "행동 강령" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:462 +msgid "Created:" +msgstr "생성일:" + #: src/routes/(root)/sign/up/[token].tsx:485 msgid "Creating account…" msgstr "계정을 생성하는 중…" @@ -211,7 +233,7 @@ msgstr "기본 공유 공개 범위" msgid "Display name" msgstr "이름" -#: src/routes/(root)/sign/index.tsx:288 +#: src/routes/(root)/sign/index.tsx:425 msgid "Do you need an account? Hackers' Pub is invite-only—please ask a friend to invite you." msgstr "계정이 필요하신가요? Hackers' Pub은 초대 전용입니다. 친구에게 초대를 요청해주세요." @@ -223,11 +245,11 @@ msgstr "유지하려는 영역을 드래그하여 선택한 다음 “자르기 msgid "Email address" msgstr "이메일 주소" -#: src/routes/(root)/sign/index.tsx:266 +#: src/routes/(root)/sign/index.tsx:380 msgid "Email or username" msgstr "이메일 또는 아이디" -#: src/routes/(root)/sign/index.tsx:201 +#: src/routes/(root)/sign/index.tsx:241 msgid "Enter your email or username below to sign in." msgstr "로그인하려면 아래에 이메일 또는 아이디를 입력해주세요." @@ -235,6 +257,10 @@ msgstr "로그인하려면 아래에 이메일 또는 아이디를 입력해주 msgid "Error" msgstr "오류" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:409 +msgid "ex) My key" +msgstr "예) 나의 키" + #: src/components/ActorArticleList.tsx:75 msgid "Failed to load more articles; click to retry" msgstr "게시글 불러오기 실패. 클릭하여 재시도하세요" @@ -251,11 +277,24 @@ msgstr "단문 불러오기 실패. 클릭하여 재시도하세요" msgid "Failed to load more notifications; click to retry" msgstr "알림을 더 불러오지 못했습니다. 클릭해서 다시 시도하세요" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:516 +msgid "Failed to load more passkeys; click to retry" +msgstr "패스키를 더 불러오지 못했습니다. 클릭해서 다시 시도하세요" + #: src/components/ActorPostList.tsx:73 #: src/components/ActorSharedPostList.tsx:75 msgid "Failed to load more posts; click to retry" msgstr "콘텐츠 불러오기 실패. 클릭하여 재시도하세요" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:290 +msgid "Failed to register passkey" +msgstr "패스키 등록 실패" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:332 +#: src/routes/(root)/[handle]/settings/passkeys.tsx:338 +msgid "Failed to revoke passkey" +msgstr "패스키 취소 실패" + #: src/routes/(root)/[handle]/settings/preferences.tsx:142 msgid "Failed to save preferences" msgstr "환경 설정 저장 실패" @@ -321,6 +360,10 @@ msgstr "활성화하면 AI가 글의 요약을 생성합니다. 비활성화 시 msgid "John Doe" msgstr "홍길동" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:471 +msgid "Last used:" +msgstr "마지막 사용:" + #: src/components/ActorArticleList.tsx:78 msgid "Load more articles" msgstr "게시글 더 불러오기" @@ -337,6 +380,10 @@ msgstr "단문 더 불러오기" msgid "Load more notifications" msgstr "알림 더 불러오기" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:517 +msgid "Load more passkeys" +msgstr "패스키 더 불러오기" + #: src/components/ActorPostList.tsx:76 #: src/components/ActorSharedPostList.tsx:78 msgid "Load more posts" @@ -358,6 +405,10 @@ msgstr "단문 불러오는 중…" msgid "Loading more notifications" msgstr "알림을 더 불러오는 중…" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:514 +msgid "Loading more passkeys…" +msgstr "패스키를 더 불러오는 중…" + #: src/components/ActorPostList.tsx:70 #: src/components/ActorSharedPostList.tsx:72 msgid "Loading more posts…" @@ -385,6 +436,10 @@ msgstr "이름은 필수입니다." msgid "Name is too long. Maximum length is 50 characters." msgstr "이름이 너무 깁니다. 최대 길이는 50자입니다." +#: src/routes/(root)/[handle]/settings/passkeys.tsx:477 +msgid "Never used" +msgstr "사용된 적 없음" + #: src/components/ActorFollowerList.tsx:90 msgid "No followers found" msgstr "팔로워가 없습니다" @@ -406,7 +461,7 @@ msgstr "콘텐츠가 없습니다" #~ msgid "No posts found." #~ msgstr "콘텐츠를 찾을 수 없습니다." -#: src/routes/(root)/sign/index.tsx:198 +#: src/routes/(root)/sign/index.tsx:238 msgid "No such account in Hackers' Pub—please try again." msgstr "Hackers' Pub에 해당 계정이 없습니다. 다시 시도해주세요." @@ -415,7 +470,11 @@ msgstr "Hackers' Pub에 해당 계정이 없습니다. 다시 시도해주세요 msgid "Notes" msgstr "단문" -#: src/routes/(root)/sign/index.tsx:299 +#: src/routes/(root)/sign/index.tsx:406 +msgid "Or" +msgstr "또는" + +#: src/routes/(root)/sign/index.tsx:436 msgid "Or enter the code from the email" msgstr "또는 이메일의 코드를 입력하세요" @@ -423,6 +482,31 @@ msgstr "또는 이메일의 코드를 입력하세요" msgid "Page Not Found" msgstr "페이지를 찾을 수 없습니다" +#: src/routes/(root)/sign/index.tsx:346 +msgid "Passkey authentication failed" +msgstr "패스키 인증 실패" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:404 +msgid "Passkey name" +msgstr "패스키 이름" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:278 +msgid "Passkey registered successfully" +msgstr "패스키 등록 성공" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:325 +msgid "Passkey revoked" +msgstr "패스키 취소됨" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:374 +#: src/routes/(root)/[handle]/settings/passkeys.tsx:385 +msgid "passkeys" +msgstr "패스키" + +#: src/components/SettingsTabs.tsx:51 +msgid "Passkeys" +msgstr "패스키" + #: src/routes/(root)/[handle]/settings/index.tsx:143 msgid "Please choose an image file smaller than 5 MiB." msgstr "5 MiB 미만의 이미지 파일을 선택해주세요." @@ -464,6 +548,35 @@ msgstr "게시글 전체 읽기" msgid "Read the full Code of conduct" msgstr "행동 강령 전문을 읽으세요." +#: src/routes/(root)/[handle]/settings/passkeys.tsx:420 +msgid "Register" +msgstr "등록" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:396 +msgid "Register a passkey" +msgstr "패스키 등록" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:398 +msgid "Register a passkey to sign in to your account. You can use a passkey instead of receiving a sign-in link by email." +msgstr "계정에 패스키를 등록하세요. 이메일로 로그인 링크를 받는 대신 패스키를 사용할 수 있습니다." + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:427 +msgid "Registered passkeys" +msgstr "등록된 패스키" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:420 +msgid "Registering…" +msgstr "등록중…" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:492 +#: src/routes/(root)/[handle]/settings/passkeys.tsx:548 +msgid "Revoke" +msgstr "취소" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:537 +msgid "Revoke passkey" +msgstr "패스키를 취소" + #: src/routes/(root)/[handle]/settings/index.tsx:408 #: src/routes/(root)/[handle]/settings/preferences.tsx:243 msgid "Save" @@ -476,6 +589,7 @@ msgstr "저장 중…" #: src/components/AppSidebar.tsx:288 #: src/routes/(root)/[handle]/settings/index.tsx:261 +#: src/routes/(root)/[handle]/settings/passkeys.tsx:379 #: src/routes/(root)/[handle]/settings/preferences.tsx:181 msgid "Settings" msgstr "설정" @@ -486,10 +600,14 @@ msgid "Shares" msgstr "공유" #: src/components/AppSidebar.tsx:241 -#: src/routes/(root)/sign/index.tsx:282 +#: src/routes/(root)/sign/index.tsx:396 msgid "Sign in" msgstr "로그인" +#: src/routes/(root)/sign/index.tsx:420 +msgid "Sign in with passkey" +msgstr "패스키를 사용하여 로그인" + #: src/components/AppSidebar.tsx:310 msgid "Sign out" msgstr "로그아웃" @@ -498,11 +616,11 @@ msgstr "로그아웃" msgid "Sign up" msgstr "가입" -#: src/routes/(root)/sign/index.tsx:249 +#: src/routes/(root)/sign/index.tsx:363 msgid "Signing in Hackers' Pub" msgstr "Hackers' Pub에 로그인" -#: src/routes/(root)/sign/index.tsx:282 +#: src/routes/(root)/sign/index.tsx:396 msgid "Signing in…" msgstr "로그인 중…" @@ -510,7 +628,7 @@ msgstr "로그인 중…" msgid "Signing up for Hackers' Pub" msgstr "Hackers' Pub 가입" -#: src/routes/(root)/sign/index.tsx:203 +#: src/routes/(root)/sign/index.tsx:243 msgid "Something went wrong—please try again." msgstr "문제가 발생했습니다. 다시 시도해주세요." @@ -542,6 +660,14 @@ msgstr "단문의 기본 공개 설정입니다." msgid "The default privacy setting for your shares." msgstr "공유의 기본 공개 설정입니다." +#: src/routes/(root)/[handle]/settings/passkeys.tsx:429 +msgid "The following passkeys are registered to your account. You can use them to sign in to your account." +msgstr "다음 패스키들이 계정에 등록되어 있습니다. 이들을 사용하여 계정에 로그인할 수 있습니다." + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:326 +msgid "The passkey has been successfully revoked." +msgstr "성공적으로 패스키를 취소했습니다." + #: src/routes/(root)/sign/up/[token].tsx:322 msgid "The sign-up link is invalid. Please make sure you're using the correct link from the email you received." msgstr "가입 링크가 유효하지 않습니다. 이메일로 받은 링크를 사용하고 있는지 확인해주세요." @@ -620,6 +746,10 @@ msgstr "공유 제외" msgid "You can change it only once, and the old username will become available to others." msgstr "아이디는 단 한 번만 변경할 수 있으며, 변경하기 전 아이디는 다른 사람이 사용할 수 있게 됩니다." +#: src/routes/(root)/[handle]/settings/passkeys.tsx:441 +msgid "You don't have any passkeys registered yet." +msgstr "등록된 패스키가 아직 없습니다." + #: src/routes/(root)/sign/up/[token].tsx:433 msgid "You were invited by" msgstr "당신을 초대한 분" @@ -649,6 +779,10 @@ msgstr "이메일 주소는 계정에 로그인할 때 사용됩니다." msgid "Your name will be displayed on your profile and in your posts." msgstr "이름은 프로필과 콘텐츠에 표시됩니다." +#: src/routes/(root)/[handle]/settings/passkeys.tsx:280 +msgid "Your passkey has been registered and can now be used for authentication." +msgstr "패스키가 등록되었습니다. 이제 이 패스키를 사용하여 로그인 할 수 있습니다." + #: src/routes/(root)/[handle]/settings/preferences.tsx:135 msgid "Your preferences have been updated successfully." msgstr "환경 설정이 성공적으로 업데이트되었습니다." diff --git a/web-next/src/locales/zh-CN/messages.po b/web-next/src/locales/zh-CN/messages.po index ca4fdf88..8af66489 100644 --- a/web-next/src/locales/zh-CN/messages.po +++ b/web-next/src/locales/zh-CN/messages.po @@ -128,7 +128,7 @@ msgstr "{0}的帖子" msgid "{0}'s shares" msgstr "{0}的转帖" -#: src/routes/(root)/sign/index.tsx:193 +#: src/routes/(root)/sign/index.tsx:233 msgid "A sign-in link has been sent to your email. Please check your inbox (or spam folder)." msgstr "登录链接已发送至您的邮箱。请检查收件箱。(或垃圾邮件文件夹)" @@ -140,6 +140,14 @@ msgstr "账户" msgid "An error occurred during signup. Please try again." msgstr "注册过程中发生错误。请重新尝试。" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:293 +msgid "An error occurred while registering your passkey." +msgstr "在注册您的通行密钥时发生错误。" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:341 +msgid "An error occurred while revoking your passkey." +msgstr "在撤销您的通行密钥时发生错误。" + #: src/routes/(root)/[handle]/settings/preferences.tsx:144 msgid "An error occurred while saving your preferences. Please try again, or contact support if the problem persists." msgstr "保存设置时出现错误。请重试,如果问题持续存在,请联系支持团队。" @@ -148,6 +156,11 @@ msgstr "保存设置时出现错误。请重试,如果问题持续存在,请 msgid "An error occurred while saving your settings. Please try again, or contact support if the problem persists." msgstr "保存设置时发生错误。请重试,如果问题仍然存在,请联系客服。" +#. placeholder {0}: passkeyToRevoke()?.name +#: src/routes/(root)/[handle]/settings/passkeys.tsx:539 +msgid "Are you sure you want to revoke passkey {0}? You won't be able to use it to sign in to your account anymore." +msgstr "您确定要撤销通行密钥“{0}”吗?您将无法再使用它登录您的账户。" + #: src/components/ProfileTabs.tsx:48 #: src/routes/(root)/[handle]/(profile)/articles.tsx:84 msgid "Articles" @@ -162,6 +175,10 @@ msgstr "仅文章" msgid "As you have already changed it {0}, you can't change it again." msgstr "自打你已经把用户名换成 {0} 了,你再也改不了了。" +#: src/routes/(root)/sign/index.tsx:419 +msgid "Authenticating…" +msgstr "正在验证…" + #: src/routes/(root)/[handle]/settings/index.tsx:275 msgid "Avatar" msgstr "头像" @@ -176,6 +193,7 @@ msgid "Bio is too long. Maximum length is 512 characters." msgstr "个人简介太长。不能长于 512 字符。" #: src/routes/(root)/[handle]/settings/index.tsx:323 +#: src/routes/(root)/[handle]/settings/passkeys.tsx:543 msgid "Cancel" msgstr "取消" @@ -186,6 +204,10 @@ msgstr "取消" msgid "Code of conduct" msgstr "行为准则" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:462 +msgid "Created:" +msgstr "创建于:" + #: src/routes/(root)/sign/up/[token].tsx:485 msgid "Creating account…" msgstr "正在创建账户…" @@ -211,7 +233,7 @@ msgstr "默认转帖隐私设置" msgid "Display name" msgstr "昵称" -#: src/routes/(root)/sign/index.tsx:288 +#: src/routes/(root)/sign/index.tsx:425 msgid "Do you need an account? Hackers' Pub is invite-only—please ask a friend to invite you." msgstr "需要创建账户吗?Hackers' Pub 仅限邀请,请联系朋友邀请您。" @@ -223,11 +245,11 @@ msgstr "拖动选择要保留的区域,然后点击「裁剪」来更新你的 msgid "Email address" msgstr "电子邮件地址" -#: src/routes/(root)/sign/index.tsx:266 +#: src/routes/(root)/sign/index.tsx:380 msgid "Email or username" msgstr "邮箱或用户名" -#: src/routes/(root)/sign/index.tsx:201 +#: src/routes/(root)/sign/index.tsx:241 msgid "Enter your email or username below to sign in." msgstr "请在下方输入您的邮箱或用户名以登录。" @@ -235,6 +257,10 @@ msgstr "请在下方输入您的邮箱或用户名以登录。" msgid "Error" msgstr "错误" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:409 +msgid "ex) My key" +msgstr "例如:我的通行密钥" + #: src/components/ActorArticleList.tsx:75 msgid "Failed to load more articles; click to retry" msgstr "加载更多文章失败,点击重试" @@ -251,11 +277,24 @@ msgstr "加载更多帖子失败,点击重试" msgid "Failed to load more notifications; click to retry" msgstr "加载更多通知失败;点击重试" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:516 +msgid "Failed to load more passkeys; click to retry" +msgstr "加载更多通行密钥失败,点击重试" + #: src/components/ActorPostList.tsx:73 #: src/components/ActorSharedPostList.tsx:75 msgid "Failed to load more posts; click to retry" msgstr "加载更多内容失败,点击重试" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:290 +msgid "Failed to register passkey" +msgstr "注册通行密钥失败" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:332 +#: src/routes/(root)/[handle]/settings/passkeys.tsx:338 +msgid "Failed to revoke passkey" +msgstr "撤销通行密钥失败" + #: src/routes/(root)/[handle]/settings/preferences.tsx:142 msgid "Failed to save preferences" msgstr "保存设置失败" @@ -321,6 +360,10 @@ msgstr "启用后,AI 将为您生成文章摘要。否则,将使用文章的 msgid "John Doe" msgstr "张三" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:471 +msgid "Last used:" +msgstr "最后使用时间:" + #: src/components/ActorArticleList.tsx:78 msgid "Load more articles" msgstr "加载更多文章" @@ -337,6 +380,10 @@ msgstr "加载更多帖子" msgid "Load more notifications" msgstr "加载更多通知" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:517 +msgid "Load more passkeys" +msgstr "加载更多通行密钥" + #: src/components/ActorPostList.tsx:76 #: src/components/ActorSharedPostList.tsx:78 msgid "Load more posts" @@ -358,6 +405,10 @@ msgstr "加载更多帖子中…" msgid "Loading more notifications" msgstr "正在加载更多通知…" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:514 +msgid "Loading more passkeys…" +msgstr "正在加载更多通行密钥…" + #: src/components/ActorPostList.tsx:70 #: src/components/ActorSharedPostList.tsx:72 msgid "Loading more posts…" @@ -385,6 +436,10 @@ msgstr "需要昵称" msgid "Name is too long. Maximum length is 50 characters." msgstr "用户名太长。不能长于 50 字符。" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:477 +msgid "Never used" +msgstr "从未使用过" + #: src/components/ActorFollowerList.tsx:90 msgid "No followers found" msgstr "未找到粉丝" @@ -406,7 +461,7 @@ msgstr "未找到内容" #~ msgid "No posts found." #~ msgstr "未找到内容。" -#: src/routes/(root)/sign/index.tsx:198 +#: src/routes/(root)/sign/index.tsx:238 msgid "No such account in Hackers' Pub—please try again." msgstr "Hackers' Pub 中无此账户,请重试。" @@ -415,7 +470,11 @@ msgstr "Hackers' Pub 中无此账户,请重试。" msgid "Notes" msgstr "帖子" -#: src/routes/(root)/sign/index.tsx:299 +#: src/routes/(root)/sign/index.tsx:406 +msgid "Or" +msgstr "或" + +#: src/routes/(root)/sign/index.tsx:436 msgid "Or enter the code from the email" msgstr "或输入邮件中的验证码" @@ -423,6 +482,31 @@ msgstr "或输入邮件中的验证码" msgid "Page Not Found" msgstr "页面未找到" +#: src/routes/(root)/sign/index.tsx:346 +msgid "Passkey authentication failed" +msgstr "通行密钥验证失败" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:404 +msgid "Passkey name" +msgstr "通行密钥名称" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:278 +msgid "Passkey registered successfully" +msgstr "通行密钥注册成功" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:325 +msgid "Passkey revoked" +msgstr "通行密钥已撤销" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:374 +#: src/routes/(root)/[handle]/settings/passkeys.tsx:385 +msgid "passkeys" +msgstr "通行密钥" + +#: src/components/SettingsTabs.tsx:51 +msgid "Passkeys" +msgstr "通行密钥" + #: src/routes/(root)/[handle]/settings/index.tsx:143 msgid "Please choose an image file smaller than 5 MiB." msgstr "请选择小于 5 MiB 的图片文件。" @@ -464,6 +548,35 @@ msgstr "阅读完整文章" msgid "Read the full Code of conduct" msgstr "阅读完整的行为准则" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:420 +msgid "Register" +msgstr "注册" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:396 +msgid "Register a passkey" +msgstr "注册通行密钥" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:398 +msgid "Register a passkey to sign in to your account. You can use a passkey instead of receiving a sign-in link by email." +msgstr "为你的账户注册通行密钥。你可以使用通行密钥登录,而不是通过电子邮件接收登录链接。" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:427 +msgid "Registered passkeys" +msgstr "已注册的通行密钥" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:420 +msgid "Registering…" +msgstr "正在注册…" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:492 +#: src/routes/(root)/[handle]/settings/passkeys.tsx:548 +msgid "Revoke" +msgstr "撤销" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:537 +msgid "Revoke passkey" +msgstr "撤销通行密钥" + #: src/routes/(root)/[handle]/settings/index.tsx:408 #: src/routes/(root)/[handle]/settings/preferences.tsx:243 msgid "Save" @@ -476,6 +589,7 @@ msgstr "保存中…" #: src/components/AppSidebar.tsx:288 #: src/routes/(root)/[handle]/settings/index.tsx:261 +#: src/routes/(root)/[handle]/settings/passkeys.tsx:379 #: src/routes/(root)/[handle]/settings/preferences.tsx:181 msgid "Settings" msgstr "设置" @@ -486,10 +600,14 @@ msgid "Shares" msgstr "转帖" #: src/components/AppSidebar.tsx:241 -#: src/routes/(root)/sign/index.tsx:282 +#: src/routes/(root)/sign/index.tsx:396 msgid "Sign in" msgstr "登录" +#: src/routes/(root)/sign/index.tsx:420 +msgid "Sign in with passkey" +msgstr "使用通行密钥登录" + #: src/components/AppSidebar.tsx:310 msgid "Sign out" msgstr "登出" @@ -498,11 +616,11 @@ msgstr "登出" msgid "Sign up" msgstr "注册" -#: src/routes/(root)/sign/index.tsx:249 +#: src/routes/(root)/sign/index.tsx:363 msgid "Signing in Hackers' Pub" msgstr "登录 Hackers' Pub" -#: src/routes/(root)/sign/index.tsx:282 +#: src/routes/(root)/sign/index.tsx:396 msgid "Signing in…" msgstr "登录中…" @@ -510,7 +628,7 @@ msgstr "登录中…" msgid "Signing up for Hackers' Pub" msgstr "注册 Hackers' Pub" -#: src/routes/(root)/sign/index.tsx:203 +#: src/routes/(root)/sign/index.tsx:243 msgid "Something went wrong—please try again." msgstr "出现错误,请重试。" @@ -542,6 +660,14 @@ msgstr "您帖子的默认隐私设置。" msgid "The default privacy setting for your shares." msgstr "您转帖的默认隐私设置。" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:429 +msgid "The following passkeys are registered to your account. You can use them to sign in to your account." +msgstr "以下通行密钥已注册到你的账户。你可以使用它们登录你的账户。" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:326 +msgid "The passkey has been successfully revoked." +msgstr "通行密钥已成功撤销。" + #: src/routes/(root)/sign/up/[token].tsx:322 msgid "The sign-up link is invalid. Please make sure you're using the correct link from the email you received." msgstr "注册链接无效。请确保你使用的是你收到的正确邮件链接。" @@ -620,6 +746,10 @@ msgstr "不含转帖" msgid "You can change it only once, and the old username will become available to others." msgstr "你只能更改一次用户名,而旧的用户名会公开为别人使用。" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:441 +msgid "You don't have any passkeys registered yet." +msgstr "您尚未注册任何通行密钥。" + #: src/routes/(root)/sign/up/[token].tsx:433 msgid "You were invited by" msgstr "您被以下用户邀请" @@ -649,6 +779,10 @@ msgstr "你的电子邮件地址将用于登录。" msgid "Your name will be displayed on your profile and in your posts." msgstr "你的昵称将在你的个人资料页面和你的帖文中显示。" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:280 +msgid "Your passkey has been registered and can now be used for authentication." +msgstr "您的通行密钥已注册,现在可以用于身份验证。" + #: src/routes/(root)/[handle]/settings/preferences.tsx:135 msgid "Your preferences have been updated successfully." msgstr "您的设置已成功更新。" diff --git a/web-next/src/locales/zh-TW/messages.po b/web-next/src/locales/zh-TW/messages.po index 175c56d6..dae97b1d 100644 --- a/web-next/src/locales/zh-TW/messages.po +++ b/web-next/src/locales/zh-TW/messages.po @@ -128,7 +128,7 @@ msgstr "{0}的貼文" msgid "{0}'s shares" msgstr "{0}的轉貼" -#: src/routes/(root)/sign/index.tsx:193 +#: src/routes/(root)/sign/index.tsx:233 msgid "A sign-in link has been sent to your email. Please check your inbox (or spam folder)." msgstr "登入連結已傳送至您的郵箱。請檢查收件匣。(或垃圾郵件資料夾)" @@ -140,6 +140,14 @@ msgstr "帳戶" msgid "An error occurred during signup. Please try again." msgstr "註冊過程中發生錯誤。請重試。" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:293 +msgid "An error occurred while registering your passkey." +msgstr "註冊通行金鑰時發生錯誤。" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:341 +msgid "An error occurred while revoking your passkey." +msgstr "撤銷通行金鑰時發生錯誤。" + #: src/routes/(root)/[handle]/settings/preferences.tsx:144 msgid "An error occurred while saving your preferences. Please try again, or contact support if the problem persists." msgstr "儲存設定時發生錯誤。請重試,如果問題持續存在,請聯繫支援團隊。" @@ -148,6 +156,11 @@ msgstr "儲存設定時發生錯誤。請重試,如果問題持續存在,請 msgid "An error occurred while saving your settings. Please try again, or contact support if the problem persists." msgstr "儲存設定時發生錯誤。請重試,如果問題仍然存在,請聯繫客服。" +#. placeholder {0}: passkeyToRevoke()?.name +#: src/routes/(root)/[handle]/settings/passkeys.tsx:539 +msgid "Are you sure you want to revoke passkey {0}? You won't be able to use it to sign in to your account anymore." +msgstr "您確定要取消密碼{0}嗎?您將無法再使用它登入您的帳戶。" + #: src/components/ProfileTabs.tsx:48 #: src/routes/(root)/[handle]/(profile)/articles.tsx:84 msgid "Articles" @@ -162,6 +175,10 @@ msgstr "僅文章" msgid "As you have already changed it {0}, you can't change it again." msgstr "自從你已經把使用者名稱換成 {0} 了,你再也改不了了。" +#: src/routes/(root)/sign/index.tsx:419 +msgid "Authenticating…" +msgstr "驗證中…" + #: src/routes/(root)/[handle]/settings/index.tsx:275 msgid "Avatar" msgstr "頭像" @@ -176,6 +193,7 @@ msgid "Bio is too long. Maximum length is 512 characters." msgstr "個人簡介太長。不能長於 512 字元。" #: src/routes/(root)/[handle]/settings/index.tsx:323 +#: src/routes/(root)/[handle]/settings/passkeys.tsx:543 msgid "Cancel" msgstr "取消" @@ -186,6 +204,10 @@ msgstr "取消" msgid "Code of conduct" msgstr "行為準則" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:462 +msgid "Created:" +msgstr "建立時間:" + #: src/routes/(root)/sign/up/[token].tsx:485 msgid "Creating account…" msgstr "創建帳戶…" @@ -211,7 +233,7 @@ msgstr "預設轉貼隱私設定" msgid "Display name" msgstr "暱稱" -#: src/routes/(root)/sign/index.tsx:288 +#: src/routes/(root)/sign/index.tsx:425 msgid "Do you need an account? Hackers' Pub is invite-only—please ask a friend to invite you." msgstr "需要建立帳戶嗎?Hackers' Pub 僅限邀請,請聯繫朋友邀請您。" @@ -223,11 +245,11 @@ msgstr "拖動選擇要保留的區域,然後點擊「裁剪」來更新你的 msgid "Email address" msgstr "電子郵件地址" -#: src/routes/(root)/sign/index.tsx:266 +#: src/routes/(root)/sign/index.tsx:380 msgid "Email or username" msgstr "電子郵件或使用者名稱" -#: src/routes/(root)/sign/index.tsx:201 +#: src/routes/(root)/sign/index.tsx:241 msgid "Enter your email or username below to sign in." msgstr "請在下方輸入您的電子郵件或使用者名稱以登入。" @@ -235,6 +257,10 @@ msgstr "請在下方輸入您的電子郵件或使用者名稱以登入。" msgid "Error" msgstr "錯誤" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:409 +msgid "ex) My key" +msgstr "例如:我的通行金鑰" + #: src/components/ActorArticleList.tsx:75 msgid "Failed to load more articles; click to retry" msgstr "載入更多文章失敗,點擊重試" @@ -251,11 +277,24 @@ msgstr "載入更多貼文失敗,點擊重試" msgid "Failed to load more notifications; click to retry" msgstr "無法載入更多通知;點擊重試" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:516 +msgid "Failed to load more passkeys; click to retry" +msgstr "載入更多通行金鑰失敗;點擊重試" + #: src/components/ActorPostList.tsx:73 #: src/components/ActorSharedPostList.tsx:75 msgid "Failed to load more posts; click to retry" msgstr "載入更多內容失敗,點擊重試" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:290 +msgid "Failed to register passkey" +msgstr "註冊通行金鑰失敗" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:332 +#: src/routes/(root)/[handle]/settings/passkeys.tsx:338 +msgid "Failed to revoke passkey" +msgstr "撤銷通行金鑰失敗" + #: src/routes/(root)/[handle]/settings/preferences.tsx:142 msgid "Failed to save preferences" msgstr "儲存設定失敗" @@ -321,6 +360,10 @@ msgstr "啟用後,AI 將為您生成文章摘要。否則,將使用文章的 msgid "John Doe" msgstr "張三" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:471 +msgid "Last used:" +msgstr "最後使用時間:" + #: src/components/ActorArticleList.tsx:78 msgid "Load more articles" msgstr "載入更多文章" @@ -337,6 +380,10 @@ msgstr "載入更多貼文" msgid "Load more notifications" msgstr "載入更多通知" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:517 +msgid "Load more passkeys" +msgstr "載入更多通行金鑰" + #: src/components/ActorPostList.tsx:76 #: src/components/ActorSharedPostList.tsx:78 msgid "Load more posts" @@ -358,6 +405,10 @@ msgstr "載入更多貼文中…" msgid "Loading more notifications" msgstr "正在載入更多通知…" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:514 +msgid "Loading more passkeys…" +msgstr "正在載入更多通行金鑰…" + #: src/components/ActorPostList.tsx:70 #: src/components/ActorSharedPostList.tsx:72 msgid "Loading more posts…" @@ -385,6 +436,10 @@ msgstr "需要暱稱" msgid "Name is too long. Maximum length is 50 characters." msgstr "使用者名稱太長。不能長於 50 字元。" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:477 +msgid "Never used" +msgstr "從未使用過" + #: src/components/ActorFollowerList.tsx:90 msgid "No followers found" msgstr "未找到粉絲" @@ -406,7 +461,7 @@ msgstr "未找到內容" #~ msgid "No posts found." #~ msgstr "未找到內容。" -#: src/routes/(root)/sign/index.tsx:198 +#: src/routes/(root)/sign/index.tsx:238 msgid "No such account in Hackers' Pub—please try again." msgstr "Hackers' Pub 中無此帳戶,請重試。" @@ -415,7 +470,11 @@ msgstr "Hackers' Pub 中無此帳戶,請重試。" msgid "Notes" msgstr "貼文" -#: src/routes/(root)/sign/index.tsx:299 +#: src/routes/(root)/sign/index.tsx:406 +msgid "Or" +msgstr "或" + +#: src/routes/(root)/sign/index.tsx:436 msgid "Or enter the code from the email" msgstr "或輸入郵件中的驗證碼" @@ -423,6 +482,31 @@ msgstr "或輸入郵件中的驗證碼" msgid "Page Not Found" msgstr "頁面未找到" +#: src/routes/(root)/sign/index.tsx:346 +msgid "Passkey authentication failed" +msgstr "通行金鑰驗證失敗" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:404 +msgid "Passkey name" +msgstr "通行金鑰名稱" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:278 +msgid "Passkey registered successfully" +msgstr "通行金鑰註冊成功" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:325 +msgid "Passkey revoked" +msgstr "通行金鑰已撤銷" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:374 +#: src/routes/(root)/[handle]/settings/passkeys.tsx:385 +msgid "passkeys" +msgstr "通行金鑰" + +#: src/components/SettingsTabs.tsx:51 +msgid "Passkeys" +msgstr "通行金鑰" + #: src/routes/(root)/[handle]/settings/index.tsx:143 msgid "Please choose an image file smaller than 5 MiB." msgstr "請選擇小於 5 MiB 的圖片檔案。" @@ -464,6 +548,35 @@ msgstr "閱讀完整文章" msgid "Read the full Code of conduct" msgstr "閱讀完整的行為守則" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:420 +msgid "Register" +msgstr "註冊" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:396 +msgid "Register a passkey" +msgstr "註冊通行金鑰" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:398 +msgid "Register a passkey to sign in to your account. You can use a passkey instead of receiving a sign-in link by email." +msgstr "為你的帳戶註冊通行金鑰。你可以使用通行金鑰登入,而不是通過電子郵件接收登入連結。" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:427 +msgid "Registered passkeys" +msgstr "已註冊的通行通行金鑰" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:420 +msgid "Registering…" +msgstr "註冊…" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:492 +#: src/routes/(root)/[handle]/settings/passkeys.tsx:548 +msgid "Revoke" +msgstr "撤銷" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:537 +msgid "Revoke passkey" +msgstr "撤銷通行金鑰" + #: src/routes/(root)/[handle]/settings/index.tsx:408 #: src/routes/(root)/[handle]/settings/preferences.tsx:243 msgid "Save" @@ -476,6 +589,7 @@ msgstr "儲存中…" #: src/components/AppSidebar.tsx:288 #: src/routes/(root)/[handle]/settings/index.tsx:261 +#: src/routes/(root)/[handle]/settings/passkeys.tsx:379 #: src/routes/(root)/[handle]/settings/preferences.tsx:181 msgid "Settings" msgstr "設定" @@ -486,10 +600,14 @@ msgid "Shares" msgstr "轉貼" #: src/components/AppSidebar.tsx:241 -#: src/routes/(root)/sign/index.tsx:282 +#: src/routes/(root)/sign/index.tsx:396 msgid "Sign in" msgstr "登入" +#: src/routes/(root)/sign/index.tsx:420 +msgid "Sign in with passkey" +msgstr "使用通行金鑰登入" + #: src/components/AppSidebar.tsx:310 msgid "Sign out" msgstr "登出" @@ -498,11 +616,11 @@ msgstr "登出" msgid "Sign up" msgstr "註冊" -#: src/routes/(root)/sign/index.tsx:249 +#: src/routes/(root)/sign/index.tsx:363 msgid "Signing in Hackers' Pub" msgstr "登入 Hackers' Pub" -#: src/routes/(root)/sign/index.tsx:282 +#: src/routes/(root)/sign/index.tsx:396 msgid "Signing in…" msgstr "登入中…" @@ -510,7 +628,7 @@ msgstr "登入中…" msgid "Signing up for Hackers' Pub" msgstr "註冊 Hackers' Pub" -#: src/routes/(root)/sign/index.tsx:203 +#: src/routes/(root)/sign/index.tsx:243 msgid "Something went wrong—please try again." msgstr "發生錯誤,請重試。" @@ -542,6 +660,14 @@ msgstr "您貼文的預設隱私設定。" msgid "The default privacy setting for your shares." msgstr "您轉貼的預設隱私設定。" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:429 +msgid "The following passkeys are registered to your account. You can use them to sign in to your account." +msgstr "以下通行通行金鑰已註冊到你的帳戶。你可以使用它們登入你的帳戶。" + +#: src/routes/(root)/[handle]/settings/passkeys.tsx:326 +msgid "The passkey has been successfully revoked." +msgstr "通行金鑰已成功撤銷。" + #: src/routes/(root)/sign/up/[token].tsx:322 msgid "The sign-up link is invalid. Please make sure you're using the correct link from the email you received." msgstr "註冊連結無效。請確保你使用的是你收到的正確郵件連結。" @@ -620,6 +746,10 @@ msgstr "不含轉貼" msgid "You can change it only once, and the old username will become available to others." msgstr "你只能更改一次使用者名稱,而舊的使用者名稱會公開為別人使用。" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:441 +msgid "You don't have any passkeys registered yet." +msgstr "您尚未註冊任何通行金鑰。" + #: src/routes/(root)/sign/up/[token].tsx:433 msgid "You were invited by" msgstr "您的邀請人" @@ -649,6 +779,10 @@ msgstr "你的電子郵件地址將用於登入。" msgid "Your name will be displayed on your profile and in your posts." msgstr "你的暱稱將在你的個人資料頁面和你的貼文中顯示。" +#: src/routes/(root)/[handle]/settings/passkeys.tsx:280 +msgid "Your passkey has been registered and can now be used for authentication." +msgstr "您的通行金鑰已經註冊,現在可以用於驗證。" + #: src/routes/(root)/[handle]/settings/preferences.tsx:135 msgid "Your preferences have been updated successfully." msgstr "您的設定已成功更新。" 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..eed42a86 --- /dev/null +++ b/web-next/src/routes/(root)/[handle]/settings/passkeys.tsx @@ -0,0 +1,557 @@ +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, + createPaginationFragment, + 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 { 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: /^@[^@]+$/, + }, + preload(args) { + void loadPageQuery(args.params.handle); + }, +} satisfies RouteDefinition; + +const passkeysPageQuery = graphql` + query passkeysPageQuery($username: String!, $first: Int, $after: String) { + viewer { + id + } + accountByUsername(username: $username) { + id + username + ...SettingsTabs_account + ...passkeysFragment_account @arguments(first: $first, after: $after) + actor { + ...ProfilePageBreadcrumb_actor + } + } + } +`; + +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, + first: number = PASSKEYS_PAGE_SIZE, + after: string | null = null, + ) => + loadQuery( + useRelayEnvironment()(), + passkeysPageQuery, + { + username: handle.replace(/^@/, ""), + first, + after, + }, + ), + "loadpasskeysPageQuery", +); + +const getPasskeyRegistrationOptionsMutation = graphql` + mutation passkeysGetPasskeyRegistrationOptionsMutation($accountId: ID!) { + getPasskeyRegistrationOptions(accountId: $accountId) + } +`; + +const verifyPasskeyRegistrationMutation = graphql` + mutation passkeysVerifyPasskeyRegistrationMutation( + $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!, $connections: [ID!]!) { + revokePasskey(passkeyId: $passkeyId) @deleteEdge(connections: $connections) + } +`; + +export default function passkeysPage() { + const params = useParams(); + const location = useLocation(); + const { t } = useLingui(); + + const data = createPreloadedQuery( + passkeysPageQuery, + () => loadPageQuery(params.handle), + ); + + const [getOptions] = createMutation< + passkeysGetPasskeyRegistrationOptionsMutation + >( + getPasskeyRegistrationOptionsMutation, + ); + const [verifyRegistration] = createMutation< + passkeysVerifyPasskeyRegistrationMutation + >( + verifyPasskeyRegistrationMutation, + ); + const [revokePasskey] = createMutation( + revokePasskeyMutation, + ); + + const [registering, setRegistering] = createSignal(false); + let passkeyNameRef: HTMLInputElement | undefined; + 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; + const name = passkeyNameRef?.value?.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, + connections: [passkeyData()!.passkeys.__id], + }, + onCompleted: resolve, + onError: reject, + }); + }); + + const result = verifyResponse.verifyPasskeyRegistration; + if (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", + }); + if (passkeyNameRef) passkeyNameRef.value = ""; + // No need to manually refresh - @appendNode automatically updates the connection + } 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, + connections: [passkeyData()!.passkeys.__id], + }, + onCompleted: resolve, + onError: reject, + }); + }); + + if (response.revokePasskey) { + showToast({ + title: t`Passkey revoked`, + description: t`The passkey has been successfully revoked.`, + variant: "success", + }); + // No need to manually refresh - @deleteEdge automatically updates the connection + } 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`} + + + + + + + + + + {t`Registered passkeys`} + + {t`The following passkeys are registered to your account. You can use them to sign in to your account.`} + + + + { + const paginatedData = passkeyData(); + return paginatedData && + paginatedData.passkeys.edges.length > 0; + }} + fallback={ +

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

+ } + > +
+ { + const paginatedData = passkeyData(); + return paginatedData + ? paginatedData.passkeys.edges + : []; + })()} + > + {(edge) => ( +
+
+

+ {edge.node.name} +

+
+
+ {t`Created:`}{" "} + +
+
+ {edge.node.lastUsed + ? ( + <> + {t`Last used:`}{" "} + + + ) + : t`Never used`} +
+
+
+ +
+ )} +
+ + { + const paginatedData = passkeyData(); + return paginatedData && + paginatedData.passkeys.pageInfo.hasNextPage; + }} + > +
+ +
+
+
+
+
+
+
+
+
+ + )} +
+ 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`} + + + + + + )} +
+ ); +} 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.`}