diff --git a/frontend/src/routes/auth.$provider.callback.tsx b/frontend/src/routes/auth.$provider.callback.tsx index 5026d67f..4ee8f9a4 100644 --- a/frontend/src/routes/auth.$provider.callback.tsx +++ b/frontend/src/routes/auth.$provider.callback.tsx @@ -1,9 +1,9 @@ import { createFileRoute, useNavigate, Link } from "@tanstack/react-router"; -import { useEffect, useState, useRef } from "react"; +import { useEffect, useState, useRef, useCallback } 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 { Loader2, Copy, Check, ExternalLink } from "lucide-react"; import { Button } from "@/components/ui/button"; import { getBillingService } from "@/billing/billingService"; @@ -28,11 +28,29 @@ function formatProviderName(provider: string): string { function OAuthCallback() { const [isProcessing, setIsProcessing] = useState(true); const [error, setError] = useState(null); + const [showCopyFallback, setShowCopyFallback] = useState(false); + const [copied, setCopied] = useState(false); + const [authCode, setAuthCode] = useState(null); const [nativeRedirectUrl, setNativeRedirectUrl] = useState(null); const navigate = useNavigate(); const { handleGitHubCallback, handleGoogleCallback, handleAppleCallback } = useOpenSecret(); const processedRef = useRef(false); + const handleCopyCode = useCallback(async () => { + if (!authCode) return; + try { + await navigator.clipboard.writeText(authCode); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Fallback: select the text in the input for manual copy + const input = document.querySelector("[data-auth-code]"); + if (input) { + input.select(); + } + } + }, [authCode]); + // Helper functions for the callback process const handleSuccessfulAuth = () => { // Check if this is a Tauri app auth flow (desktop or mobile) @@ -46,6 +64,13 @@ function OAuthCallback() { const accessToken = localStorage.getItem("access_token") || ""; const refreshToken = localStorage.getItem("refresh_token"); + // Generate the fallback auth code for copy/paste flow + const codePayload = JSON.stringify({ + access_token: accessToken, + refresh_token: refreshToken || "" + }); + setAuthCode(btoa(codePayload)); + let deepLinkUrl = `cloud.opensecret.maple://auth?access_token=${encodeURIComponent(accessToken)}`; if (refreshToken) { @@ -60,6 +85,11 @@ function OAuthCallback() { window.location.href = deepLinkUrl; }, 1000); + // Show the copy fallback after a few seconds in case the redirect doesn't work + setTimeout(() => { + setShowCopyFallback(true); + }, 3000); + return; } @@ -168,9 +198,41 @@ function OAuthCallback() {

Authentication successful! Tap the button below to return to Maple.

-
+
+ {showCopyFallback && authCode && ( +
+

+ + Trouble opening the app? Copy the code below and paste it in the Maple app. +

+
+ (e.target as HTMLInputElement).select()} + /> + +
+

+ Switch to the Maple app and paste the code into the login code input. +

+
+ )} ); diff --git a/frontend/src/routes/login.tsx b/frontend/src/routes/login.tsx index 47822ae7..f92ec62f 100644 --- a/frontend/src/routes/login.tsx +++ b/frontend/src/routes/login.tsx @@ -33,7 +33,7 @@ export const Route = createFileRoute("/login")({ }) }); -type LoginMethod = "email" | "github" | "google" | "apple" | "guest" | null; +type LoginMethod = "email" | "github" | "google" | "apple" | "guest" | "paste-code" | null; function LoginPage() { const navigate = useNavigate(); @@ -43,10 +43,26 @@ function LoginPage() { const [error, setError] = useState(null); const [isLoading, setIsLoading] = useState(false); + const [pasteCodeValue, setPasteCodeValue] = useState(""); + const [oauthProvider, setOauthProvider] = useState(null); + const [showPasteInput, setShowPasteInput] = useState(false); + // Use platform detection functions const isIOSPlatform = isIOS(); const isTauriEnv = isTauri(); + // Show paste code input after a delay when auto-navigated from OAuth + useEffect(() => { + if (loginMethod === "paste-code" && oauthProvider) { + setShowPasteInput(false); + const timer = setTimeout(() => setShowPasteInput(true), 3000); + return () => clearTimeout(timer); + } + if (loginMethod === "paste-code" && !oauthProvider) { + setShowPasteInput(true); + } + }, [loginMethod, oauthProvider]); + // Redirect if already logged in useEffect(() => { if (os.auth.user) { @@ -105,6 +121,49 @@ function LoginPage() { } }; + const handlePasteCode = async () => { + setIsLoading(true); + setError(null); + try { + const decoded = atob(pasteCodeValue.trim()); + const parsed = JSON.parse(decoded) as { access_token?: string; refresh_token?: string }; + + if (!parsed.access_token) { + throw new Error("Invalid login code: missing token"); + } + + // Store tokens in localStorage (clear old refresh token to prevent stale mismatch) + localStorage.setItem("access_token", parsed.access_token); + localStorage.removeItem("refresh_token"); + if (parsed.refresh_token) { + localStorage.setItem("refresh_token", parsed.refresh_token); + } + + // Clear any existing billing token + try { + getBillingService().clearToken(); + } catch (billingError) { + console.warn("Failed to clear billing token:", billingError); + } + + // Reload the app to pick up the new tokens + window.location.href = "/"; + } catch (err) { + if ( + err instanceof SyntaxError || + (err instanceof DOMException && err.name === "InvalidCharacterError") + ) { + setError("Invalid login code. Please copy the code from the browser and try again."); + } else if (err instanceof Error) { + setError(err.message); + } else { + setError("Failed to process login code. Please try again."); + } + } finally { + setIsLoading(false); + } + }; + const handleGitHubLogin = async () => { try { console.log("[OAuth] Using", isTauriEnv ? "Tauri" : "web", "flow"); @@ -130,6 +189,11 @@ function LoginPage() { console.error("[OAuth] Failed to open external browser:", error); setError("Failed to open authentication page in browser"); }); + + // Navigate to paste-code screen so user sees it while browser is open + setError(null); + setOauthProvider("GitHub"); + setLoginMethod("paste-code"); } else { // Web flow remains unchanged const { auth_url } = await os.initiateGitHubAuth(""); @@ -172,6 +236,11 @@ function LoginPage() { console.error("[OAuth] Failed to open external browser:", error); setError("Failed to open authentication page in browser"); }); + + // Navigate to paste-code screen so user sees it while browser is open + setError(null); + setOauthProvider("Google"); + setLoginMethod("paste-code"); } else { // Web flow remains unchanged const { auth_url } = await os.initiateGoogleAuth(""); @@ -336,6 +405,11 @@ function LoginPage() { console.error("[OAuth] Failed to open external browser:", error); setError("Failed to open authentication page in browser"); }); + + // Navigate to paste-code screen so user sees it while browser is open + setError(null); + setOauthProvider("Apple"); + setLoginMethod("paste-code"); } else { // Web flow - use AppleAuthProvider component which will initiate the flow console.log("[OAuth] Using web flow for Apple Sign In (Web only)"); @@ -401,6 +475,79 @@ function LoginPage() { ); } + if (loginMethod === "paste-code") { + return ( + + {oauthProvider && !showPasteInput && ( +
+ +
+ )} + {error && } + {showPasteInput && ( + <> + {oauthProvider && ( +

+ Having trouble? Paste the login code from the browser below. +

+ )} +
+ + setPasteCodeValue(e.target.value)} + className="font-mono text-xs" + autoFocus + /> +

+ After signing in with your browser, copy the code shown on the success page and + paste it here. +

+
+ + + )} + +
+ ); + } + if (loginMethod === "guest") { return (
diff --git a/frontend/src/routes/signup.tsx b/frontend/src/routes/signup.tsx index 7fc581cb..140340e3 100644 --- a/frontend/src/routes/signup.tsx +++ b/frontend/src/routes/signup.tsx @@ -36,7 +36,7 @@ export const Route = createFileRoute("/signup")({ }) }); -type SignUpMethod = "email" | "github" | "google" | "apple" | "guest" | null; +type SignUpMethod = "email" | "github" | "google" | "apple" | "guest" | "paste-code" | null; function SignupPage() { const navigate = useNavigate(); @@ -49,10 +49,26 @@ function SignupPage() { const [showGuestCredentials, setShowGuestCredentials] = useState(false); const [guestUuid, setGuestUuid] = useState(null); + const [pasteCodeValue, setPasteCodeValue] = useState(""); + const [oauthProvider, setOauthProvider] = useState(null); + const [showPasteInput, setShowPasteInput] = useState(false); + // Use platform detection functions const isIOSPlatform = isIOS(); const isTauriEnv = isTauri(); + // Show paste code input after a delay when auto-navigated from OAuth + useEffect(() => { + if (signUpMethod === "paste-code" && oauthProvider) { + setShowPasteInput(false); + const timer = setTimeout(() => setShowPasteInput(true), 3000); + return () => clearTimeout(timer); + } + if (signUpMethod === "paste-code" && !oauthProvider) { + setShowPasteInput(true); + } + }, [signUpMethod, oauthProvider]); + // Redirect if already logged in (but not if we're showing guest credentials) useEffect(() => { if (os.auth.user && !showGuestCredentials) { @@ -116,6 +132,49 @@ function SignupPage() { } }; + const handlePasteCode = async () => { + setIsLoading(true); + setError(null); + try { + const decoded = atob(pasteCodeValue.trim()); + const parsed = JSON.parse(decoded) as { access_token?: string; refresh_token?: string }; + + if (!parsed.access_token) { + throw new Error("Invalid login code: missing token"); + } + + // Store tokens in localStorage (clear old refresh token to prevent stale mismatch) + localStorage.setItem("access_token", parsed.access_token); + localStorage.removeItem("refresh_token"); + if (parsed.refresh_token) { + localStorage.setItem("refresh_token", parsed.refresh_token); + } + + // Clear any existing billing token + try { + getBillingService().clearToken(); + } catch (billingError) { + console.warn("Failed to clear billing token:", billingError); + } + + // Reload the app to pick up the new tokens + window.location.href = "/"; + } catch (err) { + if ( + err instanceof SyntaxError || + (err instanceof DOMException && err.name === "InvalidCharacterError") + ) { + setError("Invalid login code. Please copy the code from the browser and try again."); + } else if (err instanceof Error) { + setError(err.message); + } else { + setError("Failed to process login code. Please try again."); + } + } finally { + setIsLoading(false); + } + }; + const handleGitHubSignup = async () => { try { console.log("[OAuth] Using", isTauriEnv ? "Tauri" : "web", "flow"); @@ -141,6 +200,11 @@ function SignupPage() { console.error("[OAuth] Failed to open external browser:", error); setError("Failed to open authentication page in browser"); }); + + // Navigate to paste-code screen so user sees it while browser is open + setError(null); + setOauthProvider("GitHub"); + setSignUpMethod("paste-code"); } else { // Web flow remains unchanged const { auth_url } = await os.initiateGitHubAuth(""); @@ -183,6 +247,11 @@ function SignupPage() { console.error("[OAuth] Failed to open external browser:", error); setError("Failed to open authentication page in browser"); }); + + // Navigate to paste-code screen so user sees it while browser is open + setError(null); + setOauthProvider("Google"); + setSignUpMethod("paste-code"); } else { // Web flow remains unchanged const { auth_url } = await os.initiateGoogleAuth(""); @@ -352,6 +421,11 @@ function SignupPage() { console.error("[OAuth] Failed to open external browser:", error); setError("Failed to open authentication page in browser"); }); + + // Navigate to paste-code screen so user sees it while browser is open + setError(null); + setOauthProvider("Apple"); + setSignUpMethod("paste-code"); } else { // Web flow - use AppleAuthProvider component which will initiate the flow console.log("[OAuth] Using web flow for Apple Sign In (Web only)"); @@ -426,6 +500,79 @@ function SignupPage() { ); } + if (signUpMethod === "paste-code") { + return ( + + {oauthProvider && !showPasteInput && ( +
+ +
+ )} + {error && } + {showPasteInput && ( + <> + {oauthProvider && ( +

+ Having trouble? Paste the login code from the browser below. +

+ )} +
+ + setPasteCodeValue(e.target.value)} + className="font-mono text-xs" + autoFocus + /> +

+ After signing in with your browser, copy the code shown on the success page and + paste it here. +

+
+ + + )} + +
+ ); + } + if (signUpMethod === "guest") { return ( <>