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
3 changes: 2 additions & 1 deletion web/src/components/RequireAuth.tsx
Original file line number Diff line number Diff line change
@@ -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 <Navigate to="/auth/login" state={{ from: location }} replace />
}
return <Outlet />
Expand Down
58 changes: 58 additions & 0 deletions web/src/lib/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 4 additions & 5 deletions web/src/pages/auth/LoginDiscordPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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<LoginResponse>(
Expand All @@ -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 =
Expand Down
29 changes: 17 additions & 12 deletions web/src/pages/auth/LoginPage.tsx
Original file line number Diff line number Diff line change
@@ -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"

Expand All @@ -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"

Expand Down Expand Up @@ -61,34 +61,41 @@ 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<LoadingTarget>(null)
const [transitioning, setTransitioning] = useState(false)
const isBusy = loading !== null || transitioning

useEffect(() => {
if (fromRouter) saveLoginReturnTo(fromRouter)
}, [fromRouter])

async function handleSuccess() {
setLoading(null)
setTransitioning(true)
// Wait for convergence + checkmark draw + hold before navigating.
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 })
}
}

Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion web/src/pages/oauth/AuthorizePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -189,6 +189,7 @@ export default function AuthorizePage() {
}, [validate.data?.prompt])

if (!session) {
saveLoginReturnFrom(location)
return <Navigate to="/auth/login" state={{ from: location }} replace />
}

Expand Down
3 changes: 2 additions & 1 deletion web/src/pages/saml/SamlAuthorizePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -154,6 +154,7 @@ export default function SamlAuthorizePage() {
}

if (!session) {
saveLoginReturnFrom(location)
return <Navigate to="/auth/login" state={{ from: location }} replace />
}

Expand Down
Loading