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: 33 additions & 8 deletions components/dashboard-auth-guard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,44 +3,69 @@
import { useEffect } from "react"
import { useTranslation } from "react-i18next"
import { Spinner } from "@/components/ui/spinner"
import { Button } from "@/components/ui/button"
import { usePathname, useRouter } from "next/navigation"
import { useAuth } from "@/contexts/auth-context"
import { useApiReady } from "@/contexts/api-context"
import { useS3Ready } from "@/contexts/s3-context"
import { usePermissions } from "@/hooks/use-permissions"
import { canAccessDashboardRoute } from "@/lib/dashboard-route-meta"
import { getLoginRoute } from "@/lib/routes"

export function DashboardAuthGuard({ children }: { children: React.ReactNode }) {
const { t } = useTranslation()
const router = useRouter()
const pathname = usePathname()
const { isAuthenticated } = useAuth()
const { isAuthenticated, getIsLoggingOut } = useAuth()
const { isReady: apiReady } = useApiReady()
const { isReady: s3Ready } = useS3Ready()
const { isAdmin, hasFetchedPolicy, hasResolvedAdmin, canAccessPath } = usePermissions()

const isReady = apiReady && s3Ready

useEffect(() => {
if (isReady && !isAuthenticated) {
if (isReady && !isAuthenticated && !getIsLoggingOut()) {
router.replace("/auth/login/?unauthorized=true")
}
}, [isAuthenticated, isReady, router])
}, [isAuthenticated, isReady, getIsLoggingOut, router])

useEffect(() => {
if (!isReady || !isAuthenticated || !hasResolvedAdmin) return
if (!isReady || !isAuthenticated || !hasResolvedAdmin || getIsLoggingOut()) return
if (!isAdmin && !hasFetchedPolicy) return
if (!canAccessDashboardRoute(pathname, { isAdmin, canAccessPath })) {
router.replace("/403/")
}
}, [isReady, isAuthenticated, hasResolvedAdmin, isAdmin, hasFetchedPolicy, canAccessPath, pathname, router])
}, [
isReady,
isAuthenticated,
hasResolvedAdmin,
isAdmin,
hasFetchedPolicy,
canAccessPath,
pathname,
getIsLoggingOut,
router,
])

