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
}