Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,76 @@ describe("AppleAuthProvider", () => {
return { completion, control };
}

function expectPending(pending: boolean): void {
const button = renderer!.root.findByType("button");
expect(button.props.disabled).toBe(pending);
expect(button.props["aria-busy"]).toBe(pending);
expect(button.children.includes("Signing in…")).toBe(pending);
expect(
button.findAllByType("svg").some((icon) => icon.props.className.includes("animate-spin"))
).toBe(pending);
}

test("shows pending from initiation through the closed popup's callback and navigation", async () => {
let resolveInitiation!: (result: { auth_url: string; state: string }) => void;
let resolveCallback!: () => void;
initiateAppleAuth.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveInitiation = resolve;
})
);
handleAppleCallback.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
resolveCallback = resolve;
})
);
await act(async () => {
renderer = create(
<AppleAuthProvider onSuccess={onSuccess} redirectAfterLogin={redirectAfterLogin} />
);
});
expectPending(false);

let completion!: Promise<void>;
await act(async () => {
completion = renderer!.root.findByType("button").props.onClick();
});
expectPending(true);
expect(appleSignIn).not.toHaveBeenCalled();

await act(async () => {
resolveInitiation({
auth_url: `https://appleid.apple.com/auth/authorize?client_id=cloud.opensecret.maple.services&nonce=${"11".repeat(32)}`,
state: "state-one"
});
});
expectPending(true);
expect(appleSignIn).toHaveBeenCalledTimes(1);

await act(async () => {
signInControls.shift()!.resolve({
authorization: { code: "callback-code", state: "state-one" }
});
});
expect(handleAppleCallback).toHaveBeenCalledTimes(1);
expectPending(true);
expect(redirectAfterLogin).not.toHaveBeenCalled();
await act(async () => {
await renderer!.root.findByType("button").props.onClick();
});
expect(initiateAppleAuth).toHaveBeenCalledTimes(1);

await act(async () => {
resolveCallback();
await completion;
});
expect(onSuccess).toHaveBeenCalledTimes(1);
expect(redirectAfterLogin).toHaveBeenCalledTimes(1);
expectPending(true);
});

