Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 65 additions & 3 deletions frontend/src/routes/auth.$provider.callback.tsx
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";

Expand All @@ -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)
Expand All @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Fail fast when OAuth tokens are missing before generating redirect/code.

If access_token is absent, this path still generates a deep link and copy code, leading to a broken "successful" flow.

🔧 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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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));
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({
access_token: accessToken,
refresh_token: refreshToken || ""
});
setAuthCode(btoa(codePayload));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/routes/auth`.$provider.callback.tsx around lines 64 - 73,
Currently the code always builds a fallback auth code using accessToken and
refreshToken and calls setAuthCode(btoa(codePayload)) even when access_token is
missing; change the logic in this component (the block that defines accessToken,
refreshToken, codePayload and calls setAuthCode) to fail fast when accessToken
is falsy: if accessToken is missing, do not construct the codePayload or call
setAuthCode, instead set an error state or trigger the error/redirect flow
(e.g., call the existing error handler or navigate to an error page) so the UI
doesn't present a broken "successful" deep link; keep refreshToken optional but
ensure accessToken is required before creating the deep link.

let deepLinkUrl = `cloud.opensecret.maple://auth?access_token=${encodeURIComponent(accessToken)}`;

if (refreshToken) {
Expand All @@ -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;
}

Expand Down Expand Up @@ -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>
</div>
)}
</CardContent>
</Card>
);
Expand Down
149 changes: 148 additions & 1 deletion frontend/src/routes/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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) {
Expand Down Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Preserve route intent instead of always redirecting to / after paste-code login.

Hardcoding / drops active flows like selected_plan and next === "/redeem" with code.

🔧 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
Verify each finding against the current code and only fix it if needed.

In `@frontend/src/routes/login.tsx` around lines 149 - 150, The current hardcoded
post-login reload (window.location.href = "/") drops any active route intent
(query params like selected_plan or next), so change the reload to preserve the
current URL + query/hash (or use the router navigate) instead of forcing "/" —
e.g., in the login completion handler in frontend/src/routes/login.tsx (the
block that sets window.location.href = "/"), replace it with a redirect that
retains window.location.pathname + window.location.search + window.location.hash
(or call the router's navigate to the current location) so pasted-code flows
like selected_plan and next=/redeem?code=... are preserved.

} 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");
Expand All @@ -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("");
Expand Down Expand Up @@ -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("");
Expand Down Expand Up @@ -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)");
Expand Down Expand Up @@ -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}>
Expand Down
Loading
Loading