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
41 changes: 24 additions & 17 deletions components/home/desktop-oauth/DesktopOAuthComplete.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"use client";

import { useEffect, useState } from "react";
import { useSearchParams } from "next/navigation";
import { useSearchParams, useRouter } from "next/navigation";
import { submitDesktopToken } from "@src/lib/utils/requests";

import ScriptioLogo from "@public/images/scriptio.svg";
import layout from "../../utils/Layout.module.css";
Expand All @@ -16,27 +17,33 @@ type Status = "working" | "done" | "error";
* After NextAuth has signed the user in (cookie set in this browser), we POST the
* nonce to /api/desktop/token. The server reads the session, mints a NextAuth JWE,
* and stows it under the nonce so the desktop poller can pick it up.
*
* The nonce comes from the URL for Google (callbackUrl cookie survives the redirect)
* or from sessionStorage for Apple (response_mode=form_post drops the cookie, so
* DesktopOAuthStart stored the nonce there before the handoff). If neither source
* has a nonce the visitor is a web user who signed in with Apple — send them to /projects.
*/
const DesktopOAuthComplete = () => {
const searchParams = useSearchParams();
const nonce = searchParams.get("nonce");
const [status, setStatus] = useState<Status>(nonce ? "working" : "error");
const router = useRouter();
const urlNonce = searchParams.get("nonce");
const [status, setStatus] = useState<Status>("working");

useEffect(() => {
if (!nonce) return;
(async () => {
try {
const res = await fetch("/api/desktop/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nonce }),
});
setStatus(res.ok ? "done" : "error");
} catch {
setStatus("error");
}
})();
}, [nonce]);
const nonce = urlNonce ?? sessionStorage.getItem("desktop-oauth-nonce");

if (!nonce) {
// Web user signed in with Apple — no desktop bridge needed.
router.replace("/projects");
return;
}

sessionStorage.removeItem("desktop-oauth-nonce");

submitDesktopToken(nonce)
.then(res => setStatus(res.ok ? "done" : "error"))
.catch(() => setStatus("error"));
}, [urlNonce, router]);

const message =
status === "working"
Expand Down
3 changes: 3 additions & 0 deletions components/home/desktop-oauth/DesktopOAuthStart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ const DesktopOAuthStart = () => {
// sessionToken.user.id !== getUserByAccount().id. Starting clean avoids that.
(async () => {
const callbackUrl = `/desktop-oauth/complete?nonce=${encodeURIComponent(nonce)}`;
// Persist the nonce in sessionStorage so /desktop-oauth/complete can recover
// it even when Apple's response_mode=form_post drops the callbackUrl cookie.
sessionStorage.setItem("desktop-oauth-nonce", nonce);
await signOut({ redirect: false });
await signIn(provider, { callbackUrl });
})();
Expand Down
10 changes: 6 additions & 4 deletions src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,13 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
callbacks: {
// Apple uses response_mode=form_post; the cross-site POST back from
// appleid.apple.com drops the `callbackUrl` cookie (Auth.js promotes state/nonce
// to SameSite=None for form_post but not callbackUrl), so NextAuth otherwise falls
// back to baseUrl ("/") and the user lands on the homepage instead of where they
// started. Treat that fallback as "send them to the projects page."
// to SameSite=None for form_post but not callbackUrl), so NextAuth falls back to
// baseUrl. Send those cases to /desktop-oauth/complete, which recovers the nonce
// from sessionStorage (set by DesktopOAuthStart before the OAuth handoff) and
// finishes the desktop bridge. Web users who signed in with Apple and have no
// nonce are redirected to /projects from within that page.
redirect: async ({ url, baseUrl }) => {
if (url === baseUrl || url === `${baseUrl}/`) return `${baseUrl}/projects`;
if (url === baseUrl || url === `${baseUrl}/`) return `${baseUrl}/desktop-oauth/complete`;
if (url.startsWith("/")) return `${baseUrl}${url}`;
try {
if (new URL(url).origin === baseUrl) return url;
Expand Down
4 changes: 4 additions & 0 deletions src/lib/utils/requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ export const requestMagicLink = (body: RequestMagicLinkBody) => {
return request(`/api/auth/magic-link`, "POST", body);
};

export const submitDesktopToken = (nonce: string) => {
return request(`/api/desktop/token`, "POST", { nonce });
};

export const cancelStripeSubscription = async (): Promise<boolean> => {
const res = await request("/api/stripe/cancel", "POST");
return res.ok;
Expand Down
Loading