for (const clientId of [
"cloud.opensecret.maple.services",
"cloud.opensecret.maple.dev.services"
Expand Down Expand Up @@ -326,6 +396,7 @@ describe("AppleAuthProvider", () => {
).toEqual([]);

const firstAttempt = await startAttempt();
expectPending(true);
await act(async () => {
await renderer?.root.findByType("button").props.onClick();
});
Expand All @@ -352,17 +423,21 @@ describe("AppleAuthProvider", () => {

expect(onError).toHaveBeenCalledTimes(0);
expect(handleAppleCallback).toHaveBeenCalledTimes(0);
expectPending(false);

const legacyCancellationAttempt = await startAttempt();
expectPending(true);
await act(async () => {
legacyCancellationAttempt.control.reject({ error: "popup_closed_by_user" });
await legacyCancellationAttempt.completion;
});

expect(onError).toHaveBeenCalledTimes(0);
expect(handleAppleCallback).toHaveBeenCalledTimes(0);
expectPending(false);

const failedAttempt = await startAttempt();
expectPending(true);
await act(async () => {
failedAttempt.control.reject({ error: "authorization_failed" });
await failedAttempt.completion;
Expand All @@ -371,6 +446,7 @@ describe("AppleAuthProvider", () => {
expect(onError).toHaveBeenCalledTimes(1);
expect(onError.mock.calls[0]?.[0]).toEqual(new Error("authorization_failed"));
expect(handleAppleCallback).toHaveBeenCalledTimes(0);
expectPending(false);

await act(async () => {
renderer?.update(
Expand All @@ -385,6 +461,7 @@ describe("AppleAuthProvider", () => {
});

const retry = await startAttempt();
expectPending(true);
documentTarget.dispatchEvent(
appleEvent("AppleIDSignInOnSuccess", {
authorization: { code: "promise-code", state: "state-two" }
Expand Down
21 changes: 18 additions & 3 deletions apps/maple-research/frontend/src/components/AppleAuthProvider.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useEffect, useRef, useState } from "react";
import { useOpenSecret } from "@mapleai/sdk";
import { Loader2 } from "lucide-react";
import { Button, type ButtonProps } from "./ui/button";
import { Apple } from "./icons/Apple";
import { HostedNativeSignInConfirmation } from "./HostedNativeSignInConfirmation";
Expand Down Expand Up @@ -121,6 +122,7 @@ export function AppleAuthProvider({
const os = useOpenSecret();
const appleScriptLoaded = useRef(false);
const isSignInPending = useRef(false);
const [isPending, setIsPending] = useState(false);
const active = useRef(true);
const ownedTarget = useRef<TransportV2DesktopOAuthState | null>(null);
const [nativeConfirmation, setNativeConfirmation] = useState<TransportV2DesktopOAuthState | null>(
Expand Down Expand Up @@ -217,6 +219,8 @@ export function AppleAuthProvider({
const handleAppleSignIn = async () => {
if (isSignInPending.current || nativeConfirmation || !active.current) return;
isSignInPending.current = true;
setIsPending(true);
let completed = false;
const nativeFlow = isNativeOAuthRedirect();
const target = nativeFlow ? readTransportV2DesktopOAuth("apple") : null;
ownedTarget.current = target;
Expand All @@ -238,6 +242,7 @@ export function AppleAuthProvider({
}

await completeAuthorization(authorization, nativeFlow, target);
completed = true;
} catch (error) {
if (!active.current || (target && !isCurrentDesktopOAuthTarget(target))) return;
const signInError = getAppleAuthError(error);
Expand All @@ -247,7 +252,11 @@ export function AppleAuthProvider({
onError?.(signInError);
}
} finally {
isSignInPending.current = false;
// Successful sign-in stays busy until navigation or account confirmation replaces it.
if (!completed) {
isSignInPending.current = false;
if (active.current) setIsPending(false);
}
}
};

Expand All @@ -269,9 +278,15 @@ export function AppleAuthProvider({
onClick={handleAppleSignIn}
variant={buttonVariant}
className={className || "w-full"}
disabled={isPending}
aria-busy={isPending}
>
<Apple className="mr-2 h-4 w-4" />
{buttonLabel}
{isPending ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<Apple className="mr-2 h-4 w-4" />
)}
{isPending ? "Signing in…" : buttonLabel}
</Button>
);
}
38 changes: 34 additions & 4 deletions apps/maple-research/frontend/src/routes/login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,21 @@ function LoginPage() {
const [loginMethod, setLoginMethod] = useState<LoginMethod>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [pendingProvider, setPendingProvider] = useState<"github" | "google" | null>(null);

// Use platform detection functions
const isIOSPlatform = isIOS();
const isTauriEnv = isTauri();

// A provider redirect can leave this page in the browser's back-forward cache.
useEffect(() => {
const restoreLogin = (event: PageTransitionEvent) => {
if (event.persisted) setPendingProvider(null);
};
window.addEventListener("pageshow", restoreLogin);
return () => window.removeEventListener("pageshow", restoreLogin);
}, []);

// Redirect if already logged in
useEffect(() => {
if (os.auth.user) {
Expand Down Expand Up @@ -123,6 +133,9 @@ function LoginPage() {
};

const handleGitHubLogin = async () => {
if (pendingProvider) return;
if (!isTauriEnv) setPendingProvider("github");
setError(null);
try {
console.log("[OAuth] Using", isTauriEnv ? "Tauri" : "web", "flow");

Expand Down Expand Up @@ -151,11 +164,15 @@ function LoginPage() {
}
} catch (error) {
console.error("Failed to initiate GitHub login:", error);
setPendingProvider(null);
setError("Failed to initiate GitHub login. Please try again.");
}
};

const handleGoogleLogin = async () => {
if (pendingProvider) return;
if (!isTauriEnv) setPendingProvider("google");
setError(null);
try {
console.log("[OAuth] Using", isTauriEnv ? "Tauri" : "web", "flow");

Expand Down Expand Up @@ -184,6 +201,7 @@ function LoginPage() {
}
} catch (error) {
console.error("Failed to initiate Google login:", error);
setPendingProvider(null);
setError("Failed to initiate Google login. Please try again.");
}
};
Expand Down Expand Up @@ -351,19 +369,31 @@ function LoginPage() {
</Button>
<Button
onClick={handleGitHubLogin}
disabled={pendingProvider !== null}
aria-busy={pendingProvider === "github"}
variant="outline"
className="w-full bg-white/40 dark:bg-white/0"
>
<Github className="mr-2 h-4 w-4" />
Log in with GitHub
{pendingProvider === "github" ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<Github className="mr-2 h-4 w-4" />
)}
{pendingProvider === "github" ? "Signing in…" : "Log in with GitHub"}
</Button>
<Button
onClick={handleGoogleLogin}
disabled={pendingProvider !== null}
aria-busy={pendingProvider === "google"}
variant="outline"
className="w-full bg-white/40 dark:bg-white/0"
>
<Google className="mr-2 h-4 w-4" />
Log in with Google
{pendingProvider === "google" ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<Google className="mr-2 h-4 w-4" />
)}
{pendingProvider === "google" ? "Signing in…" : "Log in with Google"}
</Button>
{isTauriEnv ? (
<Button
Expand Down
38 changes: 34 additions & 4 deletions apps/maple-research/frontend/src/routes/signup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ function SignupPage() {
const [signUpMethod, setSignUpMethod] = useState<SignUpMethod>(null);
const [error, setError] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [pendingProvider, setPendingProvider] = useState<"github" | "google" | null>(null);
const [showGuestWarning, setShowGuestWarning] = useState(false);
const [showGuestCredentials, setShowGuestCredentials] = useState(false);
const [guestUuid, setGuestUuid] = useState<string | null>(null);
Expand All @@ -68,6 +69,15 @@ function SignupPage() {
const isIOSPlatform = isIOS();
const isTauriEnv = isTauri();

// A provider redirect can leave this page in the browser's back-forward cache.
useEffect(() => {
const restoreLogin = (event: PageTransitionEvent) => {
if (event.persisted) setPendingProvider(null);
};
window.addEventListener("pageshow", restoreLogin);
return () => window.removeEventListener("pageshow", restoreLogin);
}, []);

// Redirect if already logged in (but not if we're showing guest credentials)
useEffect(() => {
if (
Expand Down Expand Up @@ -153,6 +163,9 @@ function SignupPage() {
};

const handleGitHubSignup = async () => {
if (pendingProvider) return;
if (!isTauriEnv) setPendingProvider("github");
setError(null);
try {
console.log("[OAuth] Using", isTauriEnv ? "Tauri" : "web", "flow");

Expand Down Expand Up @@ -181,11 +194,15 @@ function SignupPage() {
}
} catch (error) {
console.error("Failed to initiate GitHub signup:", error);
setPendingProvider(null);
setError("Failed to initiate GitHub signup. Please try again.");
}
};

const handleGoogleSignup = async () => {
if (pendingProvider) return;
if (!isTauriEnv) setPendingProvider("google");
setError(null);
try {
console.log("[OAuth] Using", isTauriEnv ? "Tauri" : "web", "flow");

Expand Down Expand Up @@ -214,6 +231,7 @@ function SignupPage() {
}
} catch (error) {
console.error("Failed to initiate Google signup:", error);
setPendingProvider(null);
setError("Failed to initiate Google signup. Please try again.");
}
};
Expand Down Expand Up @@ -384,19 +402,31 @@ function SignupPage() {
</Button>
<Button
onClick={handleGitHubSignup}
disabled={pendingProvider !== null}
aria-busy={pendingProvider === "github"}
variant="outline"
className="w-full bg-white/40 dark:bg-white/0"
>
<Github className="mr-2 h-4 w-4" />
Sign up with GitHub
{pendingProvider === "github" ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<Github className="mr-2 h-4 w-4" />
)}
{pendingProvider === "github" ? "Signing in…" : "Sign up with GitHub"}
</Button>
<Button
onClick={handleGoogleSignup}
disabled={pendingProvider !== null}
aria-busy={pendingProvider === "google"}
variant="outline"
className="w-full bg-white/40 dark:bg-white/0"
>
<Google className="mr-2 h-4 w-4" />
Sign up with Google
{pendingProvider === "google" ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
) : (
<Google className="mr-2 h-4 w-4" />
)}
{pendingProvider === "google" ? "Signing in…" : "Sign up with Google"}
</Button>
{isTauriEnv ? (
<Button
Expand Down
Loading