From 5fd60ba07d245d1c4e5a1e7f8623d0fcea1d7004 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Mon, 13 Apr 2026 20:10:00 +0200 Subject: [PATCH] WIP: Komoot import integration Add Komoot API client, import logic, crypto helpers, integration routes, DB schema changes, and i18n strings for the Komoot import feature. Import processing runs as a durable pg-boss background job with retries instead of blocking the HTTP request. Co-Authored-By: Claude Opus 4.6 (1M context) --- apps/journal/app/jobs/komoot-import.ts | 55 +++++ apps/journal/app/lib/boss.server.ts | 14 ++ apps/journal/app/lib/crypto.server.test.ts | 40 ++++ apps/journal/app/lib/crypto.server.ts | 31 +++ apps/journal/app/lib/komoot-import.server.ts | 143 +++++++++++++ apps/journal/app/lib/komoot.server.test.ts | 54 +++++ apps/journal/app/lib/komoot.server.ts | 141 +++++++++++++ apps/journal/app/root.tsx | 3 + apps/journal/app/routes.ts | 5 + .../routes/api.integrations.komoot.connect.ts | 44 ++++ .../api.integrations.komoot.disconnect.ts | 23 +++ .../api.integrations.komoot.import-status.ts | 35 ++++ .../routes/api.integrations.komoot.import.ts | 45 ++++ apps/journal/app/routes/auth.register.tsx | 57 +++-- apps/journal/app/routes/integrations.tsx | 195 ++++++++++++++++++ apps/journal/app/routes/privacy.tsx | 1 + apps/journal/server.ts | 7 +- infrastructure/docker-compose.yml | 1 + infrastructure/secrets.app.env | 8 +- openspec/changes/komoot-import/tasks.md | 56 ++--- packages/db/src/schema/journal.ts | 19 ++ packages/i18n/src/locales/de.ts | 27 +++ packages/i18n/src/locales/en.ts | 27 +++ packages/jobs/src/worker.ts | 2 +- 24 files changed, 982 insertions(+), 51 deletions(-) create mode 100644 apps/journal/app/jobs/komoot-import.ts create mode 100644 apps/journal/app/lib/boss.server.ts create mode 100644 apps/journal/app/lib/crypto.server.test.ts create mode 100644 apps/journal/app/lib/crypto.server.ts create mode 100644 apps/journal/app/lib/komoot-import.server.ts create mode 100644 apps/journal/app/lib/komoot.server.test.ts create mode 100644 apps/journal/app/lib/komoot.server.ts create mode 100644 apps/journal/app/routes/api.integrations.komoot.connect.ts create mode 100644 apps/journal/app/routes/api.integrations.komoot.disconnect.ts create mode 100644 apps/journal/app/routes/api.integrations.komoot.import-status.ts create mode 100644 apps/journal/app/routes/api.integrations.komoot.import.ts create mode 100644 apps/journal/app/routes/integrations.tsx diff --git a/apps/journal/app/jobs/komoot-import.ts b/apps/journal/app/jobs/komoot-import.ts new file mode 100644 index 0000000..cd262e8 --- /dev/null +++ b/apps/journal/app/jobs/komoot-import.ts @@ -0,0 +1,55 @@ +import type { JobDefinition } from "@trails-cool/jobs"; +import { eq, and } from "drizzle-orm"; +import { getDb } from "../lib/db.ts"; +import { syncConnections } from "@trails-cool/db/schema/journal"; +import { decrypt } from "../lib/crypto.server.ts"; +import { importKomootTours } from "../lib/komoot-import.server.ts"; +import type { KomootCredentials } from "../lib/komoot.server.ts"; +import { logger } from "../lib/logger.server.ts"; + +interface KomootImportData { + userId: string; + connectionId: string; + batchId: string; +} + +export const komootImportJob: JobDefinition = { + name: "komoot-import", + retryLimit: 1, + expireInSeconds: 300, + async handler(jobs) { + for (const job of jobs) { + const { userId, connectionId, batchId } = job.data; + logger.info({ userId, connectionId, batchId }, "starting Komoot import job"); + + const db = getDb(); + const [connection] = await db + .select() + .from(syncConnections) + .where( + and( + eq(syncConnections.id, connectionId), + eq(syncConnections.userId, userId), + ), + ); + + if (!connection?.encryptedCredentials) { + throw new Error(`Komoot connection ${connectionId} not found or missing credentials`); + } + + const { email, password } = JSON.parse(decrypt(connection.encryptedCredentials)) as { + email: string; + password: string; + }; + const creds: KomootCredentials = { + email, + password, + username: connection.providerUserId!, + token: connection.accessToken, + }; + + await importKomootTours(userId, connectionId, creds, batchId); + logger.info({ batchId }, "Komoot import job completed"); + } + }, +}; diff --git a/apps/journal/app/lib/boss.server.ts b/apps/journal/app/lib/boss.server.ts new file mode 100644 index 0000000..d588ecc --- /dev/null +++ b/apps/journal/app/lib/boss.server.ts @@ -0,0 +1,14 @@ +import type { createBoss } from "@trails-cool/jobs"; + +type Boss = ReturnType; + +let boss: Boss | null = null; + +export function setBoss(instance: Boss): void { + boss = instance; +} + +export function getBoss(): Boss { + if (!boss) throw new Error("pg-boss not initialized — server still starting?"); + return boss; +} diff --git a/apps/journal/app/lib/crypto.server.test.ts b/apps/journal/app/lib/crypto.server.test.ts new file mode 100644 index 0000000..4e04039 --- /dev/null +++ b/apps/journal/app/lib/crypto.server.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, beforeAll } from "vitest"; +import { encrypt, decrypt } from "./crypto.server"; + +beforeAll(() => { + process.env.INTEGRATION_SECRET = "test-secret-for-unit-tests"; +}); + +describe("crypto", () => { + it("encrypts and decrypts a string", () => { + const plaintext = "my-secret-password-123"; + const encrypted = encrypt(plaintext); + expect(encrypted).not.toBe(plaintext); + expect(decrypt(encrypted)).toBe(plaintext); + }); + + it("produces different ciphertext for the same input", () => { + const plaintext = "same-input"; + const a = encrypt(plaintext); + const b = encrypt(plaintext); + expect(a).not.toBe(b); + expect(decrypt(a)).toBe(plaintext); + expect(decrypt(b)).toBe(plaintext); + }); + + it("handles empty string", () => { + const encrypted = encrypt(""); + expect(decrypt(encrypted)).toBe(""); + }); + + it("handles unicode", () => { + const plaintext = "Passwort mit Ümlauten: äöü 🏔️"; + expect(decrypt(encrypt(plaintext))).toBe(plaintext); + }); + + it("throws on tampered ciphertext", () => { + const encrypted = encrypt("secret"); + const tampered = encrypted.slice(0, -2) + "XX"; + expect(() => decrypt(tampered)).toThrow(); + }); +}); diff --git a/apps/journal/app/lib/crypto.server.ts b/apps/journal/app/lib/crypto.server.ts new file mode 100644 index 0000000..5223ec0 --- /dev/null +++ b/apps/journal/app/lib/crypto.server.ts @@ -0,0 +1,31 @@ +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto"; + +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 12; +const TAG_LENGTH = 16; + +function getKey(): Buffer { + const secret = process.env.INTEGRATION_SECRET; + if (!secret) throw new Error("INTEGRATION_SECRET environment variable is required"); + return scryptSync(secret, "trails-cool-integrations", 32); +} + +export function encrypt(plaintext: string): string { + const key = getKey(); + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH }); + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const tag = cipher.getAuthTag(); + return Buffer.concat([iv, tag, encrypted]).toString("base64"); +} + +export function decrypt(encoded: string): string { + const key = getKey(); + const buf = Buffer.from(encoded, "base64"); + const iv = buf.subarray(0, IV_LENGTH); + const tag = buf.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH); + const encrypted = buf.subarray(IV_LENGTH + TAG_LENGTH); + const decipher = createDecipheriv(ALGORITHM, key, iv, { authTagLength: TAG_LENGTH }); + decipher.setAuthTag(tag); + return decipher.update(encrypted) + decipher.final("utf8"); +} diff --git a/apps/journal/app/lib/komoot-import.server.ts b/apps/journal/app/lib/komoot-import.server.ts new file mode 100644 index 0000000..5ef1573 --- /dev/null +++ b/apps/journal/app/lib/komoot-import.server.ts @@ -0,0 +1,143 @@ +import { randomUUID } from "node:crypto"; +import { eq } from "drizzle-orm"; +import { getDb } from "./db.ts"; +import { importBatches } from "@trails-cool/db/schema/journal"; +import { createActivity } from "./activities.server.ts"; +import { createRoute, type RouteInput } from "./routes.server.ts"; +import { isAlreadyImported, recordImport } from "./sync/imports.server.ts"; +import { fetchTours, fetchTourGpx, type KomootCredentials, type KomootTour } from "./komoot.server.ts"; + +export async function importKomootTours( + userId: string, + connectionId: string, + creds: KomootCredentials, + existingBatchId?: string, +): Promise { + const db = getDb(); + const batchId = existingBatchId ?? randomUUID(); + + if (!existingBatchId) { + await db.insert(importBatches).values({ + id: batchId, + userId, + connectionId, + status: "running", + }); + } else { + await db + .update(importBatches) + .set({ status: "running" }) + .where(eq(importBatches.id, batchId)); + } + + try { + let page = 0; + let totalFound = 0; + let importedCount = 0; + let duplicateCount = 0; + let hasMore = true; + + while (hasMore) { + const result = await fetchTours(creds, page); + totalFound += result.tours.length; + + await db + .update(importBatches) + .set({ totalFound }) + .where(eq(importBatches.id, batchId)); + + for (const tour of result.tours) { + const alreadyImported = await isAlreadyImported(userId, "komoot", tour.id); + if (alreadyImported) { + duplicateCount++; + continue; + } + + try { + await importSingleTour(userId, creds, tour); + importedCount++; + } catch (err) { + console.error(`Failed to import Komoot tour ${tour.id}:`, err); + } + + await db + .update(importBatches) + .set({ importedCount, duplicateCount }) + .where(eq(importBatches.id, batchId)); + } + + hasMore = page + 1 < result.totalPages; + page++; + } + + await db + .update(importBatches) + .set({ + status: "completed", + totalFound, + importedCount, + duplicateCount, + completedAt: new Date(), + }) + .where(eq(importBatches.id, batchId)); + + return batchId; + } catch (err) { + await db + .update(importBatches) + .set({ + status: "failed", + errorMessage: err instanceof Error ? err.message : String(err), + completedAt: new Date(), + }) + .where(eq(importBatches.id, batchId)); + throw err; + } +} + +async function importSingleTour( + userId: string, + creds: KomootCredentials, + tour: KomootTour, +): Promise { + let gpx: string | undefined; + try { + gpx = await fetchTourGpx(creds, tour.id); + } catch { + // Tour without downloadable GPX — import metadata only + } + + const routeInput: RouteInput = { + name: tour.name, + gpx, + }; + + const routeId = await createRoute(userId, routeInput); + + // Mark the route source as komoot + const db = getDb(); + const { routes } = await import("@trails-cool/db/schema/journal"); + await db.update(routes).set({ source: "komoot" }).where(eq(routes.id, routeId)); + + const activityId = await createActivity(userId, { + name: tour.name, + gpx, + routeId, + distance: tour.distance, + duration: tour.duration, + startedAt: new Date(tour.date), + }); + + await recordImport(userId, "komoot", tour.id, activityId); +} + +export async function getLatestBatch(userId: string, connectionId: string) { + const db = getDb(); + const [batch] = await db + .select() + .from(importBatches) + .where(eq(importBatches.connectionId, connectionId)) + .orderBy(importBatches.startedAt) + .limit(1); + return batch ?? null; +} diff --git a/apps/journal/app/lib/komoot.server.test.ts b/apps/journal/app/lib/komoot.server.test.ts new file mode 100644 index 0000000..4f78851 --- /dev/null +++ b/apps/journal/app/lib/komoot.server.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { parseTourResponse } from "./komoot.server"; + +describe("komoot", () => { + describe("parseTourResponse", () => { + it("parses a page of tours", () => { + const data = { + _embedded: { + tours: [ + { + id: 12345, + name: "Morning hike in the Alps", + sport: "hike", + date: "2026-03-15T08:30:00.000Z", + distance: 15234.5, + duration: 18900, + elevation_up: 850, + elevation_down: 830, + }, + { + id: 67890, + name: "Cycling to work", + sport: "touringbicycle", + date: "2026-03-14T07:00:00.000Z", + distance: 8500, + duration: 1800, + elevation_up: 45, + elevation_down: 50, + }, + ], + }, + }; + + const tours = parseTourResponse(data); + expect(tours).toHaveLength(2); + expect(tours[0]).toEqual({ + id: "12345", + name: "Morning hike in the Alps", + sport: "hike", + date: "2026-03-15T08:30:00.000Z", + distance: 15234.5, + duration: 18900, + elevationUp: 850, + elevationDown: 830, + }); + }); + + it("handles empty response", () => { + expect(parseTourResponse({})).toEqual([]); + expect(parseTourResponse({ _embedded: {} })).toEqual([]); + expect(parseTourResponse({ _embedded: { tours: [] } })).toEqual([]); + }); + }); +}); diff --git a/apps/journal/app/lib/komoot.server.ts b/apps/journal/app/lib/komoot.server.ts new file mode 100644 index 0000000..1b08369 --- /dev/null +++ b/apps/journal/app/lib/komoot.server.ts @@ -0,0 +1,141 @@ +const API_BASE = "https://api.komoot.de/v007"; +const AUTH_BASE = "https://api.komoot.de/v006"; + +export interface KomootCredentials { + email: string; + password: string; + username: string; + token: string; +} + +export interface KomootTour { + id: string; + name: string; + sport: string; + date: string; + distance: number; + duration: number; + elevationUp: number; + elevationDown: number; +} + +export interface KomootTourPage { + tours: KomootTour[]; + totalPages: number; + page: number; +} + +export async function login( + email: string, + password: string, +): Promise<{ username: string; token: string }> { + const res = await fetch(`${AUTH_BASE}/account/email/${email}/`, { + headers: { + Authorization: `Basic ${Buffer.from(`${email}:${password}`).toString("base64")}`, + }, + }); + + if (!res.ok) { + if (res.status === 401 || res.status === 403) { + throw new Error("Invalid Komoot credentials"); + } + throw new Error(`Komoot login failed: ${res.status}`); + } + + const data = (await res.json()) as { username: string }; + return { username: data.username, token: Buffer.from(`${email}:${password}`).toString("base64") }; +} + +export async function fetchTours( + creds: KomootCredentials, + page = 0, +): Promise { + const res = await fetch( + `${API_BASE}/users/${creds.username}/tours/?page=${page}&limit=50&type=tour_recorded&sort_field=date&sort_direction=desc`, + { + headers: { Authorization: `Basic ${creds.token}` }, + }, + ); + + if (!res.ok) { + if (res.status === 401) throw new Error("Komoot auth expired"); + throw new Error(`Komoot tours fetch failed: ${res.status}`); + } + + const data = (await res.json()) as { + _embedded?: { + tours?: Array<{ + id: number; + name: string; + sport: string; + date: string; + distance: number; + duration: number; + elevation_up: number; + elevation_down: number; + }>; + }; + page?: { totalPages: number; number: number }; + }; + + console.log("[komoot] page response keys:", Object.keys(data), "page field:", JSON.stringify(data.page)); + + const tours = (data._embedded?.tours ?? []).map((t) => ({ + id: String(t.id), + name: t.name, + sport: t.sport, + date: t.date, + distance: t.distance, + duration: t.duration, + elevationUp: t.elevation_up, + elevationDown: t.elevation_down, + })); + + return { + tours, + totalPages: data.page?.totalPages ?? 1, + page: data.page?.number ?? 0, + }; +} + +export async function fetchTourGpx( + creds: KomootCredentials, + tourId: string, +): Promise { + const res = await fetch(`${API_BASE}/tours/${tourId}.gpx`, { + headers: { Authorization: `Basic ${creds.token}` }, + }); + + if (!res.ok) { + throw new Error(`Komoot GPX fetch failed for tour ${tourId}: ${res.status}`); + } + + return res.text(); +} + +export function parseTourResponse(data: unknown): KomootTour[] { + const obj = data as { + _embedded?: { + tours?: Array<{ + id: number; + name: string; + sport: string; + date: string; + distance: number; + duration: number; + elevation_up: number; + elevation_down: number; + }>; + }; + }; + return (obj._embedded?.tours ?? []).map((t) => ({ + id: String(t.id), + name: t.name, + sport: t.sport, + date: t.date, + distance: t.distance, + duration: t.duration, + elevationUp: t.elevation_up, + elevationDown: t.elevation_down, + })); +} diff --git a/apps/journal/app/root.tsx b/apps/journal/app/root.tsx index be4a5f4..b4094b0 100644 --- a/apps/journal/app/root.tsx +++ b/apps/journal/app/root.tsx @@ -68,6 +68,9 @@ function NavBar({ user }: { user: { id: string; username: string } | null }) { {t("nav.activities")} + + {t("nav.integrations")} + )} diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index 537369b..19dd6c9 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -32,6 +32,11 @@ export default [ route("api/sync/callback/:provider", "routes/api.sync.callback.$provider.ts"), route("api/sync/disconnect/:provider", "routes/api.sync.disconnect.$provider.ts"), route("api/sync/webhook/:provider", "routes/api.sync.webhook.$provider.ts"), + route("integrations", "routes/integrations.tsx"), + route("api/integrations/komoot/connect", "routes/api.integrations.komoot.connect.ts"), + route("api/integrations/komoot/disconnect", "routes/api.integrations.komoot.disconnect.ts"), + route("api/integrations/komoot/import", "routes/api.integrations.komoot.import.ts"), + route("api/integrations/komoot/import-status", "routes/api.integrations.komoot.import-status.ts"), route("privacy", "routes/privacy.tsx"), // REST API v1 route("api/v1/routes", "routes/api.v1.routes._index.ts"), diff --git a/apps/journal/app/routes/api.integrations.komoot.connect.ts b/apps/journal/app/routes/api.integrations.komoot.connect.ts new file mode 100644 index 0000000..b77ff29 --- /dev/null +++ b/apps/journal/app/routes/api.integrations.komoot.connect.ts @@ -0,0 +1,44 @@ +import { data } from "react-router"; +import { randomUUID } from "node:crypto"; +import type { Route } from "./+types/api.integrations.komoot.connect"; +import { getSessionUser } from "~/lib/auth.server"; +import { getDb } from "~/lib/db"; +import { syncConnections } from "@trails-cool/db/schema/journal"; +import { login } from "~/lib/komoot.server"; +import { encrypt } from "~/lib/crypto.server"; + +export async function action({ request }: Route.ActionArgs) { + const user = await getSessionUser(request); + if (!user) throw data(null, { status: 401 }); + + const form = await request.formData(); + const email = String(form.get("email") ?? "").trim(); + const password = String(form.get("password") ?? ""); + + if (!email || !password) { + return data({ error: "Email and password are required" }, { status: 400 }); + } + + let credentials: { username: string; token: string }; + try { + credentials = await login(email, password); + } catch { + return data({ error: "Invalid Komoot credentials" }, { status: 400 }); + } + + const encrypted = encrypt(JSON.stringify({ email, password })); + + const db = getDb(); + await db.insert(syncConnections).values({ + id: randomUUID(), + userId: user.id, + provider: "komoot", + accessToken: credentials.token, + refreshToken: "", + expiresAt: new Date("2099-12-31"), + providerUserId: credentials.username, + encryptedCredentials: encrypted, + }); + + return data({ ok: true }); +} diff --git a/apps/journal/app/routes/api.integrations.komoot.disconnect.ts b/apps/journal/app/routes/api.integrations.komoot.disconnect.ts new file mode 100644 index 0000000..971fee2 --- /dev/null +++ b/apps/journal/app/routes/api.integrations.komoot.disconnect.ts @@ -0,0 +1,23 @@ +import { data } from "react-router"; +import { eq, and } from "drizzle-orm"; +import type { Route } from "./+types/api.integrations.komoot.disconnect"; +import { getSessionUser } from "~/lib/auth.server"; +import { getDb } from "~/lib/db"; +import { syncConnections } from "@trails-cool/db/schema/journal"; + +export async function action({ request }: Route.ActionArgs) { + const user = await getSessionUser(request); + if (!user) throw data(null, { status: 401 }); + + const db = getDb(); + await db + .delete(syncConnections) + .where( + and( + eq(syncConnections.userId, user.id), + eq(syncConnections.provider, "komoot"), + ), + ); + + return data({ ok: true }); +} diff --git a/apps/journal/app/routes/api.integrations.komoot.import-status.ts b/apps/journal/app/routes/api.integrations.komoot.import-status.ts new file mode 100644 index 0000000..aad5d30 --- /dev/null +++ b/apps/journal/app/routes/api.integrations.komoot.import-status.ts @@ -0,0 +1,35 @@ +import { data } from "react-router"; +import { eq, and, desc } from "drizzle-orm"; +import type { Route } from "./+types/api.integrations.komoot.import-status"; +import { getSessionUser } from "~/lib/auth.server"; +import { getDb } from "~/lib/db"; +import { syncConnections, importBatches } from "@trails-cool/db/schema/journal"; + +export async function loader({ request }: Route.LoaderArgs) { + const user = await getSessionUser(request); + if (!user) throw data(null, { status: 401 }); + + const db = getDb(); + const [connection] = await db + .select() + .from(syncConnections) + .where( + and( + eq(syncConnections.userId, user.id), + eq(syncConnections.provider, "komoot"), + ), + ); + + if (!connection) { + return data({ batch: null }); + } + + const [batch] = await db + .select() + .from(importBatches) + .where(eq(importBatches.connectionId, connection.id)) + .orderBy(desc(importBatches.startedAt)) + .limit(1); + + return data({ batch: batch ?? null }); +} diff --git a/apps/journal/app/routes/api.integrations.komoot.import.ts b/apps/journal/app/routes/api.integrations.komoot.import.ts new file mode 100644 index 0000000..3417f4d --- /dev/null +++ b/apps/journal/app/routes/api.integrations.komoot.import.ts @@ -0,0 +1,45 @@ +import { randomUUID } from "node:crypto"; +import { data } from "react-router"; +import { eq, and } from "drizzle-orm"; +import type { Route } from "./+types/api.integrations.komoot.import"; +import { getSessionUser } from "~/lib/auth.server"; +import { getDb } from "~/lib/db"; +import { syncConnections, importBatches } from "@trails-cool/db/schema/journal"; +import { getBoss } from "~/lib/boss.server"; + +export async function action({ request }: Route.ActionArgs) { + const user = await getSessionUser(request); + if (!user) throw data(null, { status: 401 }); + + const db = getDb(); + const [connection] = await db + .select() + .from(syncConnections) + .where( + and( + eq(syncConnections.userId, user.id), + eq(syncConnections.provider, "komoot"), + ), + ); + + if (!connection?.encryptedCredentials) { + return data({ error: "Komoot not connected" }, { status: 400 }); + } + + const batchId = randomUUID(); + await db.insert(importBatches).values({ + id: batchId, + userId: user.id, + connectionId: connection.id, + status: "queued", + }); + + const boss = getBoss(); + await boss.send("komoot-import", { + userId: user.id, + connectionId: connection.id, + batchId, + }); + + return data({ batchId }); +} diff --git a/apps/journal/app/routes/auth.register.tsx b/apps/journal/app/routes/auth.register.tsx index 90ea6b1..dc8f9f0 100644 --- a/apps/journal/app/routes/auth.register.tsx +++ b/apps/journal/app/routes/auth.register.tsx @@ -8,11 +8,14 @@ export default function RegisterPage() { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); const [supportsPasskey, setSupportsPasskey] = useState(null); + const [usePasskey, setUsePasskey] = useState(true); const [magicLinkSent, setMagicLinkSent] = useState(false); useEffect(() => { import("@simplewebauthn/browser").then(({ browserSupportsWebAuthn }) => { - setSupportsPasskey(browserSupportsWebAuthn()); + const supported = browserSupportsWebAuthn(); + setSupportsPasskey(supported); + setUsePasskey(supported); }); }, []); @@ -114,7 +117,7 @@ export default function RegisterPage() { {t("auth.registerDescription")}

-
+