diff --git a/frontend/src/components/MapleLoadingMark.test.tsx b/frontend/src/components/MapleLoadingMark.test.tsx new file mode 100644 index 000000000..7e0ff977b --- /dev/null +++ b/frontend/src/components/MapleLoadingMark.test.tsx @@ -0,0 +1,178 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; + +import { MapleLoadingMark } from "./MapleLoadingMark"; + +/** Minimal stand-in for the SVGPathElement the component writes `d` to. */ +class FakePath { + readonly writes: string[] = []; + setAttribute(name: string, value: string): void { + if (name === "d") this.writes.push(value); + } + getAttribute(name: string): string | null { + return name === "d" ? (this.writes.at(-1) ?? null) : null; + } +} + +function points(d: string): Array<[number, number]> { + return d + .slice(0, -1) + .split(/(?=[ML])/) + .filter(Boolean) + .map((seg) => { + const [x, y] = seg.slice(1).split(" ").map(Number); + return [x, y] as [number, number]; + }); +} + +function bbox(pts: Array<[number, number]>) { + const xs = pts.map((p) => p[0]); + const ys = pts.map((p) => p[1]); + return { + minX: Math.min(...xs), + maxX: Math.max(...xs), + minY: Math.min(...ys), + maxY: Math.max(...ys) + }; +} + +const originalNow = globalThis.performance.now; +const originalRaf = globalThis.requestAnimationFrame; +const originalCaf = globalThis.cancelAnimationFrame; +const originalMatchMedia = globalThis.matchMedia; +const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); + +describe("MapleLoadingMark", () => { + let node: FakePath; + let pending: FrameRequestCallback | null; + let reduceMotion: boolean; + let clock: number; + let lastHandle: number; + let cancelled: number[]; + + beforeEach(() => { + node = new FakePath(); + pending = null; + reduceMotion = false; + // The component times itself off performance.now(), so the test drives that + // clock and the rAF timestamp together rather than passing bare numbers. + clock = 0; + lastHandle = 0; + cancelled = []; + globalThis.performance.now = () => clock; + globalThis.requestAnimationFrame = ((cb: FrameRequestCallback) => { + pending = cb; + lastHandle += 1; + return lastHandle; + }) as typeof requestAnimationFrame; + globalThis.cancelAnimationFrame = ((handle: number) => { + cancelled.push(handle); + }) as typeof cancelAnimationFrame; + globalThis.matchMedia = ((query: string) => ({ + matches: reduceMotion && query.includes("reduce") + })) as unknown as typeof matchMedia; + Object.defineProperty(globalThis, "window", { + configurable: true, + value: { matchMedia: globalThis.matchMedia }, + writable: true + }); + }); + + afterEach(() => { + globalThis.performance.now = originalNow; + globalThis.requestAnimationFrame = originalRaf; + globalThis.cancelAnimationFrame = originalCaf; + globalThis.matchMedia = originalMatchMedia; + if (originalWindow) Object.defineProperty(globalThis, "window", originalWindow); + else Reflect.deleteProperty(globalThis, "window"); + }); + + function render(): ReactTestRenderer { + let renderer!: ReactTestRenderer; + act(() => { + renderer = create(, { createNodeMock: () => node }); + }); + return renderer; + } + + /** Move the shared clock to `ms` and run the frame the component scheduled. */ + function advanceTo(ms: number) { + clock = ms; + act(() => { + const cb = pending; + pending = null; + cb?.(ms); + }); + } + + test("draws a closed 64-point ring", () => { + render(); + advanceTo(0); + const d = node.writes.at(-1)!; + expect(d.startsWith("M")).toBe(true); + expect(d.endsWith("Z")).toBe(true); + expect(points(d)).toHaveLength(64); + }); + + test("the first letter is the wordmark's M, centred in the 32-unit box", () => { + render(); + advanceTo(0); + const box = bbox(points(node.writes.at(-1)!)); + // The M of the mark is 27.02 x 23.99; centred leaves ~2.49 / ~4.0 of margin. + expect(box.maxX - box.minX).toBeCloseTo(27.02, 1); + expect(box.maxY - box.minY).toBeCloseTo(23.99, 1); + expect(box.minX).toBeCloseTo((32 - 27.02) / 2, 1); + expect(box.minY).toBeCloseTo((32 - 23.99) / 2, 1); + }); + + test("holds the letter, then morphs away from it", () => { + render(); + advanceTo(0); + const atRest = node.writes.at(-1)!; + advanceTo(100); // still inside the 140ms hold + expect(node.writes.at(-1)).toBe(atRest); + advanceTo(140); // hold elapses, morph begins + advanceTo(340); // ~halfway through the 400ms morph + expect(node.writes.at(-1)).not.toBe(atRest); + }); + + test("never renders the letter it just left on the frame a morph completes", () => { + // Regression: deriving the letter pair before advancing the state made the + // completing frame draw the source letter for one frame — a visible flash. + render(); + advanceTo(0); + const first = points(node.writes.at(-1)!); + advanceTo(140); // hold elapses, morph begins + advanceTo(540); // 400ms later the morph completes and the state advances + const after = points(node.writes.at(-1)!); + let maxDrift = 0; + for (let i = 0; i < first.length; i++) + maxDrift = Math.max( + maxDrift, + Math.hypot(after[i][0] - first[i][0], after[i][1] - first[i][1]) + ); + // it must have landed on the NEXT letter, not snapped back to the first + expect(maxDrift).toBeGreaterThan(5); + }); + + test("stops its animation frame when unmounted", () => { + // The auth screens unmount this the moment the callback resolves. Without the + // cleanup the loop keeps running against a detached node for the life of the page. + const renderer = render(); + advanceTo(0); + advanceTo(200); + const scheduled = lastHandle; + + act(() => renderer.unmount()); + + expect(cancelled).toContain(scheduled); + }); + + test("honours prefers-reduced-motion with a static mark", () => { + reduceMotion = true; + render(); + expect(node.writes).toHaveLength(1); + expect(pending).toBeNull(); + expect(points(node.writes[0])).toHaveLength(64); + }); +}); diff --git a/frontend/src/components/MapleLoadingMark.tsx b/frontend/src/components/MapleLoadingMark.tsx new file mode 100644 index 000000000..ea6c12cdd --- /dev/null +++ b/frontend/src/components/MapleLoadingMark.tsx @@ -0,0 +1,243 @@ +import { useEffect, useRef } from "react"; + +/** + * The Maple mark walking its own wordmark: M -> A -> P -> L -> E. + * + * Every letter of the mark is a single closed contour with no counter, so all five + * can be resampled to the same point count and interpolated directly. That is why + * this needs no morph library: `d` is just a lerp between two equal-length rings. + * + * Geometry is derived once, lazily, from the wordmark's own path data — so if the + * logo asset changes, the animation follows it instead of drifting out of sync. + */ + +// The five glyphs of the wordmark, viewBox "0 0 124 24". +const GLYPHS = [ + "M0 20.6204V3.03281C0 0.326049 2.89961-0.862295 5.11604 0.72215L13.5079 7.78051L21.8998 0.72215C24.1176-0.862295 27.0158 0.326049 27.0158 3.03281V20.6204C27.0158 22.4858 25.4925 23.9986 23.6141 23.9986H3.10046C1.08488 23.9986 0 22.8159 0 20.6204Z", + "M29.5038 19.9181L39.9905 1.68295C41.2805-0.557469 43.4432-0.564493 44.7374 1.68295L55.2198 19.9181C56.5946 22.3074 55.7474 24 52.9737 24H31.75C28.9777 24 28.1304 22.3102 29.5038 19.9181Z", + "M68.8833 19.7032V20.6204C68.8833 22.4858 67.36 23.9986 65.4816 23.9986H60.9709C59.0911 23.9986 57.5692 22.4858 57.5692 20.6204V3.78007C57.5692 1.91329 59.0926 0.400493 60.9723 0.400493H68.6697C77.6132 0.400493 80.7816 4.32788 80.7816 10.0659C80.7816 15.8039 77.6642 19.6414 68.8847 19.7032H68.8833Z", + "M82.4006 20.6204V3.78007C82.4006 1.91329 83.924 0.400493 85.8038 0.400493H90.3144C92.1942 0.400493 93.7162 1.91329 93.7162 3.77867V8.26653H98.2353C100.115 8.26653 101.637 9.77933 101.637 11.6447V20.619C101.637 22.4844 100.114 23.9972 98.2353 23.9972H85.8038C83.924 23.9972 82.4021 22.4844 82.4021 20.619L82.4006 20.6204Z", + "M120.598 8.26653H115.728C117.406 8.74271 118.632 10.2766 118.632 12.0942C118.632 14.013 117.269 15.6128 115.453 15.9921H120.607C122.81 15.9921 124 17.1706 124 19.3619V20.6274C124 22.8173 122.81 23.9986 120.604 23.9986H106.887C104.679 23.9986 103.491 22.8173 103.491 20.6274V3.77165C103.491 1.5804 104.679 0.400493 106.887 0.400493H120.604C122.81 0.400493 124 1.5804 124 3.77165V4.88834C124 6.75372 122.477 8.26512 120.6 8.26512L120.598 8.26653Z" +] as const; + +const N = 64; // points per letter; sub-pixel accurate at every size we render +const BOX = 32; + +type Ring = Float64Array; // [x0,y0,x1,y1,...] + +function parse(d: string): number[][] { + const tok = d.match(/[MLHVCZ]|-?\d*\.?\d+(?:e-?\d+)?/gi) ?? []; + const pts: number[][] = []; + let x = 0, + y = 0, + cmd = ""; + for (let i = 0; i < tok.length; ) { + if (/[MLHVCZ]/i.test(tok[i])) { + cmd = tok[i++]; + if (cmd === "Z") continue; + } + const num = () => parseFloat(tok[i++]); + if (cmd === "M") { + x = num(); + y = num(); + pts.push([x, y]); + cmd = "L"; + } else if (cmd === "L") { + x = num(); + y = num(); + pts.push([x, y]); + } else if (cmd === "H") { + x = num(); + pts.push([x, y]); + } else if (cmd === "V") { + y = num(); + pts.push([x, y]); + } else if (cmd === "C") { + const x1 = num(), + y1 = num(), + x2 = num(), + y2 = num(), + x3 = num(), + y3 = num(); + for (let s = 1; s <= 16; s++) { + const t = s / 16, + u = 1 - t; + pts.push([ + u * u * u * x + 3 * u * u * t * x1 + 3 * u * t * t * x2 + t * t * t * x3, + u * u * u * y + 3 * u * u * t * y1 + 3 * u * t * t * y2 + t * t * t * y3 + ]); + } + x = x3; + y = y3; + } else i++; + } + return pts; +} + +function resampleCentred(pts: number[][], n: number): Ring { + const len: number[] = []; + let total = 0; + for (let i = 0; i < pts.length; i++) { + const a = pts[i], + b = pts[(i + 1) % pts.length]; + const d = Math.hypot(b[0] - a[0], b[1] - a[1]); + len.push(d); + total += d; + } + const out = new Float64Array(n * 2); + const step = total / n; + let seg = 0, + acc = 0; + for (let k = 0; k < n; k++) { + const target = k * step; + while (seg < len.length - 1 && acc + len[seg] < target) acc += len[seg++]; + const a = pts[seg], + b = pts[(seg + 1) % pts.length]; + const t = len[seg] ? (target - acc) / len[seg] : 0; + out[k * 2] = a[0] + (b[0] - a[0]) * t; + out[k * 2 + 1] = a[1] + (b[1] - a[1]) * t; + } + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (let k = 0; k < n; k++) { + minX = Math.min(minX, out[k * 2]); + maxX = Math.max(maxX, out[k * 2]); + minY = Math.min(minY, out[k * 2 + 1]); + maxY = Math.max(maxY, out[k * 2 + 1]); + } + const dx = (BOX - (maxX - minX)) / 2 - minX, + dy = (BOX - (maxY - minY)) / 2 - minY; + for (let k = 0; k < n; k++) { + out[k * 2] += dx; + out[k * 2 + 1] += dy; + } + return out; +} + +function rotate(r: Ring, shift: number): Ring { + const n = r.length / 2, + out = new Float64Array(r.length); + for (let k = 0; k < n; k++) { + const s = (k + shift) % n; + out[k * 2] = r[s * 2]; + out[k * 2 + 1] = r[s * 2 + 1]; + } + return out; +} + +/** Rotate each ring so its points travel the short way to the next letter. */ +function align(rings: Ring[]): Ring[] { + const n = rings[0].length / 2; + const cost = (r: Ring, ref: Ring, shift: number) => { + let c = 0; + for (let k = 0; k < n; k += 2) { + const s = (k + shift) % n; + c += (r[s * 2] - ref[k * 2]) ** 2 + (r[s * 2 + 1] - ref[k * 2 + 1]) ** 2; + } + return c; + }; + const best = (r: Ring, ref: Ring) => { + let bi = 0, + bc = Infinity; + for (let s = 0; s < n; s++) { + const c = cost(r, ref, s); + if (c < bc) { + bc = c; + bi = s; + } + } + return bi; + }; + // A one-way chain leaves the wrap-around pair unoptimised, and that is exactly + // where a morph crumples. Two sweeps settle every pair including the last. + for (let pass = 0; pass < 2; pass++) + for (let i = 1; i < rings.length; i++) + rings[i] = rotate(rings[i], best(rings[i], rings[i - 1])); + rings[0] = rotate(rings[0], best(rings[0], rings[rings.length - 1])); + return rings; +} + +let cached: Ring[] | null = null; +function letters(): Ring[] { + if (!cached) cached = align(GLYPHS.map((g) => resampleCentred(parse(g), N))); + return cached; +} + +function pathAt(a: Ring, b: Ring, t: number): string { + let d = ""; + for (let k = 0; k < N; k++) { + const x = a[k * 2] + (b[k * 2] - a[k * 2]) * t; + const y = a[k * 2 + 1] + (b[k * 2 + 1] - a[k * 2 + 1]) * t; + d += (k ? "L" : "M") + Math.round(x * 100) / 100 + " " + Math.round(y * 100) / 100; + } + return d + "Z"; +} + +export function MapleLoadingMark({ + size = 48, + morphMs = 400, + holdMs = 140, + className, + label = "Loading" +}: { + size?: number; + morphMs?: number; + holdMs?: number; + className?: string; + label?: string; +}) { + const ref = useRef(null); + + useEffect(() => { + const rings = letters(); + const node = ref.current; + if (!node) return; + + if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) { + node.setAttribute("d", pathAt(rings[0], rings[0], 0)); + return; + } + + let i = 0, + phase: "hold" | "morph" = "hold", + t0 = performance.now(), + raf = 0; + const tick = (now: number) => { + const elapsed = now - t0; + // Advance state before reading it: deriving the pair first makes the frame + // that completes a morph render the letter it just left, which flashes. + if (phase === "hold") { + if (elapsed >= holdMs) { + phase = "morph"; + t0 = now; + } + } else if (elapsed >= morphMs) { + i = (i + 1) % rings.length; + phase = "hold"; + t0 = now; + } + + const t = phase === "morph" ? Math.min(1, (now - t0) / morphMs) : 0; + const eased = -(Math.cos(Math.PI * t) - 1) / 2; + node.setAttribute("d", pathAt(rings[i], rings[(i + 1) % rings.length], eased)); + raf = requestAnimationFrame(tick); + }; + raf = requestAnimationFrame(tick); + return () => cancelAnimationFrame(raf); + }, [morphMs, holdMs]); + + return ( + + + + ); +} diff --git a/frontend/src/routes/auth.$provider.callback.test.tsx b/frontend/src/routes/auth.$provider.callback.test.tsx new file mode 100644 index 000000000..fdf988964 --- /dev/null +++ b/frontend/src/routes/auth.$provider.callback.test.tsx @@ -0,0 +1,200 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; + +/** + * The OAuth callback route has two ways to end: the deep-link hand-off back to the + * Tauri app, and an error. These tests cover the error end, which is the one a + * returning password-account user hits when they press "Sign in with Google" and + * OpenSecret answers 409 `UserExistsNotLinked`. + */ + +class MemoryStorage implements Storage { + private readonly values = new Map(); + get length(): number { + return this.values.size; + } + clear(): void { + this.values.clear(); + } + getItem(key: string): string | null { + return this.values.get(key) ?? null; + } + key(index: number): string | null { + return [...this.values.keys()][index] ?? null; + } + removeItem(key: string): void { + this.values.delete(key); + } + setItem(key: string, value: string): void { + this.values.set(key, value); + } +} + +let capturedComponent: (() => JSX.Element) | null = null; +let provider = "google"; +let handleGoogleCallback = mock(async () => {}); +const navigate = mock(() => {}); + +mock.module("@tanstack/react-router", () => ({ + // Capture the route's component so the test can render it without a router. + createFileRoute: () => (options: { component: () => JSX.Element }) => { + capturedComponent = options.component; + return { useParams: () => ({ provider }), useSearch: () => ({}) }; + }, + useNavigate: () => navigate, + useRouter: () => ({ history: {} }), + Link: ({ children }: { children?: unknown }) => children as JSX.Element +})); + +mock.module("@opensecret/react", () => ({ + useOpenSecret: () => ({ + handleGitHubCallback: mock(async () => {}), + handleGoogleCallback, + handleAppleCallback: mock(async () => {}) + }) +})); + +mock.module("@/billing/billingService", () => ({ + getBillingService: () => ({ clearToken: mock(() => {}) }) +})); + +// Import once, at module scope: the route registers its component the first time +// it is evaluated, and a later import returns the cached module without re-running. +await import("./auth.$provider.callback"); + +const originalLocalStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); +const originalSessionStorage = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"); +const originalWindow = Object.getOwnPropertyDescriptor(globalThis, "window"); + +function setGlobal(name: string, value: unknown): void { + Object.defineProperty(globalThis, name, { configurable: true, value, writable: true }); +} +function restore(name: string, d: PropertyDescriptor | undefined): void { + if (d) Object.defineProperty(globalThis, name, d); + else Reflect.deleteProperty(globalThis, name); +} + +/** Flatten the rendered tree to text so assertions read like what a user sees. */ +function textOf(renderer: ReactTestRenderer): string { + const walk = (node: unknown): string => { + if (node === null || node === undefined || typeof node === "boolean") return ""; + if (typeof node === "string" || typeof node === "number") return String(node); + if (Array.isArray(node)) return node.map(walk).join(" "); + const children = (node as { children?: unknown }).children; + return children ? walk(children) : ""; + }; + return walk(renderer.toJSON()).replace(/\s+/g, " ").trim(); +} + +describe("OAuth callback route", () => { + let localStorage: MemoryStorage; + let sessionStorage: MemoryStorage; + let renderer: ReactTestRenderer | null; + let originalConsoleError: typeof console.error; + + beforeEach(() => { + renderer = null; + provider = "google"; + navigate.mockClear(); + originalConsoleError = console.error; + console.error = mock(() => {}); + + localStorage = new MemoryStorage(); + sessionStorage = new MemoryStorage(); + setGlobal("localStorage", localStorage); + setGlobal("sessionStorage", sessionStorage); + setGlobal("window", { + localStorage, + sessionStorage, + location: { + href: "https://trymaple.ai/auth/google/callback?code=abc&state=xyz", + origin: "https://trymaple.ai", + search: "?code=abc&state=xyz" + } + }); + }); + + afterEach(() => { + if (renderer) act(() => renderer!.unmount()); + console.error = originalConsoleError; + restore("localStorage", originalLocalStorage); + restore("sessionStorage", originalSessionStorage); + restore("window", originalWindow); + }); + + async function renderCallback(): Promise { + const Component = capturedComponent!; + let r!: ReactTestRenderer; + await act(async () => { + r = create(); + }); + renderer = r; + return r; + } + + const ALREADY_REGISTERED = + "An account with this email already exists. Please sign in using your existing account."; + + test("shows the failure to a desktop user instead of spinning forever", async () => { + // The desktop flow sets this flag on trymaple.ai before bouncing to Google. + // Left unhandled, the native branch renders above the error branch and the + // user watches a spinner that will never resolve. + localStorage.setItem("redirect-to-native", "true"); + handleGoogleCallback = mock(async () => { + throw new Error(ALREADY_REGISTERED); + }); + + const r = await renderCallback(); + const text = textOf(r); + + expect(text).not.toContain("Completing authentication"); + expect(text).toContain("This email already has a Maple account"); + }); + + test("points a 409 at the right sign-in method rather than at retrying", async () => { + handleGoogleCallback = mock(async () => { + throw new Error(ALREADY_REGISTERED); + }); + + const text = textOf(await renderCallback()); + + expect(text).toContain("Go to log in"); + expect(text).toContain("Log in with Email"); + // retrying the same provider is exactly what will not work here + expect(text).not.toContain("Try Again"); + }); + + test("keeps the generic failure card for ordinary errors", async () => { + localStorage.setItem("redirect-to-native", "true"); + handleGoogleCallback = mock(async () => { + throw new Error("Failed to authenticate with Google. Please try again."); + }); + + const text = textOf(await renderCallback()); + + expect(text).toContain("Authentication Failed"); + expect(text).toContain("Try Again"); + expect(text).not.toContain("Completing authentication"); + }); + + test("clears the native hand-off flag on failure so later web sign-ins are unaffected", async () => { + // Left set, the flag makes a subsequent *successful* web sign-in deep-link + // into the desktop app instead of continuing in the browser. + localStorage.setItem("redirect-to-native", "true"); + handleGoogleCallback = mock(async () => { + throw new Error("Failed to authenticate with Google. Please try again."); + }); + + await renderCallback(); + + expect(localStorage.getItem("redirect-to-native")).toBeNull(); + }); + + test("still surfaces the failure in the plain web flow", async () => { + handleGoogleCallback = mock(async () => { + throw new Error(ALREADY_REGISTERED); + }); + + expect(textOf(await renderCallback())).toContain("This email already has a Maple account"); + }); +}); diff --git a/frontend/src/routes/auth.$provider.callback.tsx b/frontend/src/routes/auth.$provider.callback.tsx index 640b6375a..3dcc458b4 100644 --- a/frontend/src/routes/auth.$provider.callback.tsx +++ b/frontend/src/routes/auth.$provider.callback.tsx @@ -3,8 +3,8 @@ import { useEffect, useState, useRef } from "react"; import { useOpenSecret } from "@opensecret/react"; import { AlertDestructive } from "@/components/AlertDestructive"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { MapleLoadingMark } from "@/components/MapleLoadingMark"; import { getBillingService } from "@/billing/billingService"; import { getSafeInternalRedirect, navigateToSafeInternalRedirect } from "@/utils/internalRedirect"; @@ -26,6 +26,15 @@ function formatProviderName(provider: string): string { } } +/** + * The platform returns 409 `UserExistsNotLinked` here and the SDK turns it into a + * sentence. There is no machine-readable code for it yet, so this matches the text + * the SDK produces; a dedicated error code on the response would be sturdier. + */ +function isEmailAlreadyRegistered(message: string): boolean { + return /already exists/i.test(message); +} + function OAuthCallback() { const [isProcessing, setIsProcessing] = useState(true); const [error, setError] = useState(null); @@ -99,6 +108,9 @@ function OAuthCallback() { const handleAuthError = (error: unknown) => { console.error(`Authentication callback error:`, error); + // Clear the native-flow flag so the failure renders as an error here, and so a + // later web sign-in from this browser is not treated as a Tauri hand-off. + localStorage.removeItem("redirect-to-native"); if (error instanceof Error) { setError(error.message); } else { @@ -175,6 +187,54 @@ function OAuthCallback() { processCallback(); }, [handleGitHubCallback, handleGoogleCallback, handleAppleCallback, navigate, provider, router]); + if (error) { + // OpenSecret answers 409 when the email already has an account that this + // provider identity is not linked to, and deliberately will not link them on a + // matching email. That is a different situation from a failed sign-in, and the + // way out is a different sign-in method rather than another attempt. + if (isEmailAlreadyRegistered(error)) { + return ( + + + This email already has a Maple account + + +

+ Your {formattedProvider} account uses an email that is already registered with Maple. + Sign in with the method you created that account with — if it was an email and + password, choose{" "} + Log in with Email. +

+
+ + +
+
+
+ ); + } + + return ( + + + Authentication Failed + + + +
+ +
+
+
+ ); + } + // After auth completes for a native app flow, show a button to open the app if (nativeRedirectUrl) { return ( @@ -204,7 +264,7 @@ function OAuthCallback() {

Completing authentication...

- +
@@ -219,25 +279,7 @@ function OAuthCallback() { Processing {formattedProvider} Login - - - - ); - } - - if (error) { - return ( - - - Authentication Failed - - - -
- -
+
); diff --git a/frontend/src/routes/desktop-auth.tsx b/frontend/src/routes/desktop-auth.tsx index e4661057a..cde5c0025 100644 --- a/frontend/src/routes/desktop-auth.tsx +++ b/frontend/src/routes/desktop-auth.tsx @@ -2,8 +2,8 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useEffect } from "react"; import { useOpenSecret } from "@opensecret/react"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Loader2 } from "lucide-react"; import { AppleAuthProvider } from "@/components/AppleAuthProvider"; +import { MapleLoadingMark } from "@/components/MapleLoadingMark"; import { getSafeInternalRedirect } from "@/utils/internalRedirect"; // Define the search parameters interface @@ -121,7 +121,7 @@ function DesktopAuth() {

Please wait while we redirect you to complete authentication...

- +