// Background refreshes must not unmount the page that requested them.
if (!isReady || !isAuthenticated || !hasResolvedAdmin || (!isAdmin && !hasFetchedPolicy)) {
return (
<div role="status" className="flex min-h-64 items-center justify-center gap-3 text-sm text-muted-foreground">
<Spinner className="size-5" />
<span>{t("Loading…")}</span>
<div className="flex min-h-64 flex-col items-center justify-center gap-3">
<div role="status" className="flex items-center gap-3 text-sm text-muted-foreground">
<Spinner className="size-5" />
<span>{t("Loading…")}</span>
</div>
{!isAuthenticated && (
<Button
variant="link"
className="min-h-11 sm:min-h-0"
nativeButton={false}
role="link"
render={<a href={getLoginRoute()} />}
>
{t("Back to Login")}
</Button>
)}
</div>
)
}
Expand Down
5 changes: 4 additions & 1 deletion contexts/api-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ interface ApiContextValue {
const ApiContext = createContext<ApiContextValue>({ api: null, isReady: false })

export function ApiProvider({ children }: { children: React.ReactNode }) {
const { credentials, isAuthenticated, logout } = useAuth()
const { credentials, isAuthenticated, logout, getIsLoggingOut } = useAuth()
const [apiClient, setApiClient] = useState<ApiClient | null>(null)
const [isReady, setIsReady] = useState(false)
const router = useRouter()
Expand Down Expand Up @@ -58,10 +58,12 @@ export function ApiProvider({ children }: { children: React.ReactNode }) {

const errorHandler = new ApiErrorHandler({
onUnauthorized: async () => {
if (getIsLoggingOut()) return
logout()
router.replace("/auth/login/")
},
onForbidden: async (url) => {
if (getIsLoggingOut()) return
// If the forbidden error happens on the accountinfo endpoint, it means the session is invalid
// or the user has no permissions at all. In this case, we should log out.
if (url?.includes("/is-admin") || url?.includes("/accountinfo") || url?.includes("/version")) {
Expand Down Expand Up @@ -94,6 +96,7 @@ export function ApiProvider({ children }: { children: React.ReactNode }) {
credentials?.SecretAccessKey,
credentials?.SessionToken,
logout,
getIsLoggingOut,
router,
])

Expand Down
42 changes: 38 additions & 4 deletions contexts/auth-context.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
"use client"

import { createContext, useCallback, useContext, useMemo, type ReactNode } from "react"
import {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
type ReactNode,
} from "react"
import type { AwsCredentialIdentity, AwsCredentialIdentityProvider } from "@aws-sdk/types"
import type { SiteConfig } from "@/types/config"
import { getLoginRoute } from "@/lib/routes"
Expand Down Expand Up @@ -41,6 +50,7 @@ interface AuthContextValue {
logout: () => void
logoutAndRedirect: () => void
logoutWithOidcRedirect: () => Promise<boolean>
getIsLoggingOut: () => boolean
setIsAdmin: (value: boolean) => void
getIsAdmin: () => boolean
credentials: Credentials | undefined
Expand Down Expand Up @@ -81,6 +91,9 @@ function isValidOidcLogoutSession(session: OidcLogoutSession | undefined): sessi
}

export function AuthProvider({ children }: { children: ReactNode }) {
// Old API/S3 callbacks must see this flag before React commits the cleared credentials.
const loggingOutRef = useRef(false)
const getIsLoggingOut = useCallback(() => loggingOutRef.current, [])
const [store, setStore] = useLocalStorage<Credentials | undefined>("auth.credentials", undefined)
const [isAdminStore, setIsAdminStore] = useLocalStorage<boolean | undefined>("auth.isAdmin", undefined)
const [oidcSessionStore, setOidcSessionStore] = useLocalStorage<OidcLogoutSession | undefined>(
Expand All @@ -90,6 +103,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {

const setCredentials = useCallback(
(credentials: Credentials) => {
loggingOutRef.current = false
setStore(credentials)
},
[setStore],
Expand Down Expand Up @@ -221,29 +235,42 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setOidcSessionStore(undefined)
}, [setStore, setIsAdminStore, setOidcSessionStore])

useEffect(() => {
const handlePageShow = (event: PageTransitionEvent) => {
if (!event.persisted || !loggingOutRef.current) return
// Reload current storage; another document may already have established a new session.
window.location.replace(getLoginRoute())
}
window.addEventListener("pageshow", handlePageShow)
return () => window.removeEventListener("pageshow", handlePageShow)
}, [])

const logoutAndRedirect = useCallback(() => {
if (loggingOutRef.current) return
logout()
window.location.href = getLoginRoute()
}, [logout])

const logoutWithOidcRedirect = useCallback(async () => {
if (loggingOutRef.current) return true
Comment thread
cxymds marked this conversation as resolved.
Resolved
const oidcSession = isValidOidcLogoutSession(oidcSessionStore) ? oidcSessionStore : undefined

if (!oidcSession) {
logout()
return false
}

loggingOutRef.current = true
try {
// Build the URL before logout(), or DashboardAuthGuard redirects to login before this navigation runs.
const { configManager } = await import("@/lib/config")
const config = await configManager.loadConfig()
const logoutUrl = buildOidcLogoutUrl(config.serverHost, oidcSession.logoutToken)

logout()
window.location.href = logoutUrl
return true
} catch {
} catch (error) {
loggingOutRef.current = false
console.error("Failed to start OIDC logout", error)
logout()
return false
}
Expand All @@ -252,6 +279,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const credentials = getCredentials()
const isAuthenticated = isValidCredentials(store)

useLayoutEffect(() => {
// Cross-tab login bypasses setCredentials; reset before consumer effects run.
if (isAuthenticated) loggingOutRef.current = false
}, [isAuthenticated])

const value = useMemo<AuthContextValue>(
() => ({
login,
Expand All @@ -260,6 +292,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
logout,
logoutAndRedirect,
logoutWithOidcRedirect,
getIsLoggingOut,
setIsAdmin,
getIsAdmin,
credentials,
Expand All @@ -273,6 +306,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
logout,
logoutAndRedirect,
logoutWithOidcRedirect,
getIsLoggingOut,
setIsAdmin,
getIsAdmin,
credentials,
Expand Down
15 changes: 10 additions & 5 deletions contexts/s3-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ const S3Context = createContext<S3ContextValue>({
})

export function S3Provider({ children }: { children: React.ReactNode }) {
const { credentials, isAuthenticated, logout } = useAuth()
const { credentials, isAuthenticated, logout, getIsLoggingOut } = useAuth()
const [s3Client, setS3Client] = useState<S3Client | null>(null)
const [isReady, setIsReady] = useState(false)
const router = useRouter()
Expand Down Expand Up @@ -152,17 +152,21 @@ export function S3Provider({ children }: { children: React.ReactNode }) {
message?: string
}
if (err?.$metadata?.httpStatusCode === 401) {
logout()
router.replace("/auth/login/")
if (!getIsLoggingOut()) {
logout()
router.replace("/auth/login/")
}
return { response: { statusCode: 401, headers: {} } }
}
if (err?.$metadata?.httpStatusCode === 403) {
const codeText = (err?.Code || err?.name || "").toLowerCase()
const isUnauthorizedAccess = codeText === "unauthorizedaccess"
const isInvalidAccessKey = codeText === "invalidaccesskeyid"
if (isUnauthorizedAccess || isInvalidAccessKey) {
logout()
router.replace("/auth/login/")
if (!getIsLoggingOut()) {
logout()
router.replace("/auth/login/")
}
return { response: { statusCode: 401, headers: {} } }
}
}
Expand Down Expand Up @@ -191,6 +195,7 @@ export function S3Provider({ children }: { children: React.ReactNode }) {
credentials?.SecretAccessKey,
credentials?.SessionToken,
logout,
getIsLoggingOut,
router,
])

Expand Down
Loading