-
Notifications
You must be signed in to change notification settings - Fork 6
Add fallback copy/paste code flow for OAuth when deep link redirect fails #438
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
4810aef
0e02bd7
3d6a626
d77ee8d
47ba95b
c2b9013
debd6cf
fef1e19
0b2bd27
f3db5fa
fe77fc8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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<string | null>(null); | ||||||||||||||||||||||||||||||||||||||||||||
| const [showCopyFallback, setShowCopyFallback] = useState(false); | ||||||||||||||||||||||||||||||||||||||||||||
| const [copied, setCopied] = useState(false); | ||||||||||||||||||||||||||||||||||||||||||||
| const [authCode, setAuthCode] = useState<string | null>(null); | ||||||||||||||||||||||||||||||||||||||||||||
| const [nativeRedirectUrl, setNativeRedirectUrl] = useState<string | null>(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<HTMLInputElement>("[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)); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
64
to
+73
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fail fast when OAuth tokens are missing before generating redirect/code. If 🔧 Suggested guard const accessToken = localStorage.getItem("access_token") || "";
const refreshToken = localStorage.getItem("refresh_token");
+ if (!accessToken) {
+ throw new Error("Missing access token in OAuth callback");
+ }
// Generate the fallback auth code for copy/paste flow
const codePayload = JSON.stringify({📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||
| 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() { | |||||||||||||||||||||||||||||||||||||||||||
| <p className="mb-4"> | ||||||||||||||||||||||||||||||||||||||||||||
| Authentication successful! Tap the button below to return to Maple. | ||||||||||||||||||||||||||||||||||||||||||||
| </p> | ||||||||||||||||||||||||||||||||||||||||||||
| <div className="flex justify-center"> | ||||||||||||||||||||||||||||||||||||||||||||
| <div className="flex justify-center mb-4"> | ||||||||||||||||||||||||||||||||||||||||||||
| <Button onClick={() => (window.location.href = nativeRedirectUrl)}>Open Maple</Button> | ||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||
| {showCopyFallback && authCode && ( | ||||||||||||||||||||||||||||||||||||||||||||
| <div className="border-t pt-4 mt-2"> | ||||||||||||||||||||||||||||||||||||||||||||
| <p className="text-sm text-muted-foreground mb-3"> | ||||||||||||||||||||||||||||||||||||||||||||
| <ExternalLink className="inline h-4 w-4 mr-1" /> | ||||||||||||||||||||||||||||||||||||||||||||
| Trouble opening the app? Copy the code below and paste it in the Maple app. | ||||||||||||||||||||||||||||||||||||||||||||
| </p> | ||||||||||||||||||||||||||||||||||||||||||||
| <div className="flex gap-2"> | ||||||||||||||||||||||||||||||||||||||||||||
| <input | ||||||||||||||||||||||||||||||||||||||||||||
| type="text" | ||||||||||||||||||||||||||||||||||||||||||||
| readOnly | ||||||||||||||||||||||||||||||||||||||||||||
| value={authCode} | ||||||||||||||||||||||||||||||||||||||||||||
| data-auth-code | ||||||||||||||||||||||||||||||||||||||||||||
| className="flex-1 rounded-md border bg-muted px-3 py-2 text-xs font-mono truncate" | ||||||||||||||||||||||||||||||||||||||||||||
| onClick={(e) => (e.target as HTMLInputElement).select()} | ||||||||||||||||||||||||||||||||||||||||||||
| /> | ||||||||||||||||||||||||||||||||||||||||||||
| <Button onClick={handleCopyCode} variant="outline" size="sm" className="shrink-0"> | ||||||||||||||||||||||||||||||||||||||||||||
| {copied ? ( | ||||||||||||||||||||||||||||||||||||||||||||
| <> | ||||||||||||||||||||||||||||||||||||||||||||
| <Check className="h-4 w-4 mr-1" /> Copied | ||||||||||||||||||||||||||||||||||||||||||||
| </> | ||||||||||||||||||||||||||||||||||||||||||||
| ) : ( | ||||||||||||||||||||||||||||||||||||||||||||
| <> | ||||||||||||||||||||||||||||||||||||||||||||
| <Copy className="h-4 w-4 mr-1" /> Copy | ||||||||||||||||||||||||||||||||||||||||||||
| </> | ||||||||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||||||||
| </Button> | ||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||
| <p className="text-xs text-muted-foreground mt-2"> | ||||||||||||||||||||||||||||||||||||||||||||
| Switch to the Maple app and paste the code into the login code input. | ||||||||||||||||||||||||||||||||||||||||||||
| </p> | ||||||||||||||||||||||||||||||||||||||||||||
AnthonyRonning marked this conversation as resolved.
Show resolved
Hide resolved
devin-ai-integration[bot] marked this conversation as resolved.
Show resolved
Hide resolved
|
||||||||||||||||||||||||||||||||||||||||||||
| </div> | ||||||||||||||||||||||||||||||||||||||||||||
| )} | ||||||||||||||||||||||||||||||||||||||||||||
| </CardContent> | ||||||||||||||||||||||||||||||||||||||||||||
| </Card> | ||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<string | null>(null); | ||
| const [isLoading, setIsLoading] = useState(false); | ||
|
|
||
| const [pasteCodeValue, setPasteCodeValue] = useState(""); | ||
| const [oauthProvider, setOauthProvider] = useState<string | null>(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 = "/"; | ||
|
Comment on lines
+149
to
+150
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Preserve route intent instead of always redirecting to Hardcoding 🔧 Suggested redirect preservation- // Reload the app to pick up the new tokens
- window.location.href = "/";
+ // Reload the app to pick up the new tokens while preserving intent
+ const redirectTarget = selected_plan
+ ? `/pricing?selected_plan=${encodeURIComponent(selected_plan)}`
+ : next === "/redeem" && code
+ ? `/redeem?code=${encodeURIComponent(code)}`
+ : next || "/";
+ window.location.href = redirectTarget;🤖 Prompt for AI Agents |
||
| } 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 ( | ||
| <AuthMain | ||
| title={oauthProvider ? `Logging in with ${oauthProvider}` : "Paste Login Code"} | ||
| description={ | ||
| oauthProvider | ||
| ? `Complete your ${oauthProvider} login in the browser that just opened.` | ||
| : "Paste the code from your browser to complete authentication." | ||
| } | ||
| > | ||
| {oauthProvider && !showPasteInput && ( | ||
| <div className="flex justify-center py-4"> | ||
| <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> | ||
| </div> | ||
| )} | ||
| {error && <AlertDestructive title="Error" description={error} />} | ||
| {showPasteInput && ( | ||
| <> | ||
| {oauthProvider && ( | ||
| <p className="text-sm text-muted-foreground text-center"> | ||
| Having trouble? Paste the login code from the browser below. | ||
| </p> | ||
| )} | ||
| <div className="grid gap-2"> | ||
| <Label htmlFor="auth-code">Login Code</Label> | ||
| <Input | ||
| id="auth-code" | ||
| type="text" | ||
| placeholder="Paste your login code here" | ||
| value={pasteCodeValue} | ||
| onChange={(e) => setPasteCodeValue(e.target.value)} | ||
| className="font-mono text-xs" | ||
| autoFocus | ||
| /> | ||
| <p className="text-xs text-muted-foreground"> | ||
| After signing in with your browser, copy the code shown on the success page and | ||
| paste it here. | ||
| </p> | ||
| </div> | ||
| <Button | ||
| onClick={handlePasteCode} | ||
| className="w-full" | ||
| disabled={isLoading || !pasteCodeValue.trim()} | ||
| > | ||
| {isLoading ? ( | ||
| <> | ||
| <Loader2 className="mr-2 h-4 w-4 animate-spin" /> | ||
| Verifying... | ||
| </> | ||
| ) : ( | ||
| "Complete Login" | ||
| )} | ||
| </Button> | ||
| </> | ||
| )} | ||
| <Button | ||
| type="button" | ||
| variant="outline" | ||
| onClick={() => { | ||
| setLoginMethod(null); | ||
| setOauthProvider(null); | ||
| setPasteCodeValue(""); | ||
| setShowPasteInput(false); | ||
| setError(null); | ||
| }} | ||
| className="w-full" | ||
| > | ||
| Back | ||
| </Button> | ||
| </AuthMain> | ||
| ); | ||
| } | ||
|
|
||
| if (loginMethod === "guest") { | ||
| return ( | ||
| <form onSubmit={handleGuestSubmit}> | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.