diff --git a/apps/journal/app/lib/connected-services/credential-adapters/noop.ts b/apps/journal/app/lib/connected-services/credential-adapters/noop.ts new file mode 100644 index 0000000..f68bd26 --- /dev/null +++ b/apps/journal/app/lib/connected-services/credential-adapters/noop.ts @@ -0,0 +1,11 @@ +// No-op CredentialAdapter for providers whose credentials never expire and +// cannot be refreshed (e.g. Komoot basic-auth, public-mode connections). + +import type { CredentialAdapter, Credentials } from "../types.ts"; + +export const noopCredentialAdapter: CredentialAdapter = { + isExpired: () => false, + async refresh(creds) { + return creds; + }, +}; diff --git a/apps/journal/app/lib/connected-services/providers/index.ts b/apps/journal/app/lib/connected-services/providers/index.ts index d97b66e..ff04735 100644 --- a/apps/journal/app/lib/connected-services/providers/index.ts +++ b/apps/journal/app/lib/connected-services/providers/index.ts @@ -4,8 +4,10 @@ import { registerManifest } from "../registry.ts"; import { wahooManifest } from "./wahoo/manifest.ts"; +import { komootManifest } from "./komoot/manifest.ts"; registerManifest(wahooManifest); +registerManifest(komootManifest); // Re-export so callers (mostly tests) can grab a manifest directly. -export { wahooManifest }; +export { wahooManifest, komootManifest }; diff --git a/apps/journal/app/lib/connected-services/providers/komoot/importer.test.ts b/apps/journal/app/lib/connected-services/providers/komoot/importer.test.ts new file mode 100644 index 0000000..5292962 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/komoot/importer.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock server-only deps before import +vi.mock("../../../crypto.server.ts", () => ({ + decrypt: vi.fn((s: string) => `decrypted:${s}`), + encrypt: vi.fn((s: string) => `encrypted:${s}`), +})); + +vi.mock("../../../komoot.server.ts", () => ({ + fetchKomootTours: vi.fn(), + fetchKomootTourGpx: vi.fn(), +})); + +vi.mock("../../../sync/imports.server.ts", () => ({ + isAlreadyImported: vi.fn(), + importActivity: vi.fn(), + recordImport: vi.fn(), +})); + +vi.mock("../../manager.ts", () => ({ + getServiceById: vi.fn(), +})); + +import { komootImporter } from "./importer.ts"; +import { fetchKomootTours, fetchKomootTourGpx } from "../../../komoot.server.ts"; +import { isAlreadyImported, importActivity } from "../../../sync/imports.server.ts"; +import { getServiceById } from "../../manager.ts"; + +const mockFetchTours = vi.mocked(fetchKomootTours); +const mockFetchGpx = vi.mocked(fetchKomootTourGpx); +const mockIsAlreadyImported = vi.mocked(isAlreadyImported); +const mockImportActivity = vi.mocked(importActivity); +const mockGetServiceById = vi.mocked(getServiceById); + +function makeCtx(creds: unknown, serviceId = "svc-1") { + return { + serviceId, + withFreshCredentials: async (fn: (c: unknown) => Promise) => fn(creds), + }; +} + +describe("komootImporter.listImportable", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("lists public tours without auth token", async () => { + mockFetchTours.mockResolvedValueOnce({ + tours: [ + { id: "111", name: "Morning ride", sport: "bike", date: "2024-01-01T00:00:00Z", distance: 30000, duration: 5400, elevationUp: 200, elevationDown: 190 }, + ], + totalPages: 1, + }); + + const ctx = makeCtx({ mode: "public", komootUserId: "999" }); + const result = await komootImporter.listImportable(ctx, 1); + + expect(mockFetchTours).toHaveBeenCalledWith("999", 1, undefined); + expect(result.workouts).toHaveLength(1); + expect(result.workouts[0]!.id).toBe("111"); + }); + + it("passes basic auth token for authenticated mode", async () => { + mockFetchTours.mockResolvedValueOnce({ tours: [], totalPages: 1 }); + + const ctx = makeCtx({ + mode: "authenticated", + email: "test@example.com", + encryptedPassword: "enc-pw", + komootUserId: "888", + }); + await komootImporter.listImportable(ctx, 1); + + // decrypt returns `decrypted:enc-pw`, so basic token = base64(test@example.com:decrypted:enc-pw) + const expectedToken = Buffer.from("test@example.com:decrypted:enc-pw").toString("base64"); + expect(mockFetchTours).toHaveBeenCalledWith("888", 1, expectedToken); + }); +}); + +describe("komootImporter.importOne", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetServiceById.mockResolvedValue({ + id: "svc-1", + userId: "user-1", + provider: "komoot", + credentialKind: "public", + credentials: { mode: "public", komootUserId: "999" }, + status: "active", + providerUserId: "999", + grantedScopes: [], + createdAt: new Date(), + }); + mockIsAlreadyImported.mockResolvedValue(false); + mockImportActivity.mockResolvedValue({ activityId: "act-123" }); + mockFetchTours.mockResolvedValue({ tours: [{ id: "tour-1", name: "Test Tour", sport: "hike", date: "2024-01-01T00:00:00Z", distance: 10000, duration: 3600, elevationUp: 100, elevationDown: 100 }], totalPages: 1 }); + mockFetchGpx.mockResolvedValue("..."); + }); + + it("imports a tour with GPX", async () => { + const ctx = makeCtx({ mode: "public", komootUserId: "999" }); + const result = await komootImporter.importOne(ctx, "tour-1"); + + expect(result.activityId).toBe("act-123"); + expect(result.hadGeometry).toBe(true); + expect(mockImportActivity).toHaveBeenCalledWith("user-1", "komoot", "tour-1", { + name: "Test Tour", + gpx: "...", + }); + }); + + it("throws when tour is already imported", async () => { + mockIsAlreadyImported.mockResolvedValue(true); + const ctx = makeCtx({ mode: "public", komootUserId: "999" }); + await expect(komootImporter.importOne(ctx, "tour-1")).rejects.toThrow("already imported"); + }); + + it("imports without geometry when GPX fetch fails", async () => { + mockFetchGpx.mockRejectedValue(new Error("404")); + const ctx = makeCtx({ mode: "public", komootUserId: "999" }); + const result = await komootImporter.importOne(ctx, "tour-1"); + expect(result.hadGeometry).toBe(false); + expect(mockImportActivity).toHaveBeenCalledWith("user-1", "komoot", "tour-1", { + name: "Test Tour", + gpx: undefined, + }); + }); +}); diff --git a/apps/journal/app/lib/connected-services/providers/komoot/importer.ts b/apps/journal/app/lib/connected-services/providers/komoot/importer.ts new file mode 100644 index 0000000..035de49 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/komoot/importer.ts @@ -0,0 +1,91 @@ +// Komoot Importer capability adapter. Implements the Importer seam against +// the Komoot tours API. +// +// Two credential modes: +// public — unauthenticated, public tours only +// authenticated — email/password (password AES-256-GCM encrypted at rest) + +import { decrypt } from "../../../crypto.server.ts"; +import { fetchKomootTours, fetchKomootTourGpx } from "../../../komoot.server.ts"; +import { isAlreadyImported, importActivity } from "../../../sync/imports.server.ts"; +import type { + CapabilityContext, + ImportableList, + ImportResult, + Importer, +} from "../../registry.ts"; + +type KomootCreds = + | { mode: "public"; komootUserId: string } + | { mode: "authenticated"; email: string; encryptedPassword: string; komootUserId: string }; + +function getBasicAuthToken(creds: KomootCreds): string | undefined { + if (creds.mode !== "authenticated") return undefined; + const password = decrypt(creds.encryptedPassword); + return Buffer.from(`${creds.email}:${password}`).toString("base64"); +} + +export const komootImporter: Importer = { + async listImportable(ctx: CapabilityContext, page: number): Promise { + return ctx.withFreshCredentials(async (rawCreds) => { + const creds = rawCreds as KomootCreds; + const basicAuthToken = getBasicAuthToken(creds); + const result = await fetchKomootTours(creds.komootUserId, page, basicAuthToken); + return { + workouts: result.tours.map((t) => ({ + id: t.id, + name: t.name, + type: t.sport, + startedAt: t.date, + duration: t.duration > 0 ? t.duration : null, + distance: t.distance > 0 ? t.distance : null, + // fileUrl not used for Komoot — GPX is fetched directly in the import route + })), + total: result.tours.length * result.totalPages, + page, + perPage: 50, + }; + }); + }, + + async importOne(ctx: CapabilityContext, tourId: string): Promise { + return ctx.withFreshCredentials(async (rawCreds) => { + const creds = rawCreds as KomootCreds; + + const { getServiceById } = await import("../../manager.ts"); + const service = await getServiceById(ctx.serviceId); + if (!service) throw new Error(`Connected service ${ctx.serviceId} not found`); + + const userId = service.userId; + if (await isAlreadyImported(userId, "komoot", tourId)) { + throw new Error(`Tour ${tourId} already imported`); + } + + const basicAuthToken = getBasicAuthToken(creds); + + let gpx: string | undefined; + try { + gpx = await fetchKomootTourGpx(tourId, basicAuthToken); + } catch { + // GPX unavailable — import activity without geometry + } + + // Fetch first page to find tour name; fall back to generic name + let tourName = `Komoot tour ${tourId}`; + try { + const result = await fetchKomootTours(creds.komootUserId, 1, basicAuthToken); + const tour = result.tours.find((t) => t.id === tourId); + if (tour) tourName = tour.name; + } catch { + // Ignore — use fallback name + } + + const { activityId } = await importActivity(userId, "komoot", tourId, { + name: tourName, + gpx, + }); + + return { activityId, hadGeometry: !!gpx }; + }); + }, +}; diff --git a/apps/journal/app/lib/connected-services/providers/komoot/manifest.ts b/apps/journal/app/lib/connected-services/providers/komoot/manifest.ts new file mode 100644 index 0000000..dd142e8 --- /dev/null +++ b/apps/journal/app/lib/connected-services/providers/komoot/manifest.ts @@ -0,0 +1,20 @@ +// Komoot provider manifest. +// +// Komoot supports two credential modes: +// public — unauthenticated ownership verification via bio link +// authenticated — email + AES-256-GCM encrypted password +// +// Neither mode uses OAuth; credentials are managed via a custom connect page. + +import { noopCredentialAdapter } from "../../credential-adapters/noop.ts"; +import type { ProviderManifest } from "../../registry.ts"; +import { komootImporter } from "./importer.ts"; + +export const komootManifest: ProviderManifest = { + id: "komoot", + displayName: "Komoot", + credentialKind: "web-login", + credentialAdapter: noopCredentialAdapter, + connectUrl: "/settings/connections/komoot", + importer: komootImporter, +}; diff --git a/apps/journal/app/lib/connected-services/registry.ts b/apps/journal/app/lib/connected-services/registry.ts index 4146589..457a79b 100644 --- a/apps/journal/app/lib/connected-services/registry.ts +++ b/apps/journal/app/lib/connected-services/registry.ts @@ -96,6 +96,9 @@ export interface ProviderManifest { oauthConfig?: ProviderOAuthConfig; // OAuth scopes requested at connect time. Wahoo grants all-or-nothing. scopes?: string[]; + // Custom connect page URL. When set, the connections settings page links + // here instead of the default OAuth connect endpoint. + connectUrl?: string; // OAuth authorization URL builder (for the connect flow). buildAuthUrl?: (redirectUri: string, state: string) => string; // OAuth code exchange (for the callback). Returns the credential blob to diff --git a/apps/journal/app/lib/connected-services/types.ts b/apps/journal/app/lib/connected-services/types.ts index 4d614d5..6dec18e 100644 --- a/apps/journal/app/lib/connected-services/types.ts +++ b/apps/journal/app/lib/connected-services/types.ts @@ -1,7 +1,7 @@ // Types for the Connected Services architecture. See docs/adr/0001-0003 and // CONTEXT.md (Connected Services section). -export type CredentialKind = "oauth" | "web-login" | "device"; +export type CredentialKind = "oauth" | "web-login" | "device" | "public"; export type ConnectionStatus = "active" | "needs_relink" | "revoked"; 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..ce849e2 --- /dev/null +++ b/apps/journal/app/lib/crypto.server.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect, beforeEach } from "vitest"; + +// Set env before importing module +beforeEach(() => { + process.env.INTEGRATION_SECRET = "test-secret-for-unit-tests"; +}); + +const { encrypt, decrypt } = await import("./crypto.server.ts"); + +describe("crypto.server", () => { + it("roundtrips ASCII plaintext", () => { + const plaintext = "hello world"; + expect(decrypt(encrypt(plaintext))).toBe(plaintext); + }); + + it("roundtrips unicode plaintext", () => { + const plaintext = "Über dich 🏔️ trails.cool"; + expect(decrypt(encrypt(plaintext))).toBe(plaintext); + }); + + it("produces different ciphertext for the same input (random IV)", () => { + const plaintext = "same input"; + const c1 = encrypt(plaintext); + const c2 = encrypt(plaintext); + expect(c1).not.toBe(c2); + // But both decrypt correctly + expect(decrypt(c1)).toBe(plaintext); + expect(decrypt(c2)).toBe(plaintext); + }); + + it("throws on tampered auth tag", () => { + const encoded = encrypt("sensitive"); + // Decode the base64 payload, split on ":", flip bytes in the auth tag + const payload = Buffer.from(encoded, "base64").toString("utf8"); + const parts = payload.split(":"); + // parts: [ivHex, encryptedHex, authTagHex] + const authTagBuf = Buffer.from(parts[2]!, "hex"); + authTagBuf[0] = authTagBuf[0]! ^ 0xff; + parts[2] = authTagBuf.toString("hex"); + const tampered = Buffer.from(parts.join(":")).toString("base64"); + expect(() => decrypt(tampered)).toThrow(); + }); + + it("throws on invalid format", () => { + expect(() => decrypt(Buffer.from("notvalidformat").toString("base64"))).toThrow( + "Invalid encrypted payload format", + ); + }); +}); diff --git a/apps/journal/app/lib/crypto.server.ts b/apps/journal/app/lib/crypto.server.ts new file mode 100644 index 0000000..815ce3c --- /dev/null +++ b/apps/journal/app/lib/crypto.server.ts @@ -0,0 +1,41 @@ +// AES-256-GCM encrypt/decrypt for storing sensitive integration credentials. +// Key is derived from INTEGRATION_SECRET env var using scrypt. +// +// Encoded format: base64(salt_hex:iv_hex:ciphertext_hex:authtag_hex) +// All fields are hex-encoded before joining so the separator is unambiguous. + +import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from "node:crypto"; + +const SALT = "trails-cool-integrations"; +const KEY_LEN = 32; // AES-256 +const IV_LEN = 12; // GCM standard + +function getKey(): Buffer { + const secret = process.env.INTEGRATION_SECRET; + if (!secret) throw new Error("INTEGRATION_SECRET env var is not set"); + return scryptSync(secret, SALT, KEY_LEN) as Buffer; +} + +export function encrypt(plaintext: string): string { + const key = getKey(); + const iv = randomBytes(IV_LEN); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + const authTag = cipher.getAuthTag(); + const payload = [iv.toString("hex"), encrypted.toString("hex"), authTag.toString("hex")].join(":"); + return Buffer.from(payload).toString("base64"); +} + +export function decrypt(encoded: string): string { + const key = getKey(); + const payload = Buffer.from(encoded, "base64").toString("utf8"); + const parts = payload.split(":"); + if (parts.length !== 3) throw new Error("Invalid encrypted payload format"); + const [ivHex, encryptedHex, authTagHex] = parts as [string, string, string]; + const iv = Buffer.from(ivHex, "hex"); + const encrypted = Buffer.from(encryptedHex, "hex"); + const authTag = Buffer.from(authTagHex, "hex"); + const decipher = createDecipheriv("aes-256-gcm", key, iv); + decipher.setAuthTag(authTag); + return decipher.update(encrypted) + decipher.final("utf8"); +} 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..9e5e3a9 --- /dev/null +++ b/apps/journal/app/lib/komoot.server.test.ts @@ -0,0 +1,184 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + parseKomootUserId, + verifyKomootOwnership, + fetchPublicProfile, + fetchKomootTours, +} from "./komoot.server.ts"; + +describe("parseKomootUserId", () => { + it("accepts a plain numeric ID", () => { + expect(parseKomootUserId("27595800585")).toBe("27595800585"); + }); + + it("accepts a full komoot.com URL", () => { + expect(parseKomootUserId("https://www.komoot.com/user/27595800585")).toBe("27595800585"); + }); + + it("accepts a locale-prefixed URL (de-de)", () => { + expect(parseKomootUserId("https://www.komoot.com/de-de/user/27595800585")).toBe("27595800585"); + }); + + it("accepts komoot.de URL", () => { + expect(parseKomootUserId("https://www.komoot.de/user/12345")).toBe("12345"); + }); + + it("returns null for an invalid input", () => { + expect(parseKomootUserId("not-a-user")).toBeNull(); + expect(parseKomootUserId("https://example.com/user/abc")).toBeNull(); + }); +}); + +describe("fetchPublicProfile", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + it("maps response fields correctly", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + username: "27595800585", + display_name: "Test User", + content: { text: "Visit https://trails.cool/users/ullrich for my routes", link: null }, + }), + } as Response); + + const profile = await fetchPublicProfile("27595800585"); + expect(profile.displayName).toBe("Test User"); + expect(profile.contentText).toBe("Visit https://trails.cool/users/ullrich for my routes"); + expect(profile.contentLink).toBeNull(); + }); + + it("falls back to username when display_name missing", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ username: "99999", content: {} }), + } as Response); + + const profile = await fetchPublicProfile("99999"); + expect(profile.displayName).toBe("99999"); + }); + + it("throws on non-ok response", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 404 } as Response); + await expect(fetchPublicProfile("bad")).rejects.toThrow("404"); + }); +}); + +describe("verifyKomootOwnership", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + it("returns true when bio contains the trails URL (exact match)", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + username: "123", + content: { text: "https://trails.cool/users/ullrich" }, + }), + } as Response); + expect(await verifyKomootOwnership("123", "https://trails.cool/users/ullrich")).toBe(true); + }); + + it("returns true for case-insensitive match", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + username: "123", + content: { text: "HTTPS://TRAILS.COOL/USERS/ULLRICH" }, + }), + } as Response); + expect(await verifyKomootOwnership("123", "https://trails.cool/users/ullrich")).toBe(true); + }); + + it("returns false when bio does not contain the trails URL", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + username: "123", + content: { text: "Just a normal bio with no trails link" }, + }), + } as Response); + expect(await verifyKomootOwnership("123", "https://trails.cool/users/ullrich")).toBe(false); + }); + + it("returns false when content_text is null", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ username: "123", content: { text: null } }), + } as Response); + expect(await verifyKomootOwnership("123", "https://trails.cool/users/ullrich")).toBe(false); + }); + + it("returns false when fetch fails", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ ok: false, status: 404 } as Response); + expect(await verifyKomootOwnership("bad", "https://trails.cool/users/x")).toBe(false); + }); +}); + +describe("fetchKomootTours", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + }); + + it("appends status=public when no auth token", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + _embedded: { tours: [] }, + page: { totalPages: 1, number: 0 }, + }), + } as Response); + await fetchKomootTours("123", 1); + const [url] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]; + expect(url).toContain("status=public"); + }); + + it("omits status=public when auth token provided", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + _embedded: { tours: [] }, + page: { totalPages: 1, number: 0 }, + }), + } as Response); + await fetchKomootTours("123", 1, "sometoken"); + const [url] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]; + expect(url).not.toContain("status=public"); + }); + + it("maps tour fields correctly", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + _embedded: { + tours: [ + { + id: 987654321, + name: "Morning ride", + type: "touringbicycle", + date: "2024-06-01T08:00:00Z", + distance: 42000, + duration: 7200, + elevation_up: 300, + elevation_down: 290, + }, + ], + }, + page: { totalPages: 3, number: 0 }, + }), + } as Response); + const result = await fetchKomootTours("123", 1); + expect(result.tours).toHaveLength(1); + expect(result.tours[0]).toMatchObject({ + id: "987654321", + name: "Morning ride", + sport: "touringbicycle", + distance: 42000, + duration: 7200, + }); + expect(result.totalPages).toBe(3); + }); +}); diff --git a/apps/journal/app/lib/komoot.server.ts b/apps/journal/app/lib/komoot.server.ts new file mode 100644 index 0000000..c9ac75c --- /dev/null +++ b/apps/journal/app/lib/komoot.server.ts @@ -0,0 +1,188 @@ +// Komoot API client. Covers unauthenticated public profile access and +// authenticated tour listing / GPX export. +// +// All network calls use plain fetch; no auth state is cached here. + +const API_BASE = "https://api.komoot.de/v007"; +const AUTH_BASE = "https://api.komoot.de/v006"; + +export interface KomootTour { + id: string; + name: string; + sport: string; + date: string; + distance: number; + duration: number; + elevationUp: number; + elevationDown: number; +} + +// --------------------------------------------------------------------------- +// URL parsing +// --------------------------------------------------------------------------- + +// Accepts a plain numeric ID or a Komoot profile URL in any locale variant. +// Returns the numeric user ID string, or null if it cannot be parsed. +export function parseKomootUserId(input: string): string | null { + const trimmed = input.trim(); + // Plain numeric ID + if (/^\d+$/.test(trimmed)) return trimmed; + // URL forms: https://www.komoot.com/user/12345 or .../de-de/user/12345 + const match = trimmed.match(/komoot\.(?:com|de)\/(?:[a-z]{2}-[a-z]{2}\/)?user\/(\d+)/i); + return match?.[1] ?? null; +} + +// --------------------------------------------------------------------------- +// Public profile +// --------------------------------------------------------------------------- + +interface KomootPublicProfileResponse { + username: string; + display_name?: string; + content?: { + text?: string | null; + link?: string | null; + }; +} + +export async function fetchPublicProfile(komootUserId: string): Promise<{ + displayName: string; + contentText: string | null; + contentLink: string | null; +}> { + const resp = await fetch(`${API_BASE}/users/${komootUserId}/`); + if (!resp.ok) { + throw new Error(`Komoot profile fetch failed: ${resp.status}`); + } + const data = (await resp.json()) as KomootPublicProfileResponse; + return { + displayName: data.display_name ?? data.username, + contentText: data.content?.text ?? null, + contentLink: data.content?.link ?? null, + }; +} + +// Returns true if the profile's content text contains the trails profile URL +// (case-insensitive, trimmed). +export async function verifyKomootOwnership( + komootUserId: string, + trailsProfileUrl: string, +): Promise { + try { + const profile = await fetchPublicProfile(komootUserId); + if (!profile.contentText) return false; + return profile.contentText.toLowerCase().includes(trailsProfileUrl.toLowerCase().trim()); + } catch { + return false; + } +} + +// --------------------------------------------------------------------------- +// Authentication +// --------------------------------------------------------------------------- + +interface KomootAuthResponse { + username: string; + // The API returns user data nested under the email key in v006 + email?: string; +} + +// Login with email+password via Basic auth. Returns the Komoot username +// (numeric user ID) and the base64-encoded Basic auth token. +export async function loginKomoot( + email: string, + password: string, +): Promise<{ username: string; basicAuthToken: string }> { + const basicAuthToken = Buffer.from(`${email}:${password}`).toString("base64"); + const resp = await fetch(`${AUTH_BASE}/account/email/${encodeURIComponent(email)}/`, { + headers: { Authorization: `Basic ${basicAuthToken}` }, + }); + if (!resp.ok) { + throw new Error(`Komoot login failed: ${resp.status}`); + } + const data = (await resp.json()) as KomootAuthResponse; + return { username: data.username, basicAuthToken }; +} + +// --------------------------------------------------------------------------- +// Tours +// --------------------------------------------------------------------------- + +interface KomootTourRaw { + id: number; + name: string; + type: string; + date: string; + distance?: number; + duration?: number; + elevation_up?: number; + elevation_down?: number; +} + +interface KomootToursResponse { + _embedded?: { + tours?: KomootTourRaw[]; + }; + page?: { + totalPages?: number; + number?: number; + }; +} + +function toTour(raw: KomootTourRaw): KomootTour { + return { + id: String(raw.id), + name: raw.name || `Tour ${raw.id}`, + sport: raw.type, + date: raw.date, + distance: raw.distance ?? 0, + duration: raw.duration ?? 0, + elevationUp: raw.elevation_up ?? 0, + elevationDown: raw.elevation_down ?? 0, + }; +} + +export async function fetchKomootTours( + komootUserId: string, + page: number, + basicAuthToken?: string, +): Promise<{ tours: KomootTour[]; totalPages: number }> { + const params = new URLSearchParams({ + page: String(page - 1), // Komoot uses 0-based pages + limit: "50", + }); + if (!basicAuthToken) { + params.set("status", "public"); + } + const headers: Record = {}; + if (basicAuthToken) { + headers["Authorization"] = `Basic ${basicAuthToken}`; + } + const resp = await fetch(`${API_BASE}/users/${komootUserId}/tours/?${params}`, { headers }); + if (!resp.ok) { + throw new Error(`Komoot tours fetch failed: ${resp.status}`); + } + const data = (await resp.json()) as KomootToursResponse; + const tours = (data._embedded?.tours ?? []).map(toTour); + const totalPages = data.page?.totalPages ?? 1; + return { tours, totalPages }; +} + +// --------------------------------------------------------------------------- +// GPX export +// --------------------------------------------------------------------------- + +export async function fetchKomootTourGpx( + tourId: string, + basicAuthToken?: string, +): Promise { + const headers: Record = {}; + if (basicAuthToken) { + headers["Authorization"] = `Basic ${basicAuthToken}`; + } + const resp = await fetch(`${API_BASE}/tours/${tourId}.gpx`, { headers }); + if (!resp.ok) { + throw new Error(`Komoot GPX fetch failed: ${resp.status}`); + } + return resp.text(); +} diff --git a/apps/journal/app/routes.ts b/apps/journal/app/routes.ts index ea456b1..ddfeba9 100644 --- a/apps/journal/app/routes.ts +++ b/apps/journal/app/routes.ts @@ -44,12 +44,16 @@ export default [ route("account", "routes/settings.account.tsx"), route("security", "routes/settings.security.tsx"), route("connections", "routes/settings.connections.tsx"), + route("connections/komoot", "routes/settings.connections.komoot.tsx"), ]), route("api/settings/profile", "routes/api.settings.profile.ts"), route("api/settings/email", "routes/api.settings.email.ts"), route("api/settings/passkey/delete", "routes/api.settings.passkey.delete.ts"), route("api/settings/delete-account", "routes/api.settings.delete-account.ts"), + route("sync/import/komoot", "routes/sync.import.komoot.tsx"), route("sync/import/:provider", "routes/sync.import.$provider.tsx"), + route("api/sync/komoot/verify", "routes/api.sync.komoot.verify.ts"), + route("api/sync/komoot/connect", "routes/api.sync.komoot.connect.ts"), route("api/sync/connect/:provider", "routes/api.sync.connect.$provider.ts"), route("api/sync/callback/:provider", "routes/api.sync.callback.$provider.ts"), route("api/sync/disconnect/:provider", "routes/api.sync.disconnect.$provider.ts"), diff --git a/apps/journal/app/routes/api.sync.komoot.connect.ts b/apps/journal/app/routes/api.sync.komoot.connect.ts new file mode 100644 index 0000000..55c25ee --- /dev/null +++ b/apps/journal/app/routes/api.sync.komoot.connect.ts @@ -0,0 +1,48 @@ +// POST /api/sync/komoot/connect +// Validates Komoot email/password credentials and stores them encrypted. + +import { data, redirect } from "react-router"; +import type { Route } from "./+types/api.sync.komoot.connect"; +import { getSessionUser } from "~/lib/auth/session.server"; +import { loginKomoot } from "~/lib/komoot.server"; +import { encrypt } from "~/lib/crypto.server"; +import { link } from "~/lib/connected-services/manager"; + +export async function action({ request }: Route.ActionArgs) { + const user = await getSessionUser(request); + if (!user) return redirect("/auth/login"); + + const body = (await request.json()) as { email?: string; password?: string }; + const email = body.email?.trim() ?? ""; + const password = body.password ?? ""; + + if (!email || !password) { + return data({ error: "missing_fields" }, { status: 400 }); + } + + let username: string; + try { + const result = await loginKomoot(email, password); + username = result.username; + } catch { + return data({ error: "invalid_credentials" }, { status: 401 }); + } + + const encryptedPassword = encrypt(password); + + await link({ + userId: user.id, + provider: "komoot", + credentialKind: "web-login", + credentials: { + mode: "authenticated", + email, + encryptedPassword, + komootUserId: username, + }, + providerUserId: username, + grantedScopes: [], + }); + + return data({ success: true }); +} diff --git a/apps/journal/app/routes/api.sync.komoot.verify.ts b/apps/journal/app/routes/api.sync.komoot.verify.ts new file mode 100644 index 0000000..da30daf --- /dev/null +++ b/apps/journal/app/routes/api.sync.komoot.verify.ts @@ -0,0 +1,41 @@ +// POST /api/sync/komoot/verify +// Verifies Komoot public profile ownership by checking that the user's +// trails.cool profile URL appears in their Komoot bio. +// On success, creates or replaces the connected service row in public mode. + +import { data, redirect } from "react-router"; +import type { Route } from "./+types/api.sync.komoot.verify"; +import { getSessionUser } from "~/lib/auth/session.server"; +import { parseKomootUserId, verifyKomootOwnership } from "~/lib/komoot.server"; +import { link } from "~/lib/connected-services/manager"; +export async function action({ request }: Route.ActionArgs) { + const user = await getSessionUser(request); + if (!user) return redirect("/auth/login"); + + const body = (await request.json()) as { komootProfileUrl?: string }; + const input = body.komootProfileUrl?.trim() ?? ""; + + const komootUserId = parseKomootUserId(input); + if (!komootUserId) { + return data({ error: "invalid_url" }, { status: 400 }); + } + + const origin = process.env.ORIGIN ?? "http://localhost:3000"; + const trailsProfileUrl = `${origin}/users/${user.username}`; + + const verified = await verifyKomootOwnership(komootUserId, trailsProfileUrl); + if (!verified) { + return data({ error: "not_verified" }, { status: 422 }); + } + + await link({ + userId: user.id, + provider: "komoot", + credentialKind: "public", + credentials: { mode: "public", komootUserId }, + providerUserId: komootUserId, + grantedScopes: [], + }); + + return data({ success: true }); +} diff --git a/apps/journal/app/routes/legal.privacy.tsx b/apps/journal/app/routes/legal.privacy.tsx index 18a0800..ce4b14a 100644 --- a/apps/journal/app/routes/legal.privacy.tsx +++ b/apps/journal/app/routes/legal.privacy.tsx @@ -284,6 +284,15 @@ export default function PrivacyPage() { Routenversion erfolgt höchstens eine Übermittlung; Sie können die Wahoo-Verbindung jederzeit in den Einstellungen trennen. +
  • + Komoot (Komoot GmbH) – Optionaler Import von + Touren aus Komoot. Im öffentlichen Modus wird ausschließlich + die Komoot-Nutzer-ID gespeichert; es werden keine Zugangsdaten + erfasst. Im authentifizierten Modus werden E-Mail-Adresse und + Passwort verschlüsselt gespeichert, um auch private Touren zu + importieren. Die Verbindung kann jederzeit in den Einstellungen + getrennt werden. +
  • Hosting – Die Dienste werden in Rechenzentren innerhalb der EU betrieben. Ein Auftragsverarbeitungsvertrag mit @@ -300,7 +309,9 @@ export default function PrivacyPage() { Wahoo (only when you opt in: OAuth tokens for sync, plus route geometry/name/description when you click “Send to Wahoo” on a route — sent so the route appears on your - ELEMNT/BOLT/ROAM); hosting provider in the EU under a DPA. + ELEMNT/BOLT/ROAM); Komoot (only when you opt in: public mode + stores your Komoot user ID only; authenticated mode stores your + encrypted Komoot password); hosting provider in the EU under a DPA.

    diff --git a/apps/journal/app/routes/settings.connections.komoot.tsx b/apps/journal/app/routes/settings.connections.komoot.tsx new file mode 100644 index 0000000..3b3d2f3 --- /dev/null +++ b/apps/journal/app/routes/settings.connections.komoot.tsx @@ -0,0 +1,204 @@ +// Komoot connection management page. Supports two modes: +// Public — verify ownership via bio link (no password stored) +// Authenticated — email + password (password encrypted at rest) + +import { useState } from "react"; +import { data, redirect, useFetcher } from "react-router"; +import { useTranslation } from "react-i18next"; +import type { Route } from "./+types/settings.connections.komoot"; +import { getSessionUser } from "~/lib/auth/session.server"; +import { getService } from "~/lib/connected-services/manager"; + +export function meta() { + return [{ title: "Connect Komoot — Settings — trails.cool" }]; +} + +export async function loader({ request }: Route.LoaderArgs) { + const user = await getSessionUser(request); + if (!user) throw redirect("/auth/login"); + + const service = await getService(user.id, "komoot"); + const origin = process.env.ORIGIN ?? "http://localhost:3000"; + const trailsProfileUrl = `${origin}/users/${user.username}`; + + return data({ + connected: !!service, + mode: service ? (service.credentials as { mode?: string }).mode ?? null : null, + providerUserId: service?.providerUserId ?? null, + serviceId: service?.id ?? null, + trailsProfileUrl, + }); +} + +type VerifyResponse = { success?: boolean; error?: string }; +type ConnectResponse = { success?: boolean; error?: string }; + +export default function KomootConnectPage({ loaderData }: Route.ComponentProps) { + const { connected, mode, providerUserId, serviceId, trailsProfileUrl } = loaderData; + const { t } = useTranslation("journal"); + + const verifyFetcher = useFetcher(); + const connectFetcher = useFetcher(); + + const [komootProfileUrl, setKomootProfileUrl] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + + const isVerifying = verifyFetcher.state !== "idle"; + const isConnecting = connectFetcher.state !== "idle"; + + const verifyError = verifyFetcher.data?.error; + const connectError = connectFetcher.data?.error; + + function handleVerify(e: React.FormEvent) { + e.preventDefault(); + verifyFetcher.submit( + JSON.stringify({ komootProfileUrl }), + { method: "post", action: "/api/sync/komoot/verify", encType: "application/json" }, + ); + } + + function handleConnect(e: React.FormEvent) { + e.preventDefault(); + connectFetcher.submit( + JSON.stringify({ email, password }), + { method: "post", action: "/api/sync/komoot/connect", encType: "application/json" }, + ); + } + + return ( +
    +
    +

    {t("komoot.title")}

    + {connected && ( +
    + + {t("komoot.modeBadge", { mode: mode === "public" ? t("komoot.publicMode") : t("komoot.authenticatedMode") })} + + {providerUserId && ( + + {t("settings.services.connectedAs", { id: providerUserId })} + + )} + + {t("sync.import")} + + {serviceId && ( +
    + +
    + )} +
    + )} +
    + + {verifyFetcher.data?.success && ( +
    + {t("komoot.verifySuccess")} +
    + )} + {connectFetcher.data?.success && ( +
    + {t("komoot.connectSuccess")} +
    + )} + + {/* Public mode section */} +
    +

    {t("komoot.publicSection")}

    +

    + {t("komoot.publicInstructions")} +

    +
    + {trailsProfileUrl} +
    +
    +
    + + setKomootProfileUrl(e.target.value)} + placeholder={t("komoot.profileUrlPlaceholder")} + className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + required + /> +
    + +
    + {verifyError && ( +

    + {verifyError === "not_verified" + ? t("komoot.verificationError") + : verifyError === "invalid_url" + ? t("komoot.invalidUrl") + : t("komoot.verificationError")} +

    + )} + {isVerifying && ( +

    {t("komoot.verifyPending")}

    + )} +
    + + {/* Authenticated mode section */} +
    +

    {t("komoot.authenticatedSection")}

    +
    +
    + + setEmail(e.target.value)} + autoComplete="username" + className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + required + /> +
    +
    + + setPassword(e.target.value)} + autoComplete="current-password" + className="mt-1 w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500" + required + /> +
    + +
    + {connectError && ( +

    + {connectError === "invalid_credentials" + ? t("komoot.authError") + : t("komoot.authError")} +

    + )} +
    +
    + ); +} diff --git a/apps/journal/app/routes/settings.connections.tsx b/apps/journal/app/routes/settings.connections.tsx index b0d905e..e0c85af 100644 --- a/apps/journal/app/routes/settings.connections.tsx +++ b/apps/journal/app/routes/settings.connections.tsx @@ -37,6 +37,7 @@ export async function loader({ request }: Route.LoaderArgs) { name: m.displayName, connected: !!conn, providerUserId: conn?.providerUserId, + connectUrl: m.connectUrl ?? null, }; }); @@ -94,7 +95,7 @@ export default function ConnectionsSettings({ loaderData }: Route.ComponentProps ) : ( {t("settings.services.connect")} diff --git a/apps/journal/app/routes/sync.import.komoot.tsx b/apps/journal/app/routes/sync.import.komoot.tsx new file mode 100644 index 0000000..5b91086 --- /dev/null +++ b/apps/journal/app/routes/sync.import.komoot.tsx @@ -0,0 +1,260 @@ +// Komoot-specific import page. Mirrors the generic sync.import.$provider page +// but fetches GPX directly from Komoot instead of downloading a FIT file. + +import { useCallback, useEffect, useRef, useState } from "react"; +import { data, redirect, useFetcher } from "react-router"; +import { useTranslation } from "react-i18next"; +import type { Route } from "./+types/sync.import.komoot"; +import { getSessionUser } from "~/lib/auth/session.server"; +import { getService, capabilityContextFor } from "~/lib/connected-services"; +import { getManifest } from "~/lib/connected-services"; +import { getImportedIds, recordImport } from "~/lib/sync/imports.server"; +import { createActivity } from "~/lib/activities.server"; +import { fetchKomootTourGpx } from "~/lib/komoot.server"; +import { decrypt } from "~/lib/crypto.server"; +import { ClientDate } from "~/components/ClientDate"; + +type KomootCreds = + | { mode: "public"; komootUserId: string } + | { mode: "authenticated"; email: string; encryptedPassword: string; komootUserId: string }; + +function getBasicAuthToken(creds: KomootCreds): string | undefined { + if (creds.mode !== "authenticated") return undefined; + const password = decrypt(creds.encryptedPassword); + return Buffer.from(`${creds.email}:${password}`).toString("base64"); +} + +export function meta() { + return [{ title: "Import from Komoot — trails.cool" }]; +} + +export async function loader({ request }: Route.LoaderArgs) { + const user = await getSessionUser(request); + if (!user) return redirect("/auth/login"); + + const manifest = getManifest("komoot"); + if (!manifest?.importer) throw data({ error: "Komoot not configured" }, { status: 500 }); + + const service = await getService(user.id, "komoot"); + if (!service) return redirect("/settings/connections/komoot"); + + const url = new URL(request.url); + const page = parseInt(url.searchParams.get("page") ?? "1"); + + const ctx = capabilityContextFor(service.id); + const workoutList = await manifest.importer.listImportable(ctx, page); + + const importedIds = await getImportedIds( + user.id, + "komoot", + workoutList.workouts.map((w) => w.id), + ); + + return data({ + workouts: workoutList.workouts.map((w) => ({ + ...w, + imported: importedIds.has(w.id), + })), + page: workoutList.page, + totalPages: Math.ceil(workoutList.total / workoutList.perPage), + }); +} + +export async function action({ request }: Route.ActionArgs) { + const user = await getSessionUser(request); + if (!user) return redirect("/auth/login"); + + const service = await getService(user.id, "komoot"); + if (!service) return redirect("/settings/connections/komoot"); + + const formData = await request.formData(); + const tourId = formData.get("workoutId") as string; + const workoutName = formData.get("workoutName") as string; + const startedAt = formData.get("startedAt") as string; + const distance = formData.get("distance") as string; + const duration = formData.get("duration") as string; + + const creds = service.credentials as KomootCreds; + const basicAuthToken = getBasicAuthToken(creds); + + let gpx: string | undefined; + try { + gpx = await fetchKomootTourGpx(tourId, basicAuthToken); + } catch { + // No GPX available — import without geometry + } + + const activityId = await createActivity(user.id, { + name: workoutName || `Komoot tour ${tourId}`, + gpx, + distance: distance ? parseFloat(distance) : null, + duration: duration ? parseInt(duration) : null, + startedAt: startedAt ? new Date(startedAt) : null, + }); + + await recordImport(user.id, "komoot", tourId, activityId); + + return data({ imported: tourId }); +} + +function TourRow({ + workout, + alreadyImported, +}: { + workout: { id: string; name: string; startedAt: string; distance: number | null; duration: number | null }; + alreadyImported: boolean; +}) { + const { t } = useTranslation("journal"); + const fetcher = useFetcher<{ imported?: string }>(); + const isImporting = fetcher.state !== "idle"; + const isImported = alreadyImported || fetcher.data?.imported === workout.id; + + return ( +
  • +
    +

    {workout.name}

    +
    + {workout.startedAt && } + {workout.distance != null && {(workout.distance / 1000).toFixed(1)} km} + {workout.duration != null && {Math.round(workout.duration / 60)} min} +
    +
    + {isImported ? ( + {t("sync.imported")} + ) : isImporting ? ( + {t("sync.importing")} + ) : ( + + + + + + + + + )} +
  • + ); +} + +function tourFormData(w: { id: string; name: string; startedAt: string; distance: number | null; duration: number | null }) { + const fd = new FormData(); + fd.set("workoutId", w.id); + fd.set("workoutName", w.name); + fd.set("startedAt", w.startedAt ?? ""); + fd.set("distance", w.distance != null ? String(w.distance) : ""); + fd.set("duration", w.duration != null ? String(w.duration) : ""); + return fd; +} + +export default function KomootImportPage({ loaderData }: Route.ComponentProps) { + const { workouts, page, totalPages } = loaderData; + const { t } = useTranslation("journal"); + + const importableWorkouts = workouts.filter((w) => !w.imported); + const [importAllActive, setImportAllActive] = useState(false); + const [importAllIndex, setImportAllIndex] = useState(0); + const importAllFetcher = useFetcher<{ imported?: string }>(); + const importAllRef = useRef({ index: 0, workouts: importableWorkouts }); + + useEffect(() => { + importAllRef.current.workouts = importableWorkouts; + }, [importableWorkouts]); + + useEffect(() => { + if (!importAllActive) return; + if (importAllFetcher.state !== "idle") return; + + const nextIndex = importAllFetcher.data + ? importAllRef.current.index + 1 + : importAllRef.current.index; + importAllRef.current.index = nextIndex; + setImportAllIndex(nextIndex); + + if (nextIndex >= importAllRef.current.workouts.length) { + setImportAllActive(false); + return; + } + + const w = importAllRef.current.workouts[nextIndex]!; + importAllFetcher.submit(tourFormData(w), { method: "post" }); + }, [importAllActive, importAllFetcher.state, importAllFetcher.data]); + + const startImportAll = useCallback(() => { + if (importableWorkouts.length === 0) return; + importAllRef.current = { index: 0, workouts: importableWorkouts }; + setImportAllIndex(0); + setImportAllActive(true); + importAllFetcher.submit(tourFormData(importableWorkouts[0]!), { method: "post" }); + }, [importableWorkouts, importAllFetcher]); + + return ( + + ); +} diff --git a/e2e/komoot-import.test.ts b/e2e/komoot-import.test.ts new file mode 100644 index 0000000..076a746 --- /dev/null +++ b/e2e/komoot-import.test.ts @@ -0,0 +1,262 @@ +import { test, expect, waitForHydration, type CDPSession, type Page } from "./fixtures/test"; + +const KOMOOT_USER_ID = "99999999999"; +const KOMOOT_PROFILE_URL = `https://www.komoot.com/user/${KOMOOT_USER_ID}`; + +const SAMPLE_GPX = ` + + + Morning Hike + + 520 + 525 + 530 + + +`; + +const MOCK_TOURS_PAGE = { + _embedded: { + tours: [ + { + id: 111111111, + name: "Morning Hike", + type: "hike", + date: "2024-06-01T08:00:00.000Z", + distance: 12500, + duration: 7200, + elevation_up: 350, + elevation_down: 350, + }, + ], + }, + page: { totalPages: 1, number: 0 }, +}; + +async function setupVirtualAuthenticator(cdp: CDPSession) { + await cdp.send("WebAuthn.enable"); + const { authenticatorId } = await cdp.send("WebAuthn.addVirtualAuthenticator", { + options: { + protocol: "ctap2", + transport: "internal", + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + }, + }); + return authenticatorId; +} + +async function removeVirtualAuthenticator(cdp: CDPSession, authenticatorId: string) { + await cdp.send("WebAuthn.removeVirtualAuthenticator", { authenticatorId }); + await cdp.send("WebAuthn.disable"); +} + +async function registerAndLogin(page: Page, cdp: CDPSession) { + const ts = Date.now(); + const email = `komoot-${ts}@example.com`; + const username = `komootuser${ts}`; + + await page.goto("/auth/register"); + await page.getByLabel("Email").fill(email); + await page.getByLabel("Username").fill(username); + await page.getByRole("button", { name: /Register with Passkey/ }).click(); + await expect(page).toHaveURL("/", { timeout: 10000 }); + + return { email, username }; +} + +function mockKomootPublicProfile(page: Page, userId: string, trailsProfileUrl: string) { + return page.route(`https://api.komoot.de/v007/users/${userId}/`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + username: userId, + display_name: "Test Komoot User", + content: { + text: `Check out my trails profile: ${trailsProfileUrl}`, + link: null, + }, + }), + }), + ); +} + +function mockKomootPublicProfileNoMatch(page: Page, userId: string) { + return page.route(`https://api.komoot.de/v007/users/${userId}/`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + username: userId, + display_name: "Test Komoot User", + content: { text: "I like hiking", link: null }, + }), + }), + ); +} + +function mockKomootTours(page: Page, userId: string) { + return page.route( + (url) => url.href.includes(`api.komoot.de/v007/users/${userId}/tours`), + (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify(MOCK_TOURS_PAGE), + }), + ); +} + +function mockKomootGpx(page: Page, tourId: string) { + return page.route( + (url) => url.href.includes(`api.komoot.de/v007/tours/${tourId}.gpx`), + (route) => + route.fulfill({ + status: 200, + contentType: "application/gpx+xml", + body: SAMPLE_GPX, + }), + ); +} + +test.describe("Komoot connection page", () => { + test("unauthenticated user is redirected to login", async ({ page }) => { + await page.goto("/settings/connections/komoot"); + await expect(page).toHaveURL(/\/auth\/login/, { timeout: 5000 }); + }); + + test("public verify — success path", async ({ page }) => { + const cdp = await page.context().newCDPSession(page); + const authenticatorId = await setupVirtualAuthenticator(cdp); + const { username } = await registerAndLogin(page, cdp); + + const trailsProfileUrl = `http://localhost:3000/users/${username}`; + await mockKomootPublicProfile(page, KOMOOT_USER_ID, trailsProfileUrl); + + await page.goto("/settings/connections/komoot"); + await waitForHydration(page); + + await page.getByLabel("Komoot profile URL or user ID").fill(KOMOOT_PROFILE_URL); + await page.getByRole("button", { name: "Verify" }).click(); + + await expect(page.getByRole("status")).toContainText("connected in public mode", { + timeout: 10000, + }); + + await removeVirtualAuthenticator(cdp, authenticatorId); + }); + + test("public verify — bio does not contain profile URL shows error", async ({ page }) => { + const cdp = await page.context().newCDPSession(page); + const authenticatorId = await setupVirtualAuthenticator(cdp); + await registerAndLogin(page, cdp); + + await mockKomootPublicProfileNoMatch(page, KOMOOT_USER_ID); + + await page.goto("/settings/connections/komoot"); + await waitForHydration(page); + + await page.getByLabel("Komoot profile URL or user ID").fill(KOMOOT_PROFILE_URL); + await page.getByRole("button", { name: "Verify" }).click(); + + await expect(page.getByRole("alert")).toContainText("Could not verify", { timeout: 10000 }); + + await removeVirtualAuthenticator(cdp, authenticatorId); + }); + + test("public verify — invalid URL shows error", async ({ page }) => { + const cdp = await page.context().newCDPSession(page); + const authenticatorId = await setupVirtualAuthenticator(cdp); + await registerAndLogin(page, cdp); + + await page.goto("/settings/connections/komoot"); + await waitForHydration(page); + + await page.getByLabel("Komoot profile URL or user ID").fill("not-a-valid-url"); + await page.getByRole("button", { name: "Verify" }).click(); + + await expect(page.getByRole("alert")).toContainText("valid Komoot", { timeout: 10000 }); + + await removeVirtualAuthenticator(cdp, authenticatorId); + }); + + test("authenticated connect — invalid credentials shows error", async ({ page }) => { + const cdp = await page.context().newCDPSession(page); + const authenticatorId = await setupVirtualAuthenticator(cdp); + await registerAndLogin(page, cdp); + + // Mock Komoot auth endpoint to reject + await page.route(/api\.komoot\.de\/v006\/account\/email/, (route) => + route.fulfill({ status: 401, body: "Unauthorized" }), + ); + + await page.goto("/settings/connections/komoot"); + await waitForHydration(page); + + await page.getByLabel("Komoot email").fill("wrong@example.com"); + await page.getByLabel("Komoot password").fill("wrongpassword"); + await page.getByRole("button", { name: "Connect" }).click(); + + await expect(page.getByRole("alert")).toContainText("Invalid Komoot", { timeout: 10000 }); + + await removeVirtualAuthenticator(cdp, authenticatorId); + }); +}); + +test.describe("Komoot import page", () => { + test("shows importable public tours after public verify", async ({ page }) => { + const cdp = await page.context().newCDPSession(page); + const authenticatorId = await setupVirtualAuthenticator(cdp); + const { username } = await registerAndLogin(page, cdp); + + const trailsProfileUrl = `http://localhost:3000/users/${username}`; + await mockKomootPublicProfile(page, KOMOOT_USER_ID, trailsProfileUrl); + await mockKomootTours(page, KOMOOT_USER_ID); + await mockKomootGpx(page, "111111111"); + + // Connect first + await page.goto("/settings/connections/komoot"); + await waitForHydration(page); + await page.getByLabel("Komoot profile URL or user ID").fill(KOMOOT_PROFILE_URL); + await page.getByRole("button", { name: "Verify" }).click(); + await expect(page.getByRole("status")).toContainText("connected in public mode", { + timeout: 10000, + }); + + // Navigate to import page + await page.goto("/sync/import/komoot"); + await expect(page.getByText("Morning Hike")).toBeVisible({ timeout: 10000 }); + + await removeVirtualAuthenticator(cdp, authenticatorId); + }); + + test("import tour creates a route", async ({ page }) => { + const cdp = await page.context().newCDPSession(page); + const authenticatorId = await setupVirtualAuthenticator(cdp); + const { username } = await registerAndLogin(page, cdp); + + const trailsProfileUrl = `http://localhost:3000/users/${username}`; + await mockKomootPublicProfile(page, KOMOOT_USER_ID, trailsProfileUrl); + await mockKomootTours(page, KOMOOT_USER_ID); + await mockKomootGpx(page, "111111111"); + + // Connect + await page.goto("/settings/connections/komoot"); + await waitForHydration(page); + await page.getByLabel("Komoot profile URL or user ID").fill(KOMOOT_PROFILE_URL); + await page.getByRole("button", { name: "Verify" }).click(); + await expect(page.getByRole("status")).toContainText("connected in public mode", { + timeout: 10000, + }); + + // Import + await page.goto("/sync/import/komoot"); + await expect(page.getByText("Morning Hike")).toBeVisible({ timeout: 10000 }); + await page.getByRole("button", { name: "Import" }).click(); + await expect(page.getByText("Imported")).toBeVisible({ timeout: 10000 }); + + await removeVirtualAuthenticator(cdp, authenticatorId); + }); +}); diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index 25bed2f..f54326d 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -418,6 +418,32 @@ export default { }, }, }, + komoot: { + title: "Komoot", + publicMode: "Nur öffentliche Touren", + authenticatedMode: "Alle Touren", + publicSection: "Verbinden mit Komoot-Bio-Verifizierung", + publicInstructions: + 'Füge deinen trails.cool-Profillink in dein Komoot-Profil ("Über dich") ein und klicke auf Bestätigen.', + profileUrlLabel: "Komoot-Profil-URL oder Nutzer-ID", + profileUrlPlaceholder: "https://www.komoot.com/user/...", + verifyButton: "Bestätigen", + verifying: "Wird überprüft…", + verifyPending: + "Es kann einige Minuten dauern, bis Änderungen an deinem Komoot-Profil sichtbar sind.", + verifySuccess: "Komoot-Konto im öffentlichen Modus verbunden.", + verificationError: + "Eigentümerschaft konnte nicht bestätigt werden. Stelle sicher, dass dein trails.cool-Profillink in deinem Komoot-Profil steht, und versuche es in ein paar Minuten erneut.", + invalidUrl: "Bitte gib eine gültige Komoot-Profil-URL oder Nutzer-ID ein.", + authenticatedSection: "Mit E-Mail & Passwort verbinden", + emailLabel: "Komoot-E-Mail", + passwordLabel: "Komoot-Passwort", + connectButton: "Verbinden", + connecting: "Verbinde…", + connectSuccess: "Komoot-Konto verbunden.", + authError: "Ungültige Komoot-Zugangsdaten. Bitte E-Mail und Passwort prüfen.", + modeBadge: "Modus: {{mode}}", + }, sync: { import: "Importieren", importFrom: "Import von {{provider}}", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index fe76246..f380998 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -418,6 +418,32 @@ export default { }, }, }, + komoot: { + title: "Komoot", + publicMode: "Public tours only", + authenticatedMode: "All tours", + publicSection: "Connect with Komoot bio verification", + publicInstructions: + 'Add your trails.cool profile link to your Komoot bio ("Über dich"), then click Verify.', + profileUrlLabel: "Komoot profile URL or user ID", + profileUrlPlaceholder: "https://www.komoot.com/user/...", + verifyButton: "Verify", + verifying: "Verifying…", + verifyPending: + "Allow a few minutes for changes to your Komoot bio to propagate before verifying.", + verifySuccess: "Komoot account connected in public mode.", + verificationError: + "Could not verify ownership. Make sure your trails.cool profile link is in your Komoot bio and try again in a few minutes.", + invalidUrl: "Please enter a valid Komoot profile URL or user ID.", + authenticatedSection: "Connect with email & password", + emailLabel: "Komoot email", + passwordLabel: "Komoot password", + connectButton: "Connect", + connecting: "Connecting…", + connectSuccess: "Komoot account connected.", + authError: "Invalid Komoot credentials. Please check your email and password.", + modeBadge: "Mode: {{mode}}", + }, sync: { import: "Import", importFrom: "Import from {{provider}}", diff --git a/playwright.config.ts b/playwright.config.ts index 99924cf..4947417 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -70,6 +70,14 @@ export default defineConfig({ baseURL: "http://localhost:3000", }, }, + { + name: "komoot-import", + testMatch: "komoot-import.test.ts", + use: { + ...devices["Desktop Chrome"], + baseURL: "http://localhost:3000", + }, + }, ], webServer: process.env.CI ? [