diff --git a/web/src/components/RequireAuth.tsx b/web/src/components/RequireAuth.tsx index d0143e9..1349d8a 100644 --- a/web/src/components/RequireAuth.tsx +++ b/web/src/components/RequireAuth.tsx @@ -1,11 +1,12 @@ import { Navigate, Outlet, useLocation } from "react-router-dom" -import { loadSession } from "@/lib/auth" +import { loadSession, saveLoginReturnFrom } from "@/lib/auth" export function RequireAuth() { const location = useLocation() const session = loadSession() if (!session) { + saveLoginReturnFrom(location) return } return diff --git a/web/src/lib/auth.ts b/web/src/lib/auth.ts index cf76f8d..a24312a 100644 --- a/web/src/lib/auth.ts +++ b/web/src/lib/auth.ts @@ -3,6 +3,64 @@ import { useQuery, useQueryClient } from "@tanstack/react-query" import { api } from "@/lib/api" const SESSION_KEY = "sentinel_session" +const LOGIN_RETURN_TO_KEY = "sentinel_login_return_to" + +function isSafeReturnTo(path: string): boolean { + return path.startsWith("/") && !path.startsWith("//") +} + +// The pre-login path (e.g. /oauth/authorize?…&redirect_uri=…) has to outlive +// the bounce to /auth/login. React Router location.state is dropped on the +// initial history.replace, a refresh, and the Discord round-trip — so both +// email and Discord login were landing back on a stripped authorize URL. +export function saveLoginReturnTo(path: string) { + if (!isSafeReturnTo(path) || path === "/") return + const existing = sessionStorage.getItem(LOGIN_RETURN_TO_KEY) + // Don't clobber a full authorize URL with a pathname-only bounce. + if (existing && existing.includes("?") && !path.includes("?")) return + sessionStorage.setItem(LOGIN_RETURN_TO_KEY, path) +} + +export function saveLoginReturnFrom(location: { + pathname: string + search?: string + hash?: string +}) { + let path = `${location.pathname}${location.search ?? ""}${location.hash ?? ""}` + // On first load RR can report an empty search while the address bar still + // has ?client_id=…&redirect_uri=…. Prefer the bar when pathnames match. + if ( + !path.includes("?") && + window.location.search && + window.location.pathname === location.pathname + ) { + path = `${window.location.pathname}${window.location.search}${window.location.hash}` + } + saveLoginReturnTo(path) +} + +export function peekLoginReturnTo(): string | null { + const raw = sessionStorage.getItem(LOGIN_RETURN_TO_KEY) + if (!raw || !isSafeReturnTo(raw)) return null + return raw +} + +export function consumeLoginReturnTo(): string { + const value = peekLoginReturnTo() ?? "/" + sessionStorage.removeItem(LOGIN_RETURN_TO_KEY) + return value +} + +export type ReturnLocation = { pathname: string; search: string; hash: string } + +export function locationFromReturnPath(path: string): ReturnLocation { + const url = new URL(isSafeReturnTo(path) ? path : "/", window.location.origin) + return { pathname: url.pathname, search: url.search, hash: url.hash } +} + +export function consumeLoginReturnLocation(): ReturnLocation { + return locationFromReturnPath(consumeLoginReturnTo()) +} export type Session = { accessToken: string diff --git a/web/src/pages/auth/LoginDiscordPage.tsx b/web/src/pages/auth/LoginDiscordPage.tsx index c7866ca..1dfffd3 100644 --- a/web/src/pages/auth/LoginDiscordPage.tsx +++ b/web/src/pages/auth/LoginDiscordPage.tsx @@ -7,7 +7,7 @@ import { OutlineButton } from "@/components/OutlineButton" import { SuccessCheck } from "@/components/SuccessCheck" import { DiscordIcon } from "@/components/icons/socials" import { api } from "@/lib/api" -import { saveSession } from "@/lib/auth" +import { consumeLoginReturnLocation, saveSession } from "@/lib/auth" import { DISCORD_INVITE_URL } from "@/lib/links" import { cn } from "@/lib/utils" @@ -52,8 +52,6 @@ export default function LoginDiscordPage() { navigate("/auth/login", { replace: true }) return } - const returnTo = params.get("state") || "/" - void (async () => { try { const res = await api.post( @@ -65,14 +63,15 @@ export default function LoginDiscordPage() { expiresIn: res.data.expires_in, entityId: res.data.entity_id, }) + const dest = consumeLoginReturnLocation() setTransitioning(true) await new Promise((r) => setTimeout(r, CONVERGE_MS + CHECKMARK_DRAW_MS + HOLD_MS), ) if (document.startViewTransition) { - document.startViewTransition(() => navigate(returnTo, { replace: true })) + document.startViewTransition(() => navigate(dest, { replace: true })) } else { - navigate(returnTo, { replace: true }) + navigate(dest, { replace: true }) } } catch (err: unknown) { const body = diff --git a/web/src/pages/auth/LoginPage.tsx b/web/src/pages/auth/LoginPage.tsx index ca2d208..ceda4a2 100644 --- a/web/src/pages/auth/LoginPage.tsx +++ b/web/src/pages/auth/LoginPage.tsx @@ -1,6 +1,6 @@ import { Loader2 } from "lucide-react" import type { ComponentType, SVGProps } from "react" -import { useState } from "react" +import { useEffect, useState } from "react" import { useLocation, useNavigate, useSearchParams } from "react-router-dom" import { toast } from "sonner" @@ -11,7 +11,7 @@ import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Label } from "@/components/ui/label" import { api } from "@/lib/api" -import { saveSession } from "@/lib/auth" +import { consumeLoginReturnTo, locationFromReturnPath, peekLoginReturnTo, saveLoginReturnTo, saveSession } from "@/lib/auth" import { DISCORD_INVITE_URL } from "@/lib/links" import { cn } from "@/lib/utils" @@ -61,23 +61,28 @@ export default function LoginPage() { const [params] = useSearchParams() const arrivedFromOnboarding = params.has("email") // Reconstruct the full pre-login URL — pathname alone drops OAuth query - // params (?client_id=…&redirect_uri=…&scope=…) and the hash, so a 3rd-party - // app that bounces an unauthenticated user through /auth/login was landing - // back at /oauth/authorize stripped down to "Invalid request." + // params (?client_id=…&redirect_uri=…&scope=…) and the hash. + // sessionStorage is the source of truth: location.state does not survive + // the unauthenticated bounce, a refresh, or Discord. const fromLocation = ( location.state as { from?: { pathname: string; search?: string; hash?: string } } | null )?.from - const from = fromLocation + const fromRouter = fromLocation ? `${fromLocation.pathname}${fromLocation.search ?? ""}${fromLocation.hash ?? ""}` - : "/" + : null + const from = fromRouter && fromRouter !== "/" ? fromRouter : (peekLoginReturnTo() ?? "/") const [email, setEmail] = useState(params.get("email") ?? "") const [password, setPassword] = useState("") const [loading, setLoading] = useState(null) const [transitioning, setTransitioning] = useState(false) const isBusy = loading !== null || transitioning + useEffect(() => { + if (fromRouter) saveLoginReturnTo(fromRouter) + }, [fromRouter]) + async function handleSuccess() { setLoading(null) setTransitioning(true) @@ -85,10 +90,12 @@ export default function LoginPage() { await new Promise((resolve) => setTimeout(resolve, CONVERGE_MS + CHECKMARK_DRAW_MS + HOLD_MS), ) + const stored = consumeLoginReturnTo() + const dest = locationFromReturnPath(stored !== "/" ? stored : from) if (document.startViewTransition) { - document.startViewTransition(() => navigate(from, { replace: true })) + document.startViewTransition(() => navigate(dest, { replace: true })) } else { - navigate(from, { replace: true }) + navigate(dest, { replace: true }) } } @@ -125,6 +132,7 @@ export default function LoginPage() { // The redirect_uri must byte-match what the oauth service uses at // token-exchange time. Building it from window.location.origin keeps // dev/prod aligned without another env var on the web side. + saveLoginReturnTo(from) const params = new URLSearchParams({ client_id: DISCORD_CLIENT_ID, response_type: "code", @@ -134,9 +142,6 @@ export default function LoginPage() { // still see it (Discord ignores prompt=none until consent is on file // for the requested scopes); subsequent sign-ins go straight back. prompt: "none", - // Round-trip the return path so the callback can land the user - // back where they were trying to go before being bounced to login. - state: from, }) window.location.href = `${DISCORD_AUTHORIZE_URL}?${params.toString()}` return diff --git a/web/src/pages/oauth/AuthorizePage.tsx b/web/src/pages/oauth/AuthorizePage.tsx index 885b73c..77dd8f0 100644 --- a/web/src/pages/oauth/AuthorizePage.tsx +++ b/web/src/pages/oauth/AuthorizePage.tsx @@ -8,7 +8,7 @@ import { SuccessCheck } from "@/components/SuccessCheck" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Button } from "@/components/ui/button" import { api } from "@/lib/api" -import { loadSession, useAuth } from "@/lib/auth" +import { loadSession, saveLoginReturnFrom, useAuth } from "@/lib/auth" import { resolveScopes } from "@/lib/scopes" import { cn } from "@/lib/utils" @@ -189,6 +189,7 @@ export default function AuthorizePage() { }, [validate.data?.prompt]) if (!session) { + saveLoginReturnFrom(location) return } diff --git a/web/src/pages/saml/SamlAuthorizePage.tsx b/web/src/pages/saml/SamlAuthorizePage.tsx index a43785c..c7211a3 100644 --- a/web/src/pages/saml/SamlAuthorizePage.tsx +++ b/web/src/pages/saml/SamlAuthorizePage.tsx @@ -8,7 +8,7 @@ import { SuccessCheck } from "@/components/SuccessCheck" import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" import { Button } from "@/components/ui/button" import { api } from "@/lib/api" -import { loadSession, useAuth } from "@/lib/auth" +import { loadSession, saveLoginReturnFrom, useAuth } from "@/lib/auth" import { cn } from "@/lib/utils" const CONVERGE_MS = 250 @@ -154,6 +154,7 @@ export default function SamlAuthorizePage() { } if (!session) { + saveLoginReturnFrom(location) return }