From 03554d021127eb2dcde7b54ecb9bdf4200460336 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Fri, 27 Feb 2026 19:04:12 +0900 Subject: [PATCH] wip Signed-off-by: Yujong Lee --- apps/desktop/src/auth/billing.tsx | 9 +- apps/desktop/src/auth/context.tsx | 11 +- .../src/shared/hooks/useDeeplinkHandler.ts | 7 +- apps/desktop/src/shared/utils.ts | 12 +- apps/web/content/deeplinks/notification.mdx | 9 - apps/web/src/functions/auth.ts | 5 + apps/web/src/functions/billing.ts | 10 + apps/web/src/functions/desktop-flow.ts | 221 ++++++++++++++++++ .../_view/app/-integrations-connect-flow.tsx | 3 + .../app/-integrations-upgrade-prompt.tsx | 37 ++- apps/web/src/routes/_view/app/account.tsx | 48 +++- apps/web/src/routes/_view/app/checkout.tsx | 22 +- apps/web/src/routes/_view/app/integration.tsx | 9 +- apps/web/src/routes/_view/callback/auth.tsx | 99 ++++++-- .../src/routes/_view/callback/integration.tsx | 103 +++++--- apps/web/src/routes/auth.tsx | 108 ++++++++- 16 files changed, 596 insertions(+), 117 deletions(-) delete mode 100644 apps/web/content/deeplinks/notification.mdx create mode 100644 apps/web/src/functions/desktop-flow.ts diff --git a/apps/desktop/src/auth/billing.tsx b/apps/desktop/src/auth/billing.tsx index fdeb5d9301..132cd6f914 100644 --- a/apps/desktop/src/auth/billing.tsx +++ b/apps/desktop/src/auth/billing.tsx @@ -18,7 +18,7 @@ import { } from "@hypr/supabase"; import { env } from "../env"; -import { getScheme } from "../shared/utils"; +import { buildWebAppUrl } from "../shared/utils"; import { useAuth } from "./context"; async function getClaimsFromToken( @@ -85,11 +85,8 @@ export function BillingProvider({ children }: { children: ReactNode }) { ); const upgradeToPro = useCallback(async () => { - const scheme = await getScheme(); - void openerCommands.openUrl( - `${env.VITE_APP_URL}/app/checkout?period=monthly&scheme=${scheme}`, - null, - ); + const url = await buildWebAppUrl("/app/checkout", { period: "monthly" }); + void openerCommands.openUrl(url, null); }, []); const value = useMemo( diff --git a/apps/desktop/src/auth/context.tsx b/apps/desktop/src/auth/context.tsx index cef75c12df..c827e7dd6d 100644 --- a/apps/desktop/src/auth/context.tsx +++ b/apps/desktop/src/auth/context.tsx @@ -18,8 +18,7 @@ import { useRef, useState, } from "react"; -import { env } from "~/env"; -import { DEVICE_FINGERPRINT_HEADER, getScheme } from "~/shared/utils"; +import { buildWebAppUrl, DEVICE_FINGERPRINT_HEADER } from "~/shared/utils"; import { commands as analyticsCommands } from "@hypr/plugin-analytics"; import { commands as miscCommands } from "@hypr/plugin-misc"; @@ -266,12 +265,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { }, []); const signIn = useCallback(async () => { - const base = env.VITE_APP_URL ?? "http://localhost:3000"; - const scheme = await getScheme(); - await openerCommands.openUrl( - `${base}/auth?flow=desktop&scheme=${scheme}`, - null, - ); + const url = await buildWebAppUrl("/auth"); + await openerCommands.openUrl(url, null); }, []); const signOut = useCallback(async () => { diff --git a/apps/desktop/src/shared/hooks/useDeeplinkHandler.ts b/apps/desktop/src/shared/hooks/useDeeplinkHandler.ts index 26bd9f8c14..0f040db075 100644 --- a/apps/desktop/src/shared/hooks/useDeeplinkHandler.ts +++ b/apps/desktop/src/shared/hooks/useDeeplinkHandler.ts @@ -4,7 +4,10 @@ import { useEffect } from "react"; import { useAuth } from "~/auth"; import { useTabs } from "~/store/zustand/tabs"; -import { events as deeplink2Events } from "@hypr/plugin-deeplink2"; +import { + commands as deeplink2Commands, + events as deeplink2Events, +} from "@hypr/plugin-deeplink2"; export function useDeeplinkHandler() { const auth = useAuth(); @@ -17,6 +20,8 @@ export function useDeeplinkHandler() { } const unlisten = deeplink2Events.deepLinkEvent.listen(({ payload }) => { + void deeplink2Commands.stopCallbackServer(); + if (payload.to === "/auth/callback") { const { access_token, refresh_token } = payload.search; if (access_token && refresh_token && auth) { diff --git a/apps/desktop/src/shared/utils.ts b/apps/desktop/src/shared/utils.ts index d0c9c71710..39e5122e69 100644 --- a/apps/desktop/src/shared/utils.ts +++ b/apps/desktop/src/shared/utils.ts @@ -1,8 +1,6 @@ import { getIdentifier } from "@tauri-apps/api/app"; -// export * from "../shared/config/configure-pro-settings"; -// export * from "~/sidebar/timeline/utils"; -// export * from "~/stt/segment"; +import { commands as deeplink2Commands } from "@hypr/plugin-deeplink2"; export const id = () => crypto.randomUUID() as string; @@ -24,10 +22,18 @@ export const buildWebAppUrl = async ( params?: Record, ): Promise => { const { env } = await import("~/env"); + const scheme = await getScheme(); + const result = await deeplink2Commands.startCallbackServer(scheme); + if (result.status !== "ok") { + throw new Error(`Failed to start callback server: ${result.error}`); + } + const redirectUri = `http://127.0.0.1:${result.data}`; + const url = new URL(path, env.VITE_APP_URL); url.searchParams.set("flow", "desktop"); url.searchParams.set("scheme", scheme); + url.searchParams.set("redirect_uri", redirectUri); if (params) { for (const [key, value] of Object.entries(params)) { url.searchParams.set(key, value); diff --git a/apps/web/content/deeplinks/notification.mdx b/apps/web/content/deeplinks/notification.mdx deleted file mode 100644 index 83b0530ca7..0000000000 --- a/apps/web/content/deeplinks/notification.mdx +++ /dev/null @@ -1,9 +0,0 @@ ---- -path: "/notification" -description: null -params: - - name: "key" - description: null - type_name: "string" ---- - diff --git a/apps/web/src/functions/auth.ts b/apps/web/src/functions/auth.ts index b342fcec17..998c349b04 100644 --- a/apps/web/src/functions/auth.ts +++ b/apps/web/src/functions/auth.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { env } from "@/env"; import { isAdminEmail } from "@/functions/admin"; +import { getDesktopReturnContext } from "@/functions/desktop-flow"; import { getSupabaseAdminClient, getSupabaseDesktopFlowClient, @@ -14,6 +15,7 @@ const shared = z.object({ flow: z.enum(["desktop", "web"]).default("desktop"), scheme: z.string().optional(), redirect: z.string().optional(), + redirect_uri: z.string().optional(), }); type Flow = z.infer["flow"]; @@ -26,10 +28,13 @@ function buildAuthCallbackParams(data: { flow: Flow; scheme?: string; redirect?: string; + redirect_uri?: string; }) { const params = new URLSearchParams({ flow: data.flow }); + const { redirectUri } = getDesktopReturnContext(data); if (data.scheme) params.set("scheme", data.scheme); if (data.redirect) params.set("redirect", data.redirect); + if (redirectUri) params.set("redirect_uri", redirectUri); return params; } diff --git a/apps/web/src/functions/billing.ts b/apps/web/src/functions/billing.ts index 8b48e446d2..959ad919a1 100644 --- a/apps/web/src/functions/billing.ts +++ b/apps/web/src/functions/billing.ts @@ -9,6 +9,7 @@ import { import { createClient } from "@hypr/api-client/client"; import { env, requireEnv } from "@/env"; +import { getDesktopReturnContext } from "@/functions/desktop-flow"; import { getStripeClient } from "@/functions/stripe"; import { getSupabaseServerClient } from "@/functions/supabase"; @@ -58,7 +59,9 @@ const getStripeCustomerIdForUser = async ( const createCheckoutSessionInput = z.object({ period: z.enum(["monthly", "yearly"]), + flow: z.enum(["desktop", "web"]).default("web"), scheme: z.string().optional(), + redirect_uri: z.string().optional(), }); export const createCheckoutSession = createServerFn({ method: "POST" }) @@ -125,9 +128,16 @@ export const createCheckoutSession = createServerFn({ method: "POST" }) : requireEnv(env.STRIPE_MONTHLY_PRICE_ID, "STRIPE_MONTHLY_PRICE_ID"); const successParams = new URLSearchParams({ success: "true" }); + const { redirectUri } = getDesktopReturnContext(data); + if (data.flow === "desktop") { + successParams.set("flow", "desktop"); + } if (data.scheme) { successParams.set("scheme", data.scheme); } + if (redirectUri) { + successParams.set("redirect_uri", redirectUri); + } const checkout = await stripe.checkout.sessions.create({ customer: stripeCustomerId, diff --git a/apps/web/src/functions/desktop-flow.ts b/apps/web/src/functions/desktop-flow.ts new file mode 100644 index 0000000000..f499edcd88 --- /dev/null +++ b/apps/web/src/functions/desktop-flow.ts @@ -0,0 +1,221 @@ +import { z } from "zod"; + +import type { DeepLink } from "@hypr/plugin-deeplink2"; + +export const DESKTOP_SCHEMES = [ + "hypr", + "hyprnote", + "hyprnote-nightly", + "hyprnote-staging", + "char", + "char-nightly", + "char-staging", +] as const; + +export const desktopSchemeSchema = z.enum(DESKTOP_SCHEMES); + +export const normalizeDesktopRedirectUri = ( + value: string | undefined, +): string | undefined => { + if (!value) { + return undefined; + } + + try { + const url = new URL(value); + if (url.protocol !== "http:") { + return undefined; + } + if (!url.port || url.username || url.password || url.search || url.hash) { + return undefined; + } + if (url.pathname !== "/" && url.pathname !== "") { + return undefined; + } + + const hostname = url.hostname.replace(/^\[(.*)\]$/, "$1").toLowerCase(); + if ( + hostname !== "localhost" && + hostname !== "127.0.0.1" && + hostname !== "::1" + ) { + return undefined; + } + + const port = Number.parseInt(url.port, 10); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + return undefined; + } + + return `http://127.0.0.1:${url.port}`; + } catch { + return undefined; + } +}; + +export const desktopRedirectUriSchema = z + .string() + .optional() + .transform((v) => normalizeDesktopRedirectUri(v)); + +export type DesktopFlow = "desktop" | "web"; + +type DesktopReturnContextInput = { + flow?: DesktopFlow; + scheme?: string; + redirect_uri?: string; +}; + +export type DesktopReturnContext = { + flow: DesktopFlow; + scheme?: string; + redirectUri?: string; + isDesktop: boolean; +}; + +export const getDesktopReturnContext = ( + input: DesktopReturnContextInput, +): DesktopReturnContext => { + const flow = input.flow ?? "web"; + return { + flow, + scheme: input.scheme, + redirectUri: normalizeDesktopRedirectUri(input.redirect_uri), + isDesktop: flow === "desktop", + }; +}; + +type DesktopCallbackPath = DeepLink["to"]; + +const DESKTOP_CALLBACK_PATHS = { + auth: "/auth/callback", + billing: "/billing/refresh", + integration: "/integration/callback", +} as const satisfies Record< + "auth" | "billing" | "integration", + DesktopCallbackPath +>; + +const buildSchemeCallbackUrl = ( + scheme: string, + path: DesktopCallbackPath, + params?: URLSearchParams, +): string => { + const normalizedPath = path.replace(/^\//, ""); + const search = params?.toString(); + return search + ? `${scheme}://${normalizedPath}?${search}` + : `${scheme}://${normalizedPath}`; +}; + +const buildLocalCallbackUrl = ( + redirectUri: string, + path: DesktopCallbackPath, + params?: URLSearchParams, +): string => { + const url = new URL(path, `${redirectUri}/`); + if (params) { + url.search = params.toString(); + } + return url.toString(); +}; + +export type DesktopCallbackUrls = { + primary?: string; + fallback?: string; + local?: string; + scheme?: string; +}; + +type DesktopAuthCallbackOptions = { + type: "auth"; + access_token: string; + refresh_token: string; +}; + +type DesktopBillingCallbackOptions = { + type: "billing"; +}; + +type DesktopIntegrationCallbackOptions = { + type: "integration"; + integration_id: string; + status: string; + return_to?: string; +}; + +type DesktopCallbackOptions = + | DesktopAuthCallbackOptions + | DesktopBillingCallbackOptions + | DesktopIntegrationCallbackOptions; + +export const buildDesktopCallbackUrls = ( + context: DesktopReturnContext, + options: DesktopCallbackOptions, +): DesktopCallbackUrls => { + if (!context.isDesktop) { + return {}; + } + + const params = new URLSearchParams(); + let path: DesktopCallbackPath; + + if (options.type === "auth") { + path = DESKTOP_CALLBACK_PATHS.auth; + params.set("access_token", options.access_token); + params.set("refresh_token", options.refresh_token); + } else if (options.type === "billing") { + path = DESKTOP_CALLBACK_PATHS.billing; + } else { + path = DESKTOP_CALLBACK_PATHS.integration; + params.set("integration_id", options.integration_id); + params.set("status", options.status); + if (options.return_to) { + params.set("return_to", options.return_to); + } + } + + const scheme = context.scheme + ? buildSchemeCallbackUrl(context.scheme, path, params) + : undefined; + const local = context.redirectUri + ? buildLocalCallbackUrl(context.redirectUri, path, params) + : undefined; + const primary = local ?? scheme; + const fallback = local && scheme ? scheme : undefined; + + return { primary, fallback, local, scheme }; +}; + +export const buildLocalAuthCallbackUrl = ( + redirectUri: string, + tokens: { access_token: string; refresh_token: string }, +): string => { + return buildLocalCallbackUrl( + redirectUri, + DESKTOP_CALLBACK_PATHS.auth, + new URLSearchParams(tokens), + ); +}; + +export const buildLocalBillingRefreshUrl = (redirectUri: string): string => { + return buildLocalCallbackUrl(redirectUri, DESKTOP_CALLBACK_PATHS.billing); +}; + +export const buildLocalIntegrationCallbackUrl = ( + redirectUri: string, + params: { integration_id: string; status: string; return_to?: string }, +): string => { + const searchParams = new URLSearchParams({ + integration_id: params.integration_id, + status: params.status, + }); + if (params.return_to) { + searchParams.set("return_to", params.return_to); + } + return buildLocalCallbackUrl( + redirectUri, + DESKTOP_CALLBACK_PATHS.integration, + searchParams, + ); +}; diff --git a/apps/web/src/routes/_view/app/-integrations-connect-flow.tsx b/apps/web/src/routes/_view/app/-integrations-connect-flow.tsx index b27ce3795d..8e8e9c3071 100644 --- a/apps/web/src/routes/_view/app/-integrations-connect-flow.tsx +++ b/apps/web/src/routes/_view/app/-integrations-connect-flow.tsx @@ -13,6 +13,8 @@ import { getIntegrationDisplay, Route } from "./integration"; export function ConnectFlow() { const search = Route.useSearch(); + const redirectUri = search.redirect_uri; + const navigate = useNavigate(); const [nango] = useState(() => new Nango()); const [status, setStatus] = useState< @@ -94,6 +96,7 @@ export function ConnectFlow() { status: "success", flow: search.flow, scheme: search.scheme, + redirect_uri: redirectUri, return_to: search.return_to, }, }); diff --git a/apps/web/src/routes/_view/app/-integrations-upgrade-prompt.tsx b/apps/web/src/routes/_view/app/-integrations-upgrade-prompt.tsx index a8fb6265d2..3cc425d42e 100644 --- a/apps/web/src/routes/_view/app/-integrations-upgrade-prompt.tsx +++ b/apps/web/src/routes/_view/app/-integrations-upgrade-prompt.tsx @@ -2,18 +2,35 @@ import { Link } from "@tanstack/react-router"; import { cn } from "@hypr/utils"; +import { + buildDesktopCallbackUrls, + DESKTOP_SCHEMES, + getDesktopReturnContext, +} from "@/functions/desktop-flow"; + import { getIntegrationDisplay } from "./integration"; export function UpgradePrompt({ integrationId, flow, scheme, + redirectUri, }: { integrationId: string; - flow: string; - scheme: string; + flow: "desktop" | "web"; + scheme: (typeof DESKTOP_SCHEMES)[number]; + redirectUri?: string; }) { const display = getIntegrationDisplay(integrationId); + const checkoutSearch = + flow === "desktop" + ? { + period: "monthly" as const, + flow: "desktop" as const, + scheme, + redirect_uri: redirectUri, + } + : { period: "monthly" as const, flow: "web" as const }; return (
@@ -35,7 +52,7 @@ export function UpgradePrompt({
{ - window.location.href = `${scheme}://integration/callback?integration_id=${integrationId}&status=upgrade_required`; + const desktopContext = getDesktopReturnContext({ + flow, + scheme, + redirect_uri: redirectUri, + }); + const callbackUrls = buildDesktopCallbackUrls(desktopContext, { + type: "integration", + integration_id: integrationId, + status: "upgrade_required", + }); + if (callbackUrls.primary) { + window.location.href = callbackUrls.primary; + } }} className="text-sm text-neutral-500 hover:text-neutral-700 transition-colors cursor-pointer" > diff --git a/apps/web/src/routes/_view/app/account.tsx b/apps/web/src/routes/_view/app/account.tsx index da5c26ce60..c0f4bbb98d 100644 --- a/apps/web/src/routes/_view/app/account.tsx +++ b/apps/web/src/routes/_view/app/account.tsx @@ -1,25 +1,27 @@ import { createFileRoute } from "@tanstack/react-router"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { z } from "zod"; +import { + buildDesktopCallbackUrls, + desktopRedirectUriSchema, + desktopSchemeSchema, + getDesktopReturnContext, +} from "@/functions/desktop-flow"; + import { DeleteAccountSection } from "./-account-delete"; import { IntegrationsSettingsCard } from "./-account-integrations"; import { ProfileInfoSection } from "./-account-profile-info"; import { AccountSettingsCard } from "./-account-settings"; import { SignOutSection } from "./-account-sign-out"; -const VALID_SCHEMES = [ - "hyprnote", - "hyprnote-nightly", - "hyprnote-staging", - "hypr", -] as const; - const validateSearch = z .object({ success: z.coerce.boolean(), trial: z.enum(["started"]), - scheme: z.enum(VALID_SCHEMES), + flow: z.enum(["desktop", "web"]), + scheme: desktopSchemeSchema, + redirect_uri: desktopRedirectUriSchema, }) .partial(); @@ -32,12 +34,34 @@ export const Route = createFileRoute("/_view/app/account")({ function Component() { const { user } = Route.useLoaderData(); const search = Route.useSearch(); + const desktopContext = useMemo( + () => + getDesktopReturnContext({ + flow: search.flow, + scheme: search.scheme, + redirect_uri: search.redirect_uri, + }), + [search.flow, search.scheme, search.redirect_uri], + ); + const billingCallbackUrls = useMemo( + () => buildDesktopCallbackUrls(desktopContext, { type: "billing" }), + [desktopContext], + ); useEffect(() => { - if ((search.success || search.trial === "started") && search.scheme) { - window.location.href = `${search.scheme}://billing/refresh`; + if ( + (search.success || search.trial === "started") && + desktopContext.isDesktop && + billingCallbackUrls.primary + ) { + window.location.href = billingCallbackUrls.primary; } - }, [search.success, search.trial, search.scheme]); + }, [ + search.success, + search.trial, + desktopContext.isDesktop, + billingCallbackUrls.primary, + ]); return (
diff --git a/apps/web/src/routes/_view/app/checkout.tsx b/apps/web/src/routes/_view/app/checkout.tsx index f7d0005361..81bce99629 100644 --- a/apps/web/src/routes/_view/app/checkout.tsx +++ b/apps/web/src/routes/_view/app/checkout.tsx @@ -2,24 +2,28 @@ import { createFileRoute, redirect } from "@tanstack/react-router"; import { z } from "zod"; import { createCheckoutSession } from "@/functions/billing"; - -const VALID_SCHEMES = [ - "hyprnote", - "hyprnote-nightly", - "hyprnote-staging", - "hypr", -] as const; +import { + desktopRedirectUriSchema, + desktopSchemeSchema, +} from "@/functions/desktop-flow"; const validateSearch = z.object({ period: z.enum(["monthly", "yearly"]).catch("monthly"), - scheme: z.enum(VALID_SCHEMES).optional(), + flow: z.enum(["desktop", "web"]).default("web"), + scheme: desktopSchemeSchema.optional(), + redirect_uri: desktopRedirectUriSchema, }); export const Route = createFileRoute("/_view/app/checkout")({ validateSearch, beforeLoad: async ({ search }) => { const { url } = await createCheckoutSession({ - data: { period: search.period, scheme: search.scheme }, + data: { + period: search.period, + flow: search.flow, + scheme: search.scheme, + redirect_uri: search.redirect_uri, + }, }); if (url) { diff --git a/apps/web/src/routes/_view/app/integration.tsx b/apps/web/src/routes/_view/app/integration.tsx index e3c92a79be..ec6d78cb08 100644 --- a/apps/web/src/routes/_view/app/integration.tsx +++ b/apps/web/src/routes/_view/app/integration.tsx @@ -1,6 +1,10 @@ import { createFileRoute } from "@tanstack/react-router"; import { z } from "zod"; +import { + desktopRedirectUriSchema, + desktopSchemeSchema, +} from "@/functions/desktop-flow"; import { useBilling } from "@/hooks/use-billing"; import { ConnectFlow } from "./-integrations-connect-flow"; @@ -10,7 +14,8 @@ const validateSearch = z.object({ integration_id: z.string().default("google-calendar"), connection_id: z.string().optional(), flow: z.enum(["desktop", "web"]).default("web"), - scheme: z.string().default("hyprnote"), + scheme: desktopSchemeSchema.default("hyprnote"), + redirect_uri: desktopRedirectUriSchema, return_to: z.string().optional(), }); @@ -45,6 +50,7 @@ export const Route = createFileRoute("/_view/app/integration")({ function Component() { const search = Route.useSearch(); + const redirectUri = search.redirect_uri; const billing = useBilling(); if (!billing.isReady) { @@ -63,6 +69,7 @@ function Component() { integrationId={search.integration_id} flow={search.flow} scheme={search.scheme} + redirectUri={redirectUri} /> ); } diff --git a/apps/web/src/routes/_view/callback/auth.tsx b/apps/web/src/routes/_view/callback/auth.tsx index a637e5be8d..e3856ac838 100644 --- a/apps/web/src/routes/_view/callback/auth.tsx +++ b/apps/web/src/routes/_view/callback/auth.tsx @@ -7,6 +7,12 @@ import { z } from "zod"; import { cn } from "@hypr/utils"; import { exchangeOAuthCode, exchangeOtpToken } from "@/functions/auth"; +import { + buildDesktopCallbackUrls, + desktopRedirectUriSchema, + desktopSchemeSchema, + getDesktopReturnContext, +} from "@/functions/desktop-flow"; import { useAnalytics } from "@/hooks/use-posthog"; const validateSearch = z.object({ @@ -14,8 +20,9 @@ const validateSearch = z.object({ token_hash: z.string().optional(), type: z.enum(["email", "recovery"]).optional(), flow: z.enum(["desktop", "web"]).default("desktop"), - scheme: z.string().default("hyprnote"), + scheme: desktopSchemeSchema.default("hyprnote"), redirect: z.string().optional(), + redirect_uri: desktopRedirectUriSchema, access_token: z.string().optional(), refresh_token: z.string().optional(), error: z.string().optional(), @@ -59,6 +66,7 @@ export const Route = createFileRoute("/_view/callback/auth")({ search: { flow: "desktop", scheme: search.scheme, + redirect_uri: search.redirect_uri, access_token: result.access_token, refresh_token: result.refresh_token, }, @@ -106,6 +114,7 @@ export const Route = createFileRoute("/_view/callback/auth")({ search: { flow: "desktop", scheme: search.scheme, + redirect_uri: search.redirect_uri, access_token: result.access_token, refresh_token: result.refresh_token, }, @@ -125,6 +134,22 @@ function Component() { const { identify: identifyOutlit, isInitialized } = useOutlit(); const { identify: identifyPosthog } = useAnalytics(); const [copied, setCopied] = useState(false); + const [localAttemptFailed, setLocalAttemptFailed] = useState(false); + const desktopContext = getDesktopReturnContext({ + flow: search.flow, + scheme: search.scheme, + redirect_uri: search.redirect_uri, + }); + const callbackUrls = + search.access_token && search.refresh_token + ? buildDesktopCallbackUrls(desktopContext, { + type: "auth", + access_token: search.access_token, + refresh_token: search.refresh_token, + }) + : {}; + const manualUrl = + callbackUrls.fallback ?? callbackUrls.scheme ?? callbackUrls.primary; useEffect(() => { if (!search.access_token || !isInitialized) return; @@ -149,30 +174,20 @@ function Component() { } }, [search.access_token, identifyOutlit, isInitialized]); - const getDeeplink = () => { - if (search.access_token && search.refresh_token) { - const params = new URLSearchParams(); - params.set("access_token", search.access_token); - params.set("refresh_token", search.refresh_token); - return `${search.scheme}://auth/callback?${params.toString()}`; - } - return null; - }; - // Browsers require a user gesture (click) to open custom URL schemes. // Auto-triggering via setTimeout fails for email magic links because // the page is opened from an external context (email client) without // "transient user activation". OAuth redirects work because they maintain // activation through the redirect chain. const handleDeeplink = () => { - const deeplink = getDeeplink(); + const deeplink = manualUrl; if (search.flow === "desktop" && deeplink) { window.location.href = deeplink; } }; const handleCopy = async () => { - const deeplink = getDeeplink(); + const deeplink = manualUrl; if (deeplink) { await navigator.clipboard.writeText(deeplink); setCopied(true); @@ -180,6 +195,39 @@ function Component() { } }; + useEffect(() => { + if ( + !desktopContext.isDesktop || + !search.access_token || + !search.refresh_token || + !callbackUrls.local + ) { + return; + } + + let cancelled = false; + void fetch(callbackUrls.local, { mode: "no-cors", cache: "no-store" }) + .then(() => { + if (!cancelled) { + setLocalAttemptFailed(false); + } + }) + .catch(() => { + if (!cancelled) { + setLocalAttemptFailed(true); + } + }); + + return () => { + cancelled = true; + }; + }, [ + desktopContext.isDesktop, + search.access_token, + search.refresh_token, + callbackUrls.local, + ]); + useEffect(() => { if (search.flow === "web" && !search.error) { navigate({ @@ -191,6 +239,17 @@ function Component() { }, [search, navigate]); if (search.error) { + const retryParams = new URLSearchParams({ + flow: search.flow, + scheme: search.scheme, + }); + if (search.redirect) { + retryParams.set("redirect", search.redirect); + } + if (desktopContext.redirectUri) { + retryParams.set("redirect_uri", desktopContext.redirectUri); + } + return ( - {hasTokens && ( + {hasTokens && manualUrl && (
- {isSuccess && ( + {isSuccess && manualUrl && (
- - + +
)} @@ -260,11 +303,13 @@ function EmailAuthView({ flow, scheme, redirect, + redirectUri, onBack, }: { flow: "desktop" | "web"; scheme?: string; redirect?: string; + redirectUri?: string; onBack: () => void; }) { const [mode, setMode] = useState("password"); @@ -305,10 +350,20 @@ function EmailAuthView({
{mode === "password" && ( - + )} {mode === "magic-link" && ( - + )} @@ -320,10 +375,12 @@ function PasswordForm({ flow, scheme, redirect, + redirectUri, }: { flow: "desktop" | "web"; scheme?: string; redirect?: string; + redirectUri?: string; }) { const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); @@ -335,7 +392,14 @@ function PasswordForm({ const signInMutation = useMutation({ mutationFn: () => doPasswordSignIn({ - data: { email, password, flow, scheme, redirect }, + data: { + email, + password, + flow, + scheme, + redirect, + redirect_uri: redirectUri, + }, }), onSuccess: (result) => { if (result && "error" in result && result.error) { @@ -356,6 +420,7 @@ function PasswordForm({ flow, scheme, redirect, + redirectUri, ); } }, @@ -364,7 +429,14 @@ function PasswordForm({ const signUpMutation = useMutation({ mutationFn: () => doPasswordSignUp({ - data: { email, password, flow, scheme, redirect }, + data: { + email, + password, + flow, + scheme, + redirect, + redirect_uri: redirectUri, + }, }), onSuccess: (result) => { if (result && "error" in result && result.error) { @@ -385,6 +457,7 @@ function PasswordForm({ flow, scheme, redirect, + redirectUri, ); } } @@ -520,11 +593,20 @@ function handlePasswordSuccess( flow: "desktop" | "web", scheme?: string, redirectPath?: string, + redirectUri?: string, ) { - if (flow === "desktop") { + const desktopContext = getDesktopReturnContext({ + flow, + scheme, + redirect_uri: redirectUri, + }); + if (desktopContext.isDesktop) { const params = new URLSearchParams(); params.set("flow", "desktop"); if (scheme) params.set("scheme", scheme); + if (desktopContext.redirectUri) { + params.set("redirect_uri", desktopContext.redirectUri); + } params.set("access_token", accessToken); params.set("refresh_token", refreshToken); window.location.href = `/callback/auth?${params.toString()}`; @@ -537,10 +619,12 @@ function MagicLinkForm({ flow, scheme, redirect, + redirectUri, }: { flow: "desktop" | "web"; scheme?: string; redirect?: string; + redirectUri?: string; }) { const [email, setEmail] = useState(""); const [submitted, setSubmitted] = useState(false); @@ -553,6 +637,7 @@ function MagicLinkForm({ flow, scheme, redirect, + redirect_uri: redirectUri, }, }), onSuccess: (result) => { @@ -625,12 +710,14 @@ function OAuthButton({ flow, scheme, redirect, + redirectUri, provider, rra, }: { flow: "desktop" | "web"; scheme?: string; redirect?: string; + redirectUri?: string; provider: "google" | "github"; rra?: boolean; }) { @@ -642,6 +729,7 @@ function OAuthButton({ flow, scheme, redirect, + redirect_uri: redirectUri, rra, }, }),