diff --git a/web-client/index.html b/web-client/index.html index af9e162..caea363 100644 --- a/web-client/index.html +++ b/web-client/index.html @@ -12,8 +12,33 @@ if (isDark) document.documentElement.classList.add('dark'); })(); + +
diff --git a/web-client/nginx.conf b/web-client/nginx.conf index 33f68ba..ddb30e5 100644 --- a/web-client/nginx.conf +++ b/web-client/nginx.conf @@ -49,6 +49,17 @@ server { # proxy_set_header X-Forwarded-Proto $scheme; # } + # Keycloak silent-check-sso: must be loadable in a hidden iframe. + # X-Frame-Options: DENY (set above with `always`) would block the iframe, + # preventing keycloak.js from receiving the postMessage and causing a timeout. + # Nginx does NOT inherit parent add_header directives in child blocks, so we + # re-declare the other security headers explicitly and omit X-Frame-Options. + location = /silent-check-sso.html { + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + try_files $uri =404; + } + # SPA routing: anything that isn't a real file falls back to index.html location / { try_files $uri $uri/ /index.html; diff --git a/web-client/public/silent-check-sso.html b/web-client/public/silent-check-sso.html new file mode 100644 index 0000000..0633966 --- /dev/null +++ b/web-client/public/silent-check-sso.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/web-client/src/AuthenticatedApp.tsx b/web-client/src/AuthenticatedApp.tsx new file mode 100644 index 0000000..e3d27b7 --- /dev/null +++ b/web-client/src/AuthenticatedApp.tsx @@ -0,0 +1,137 @@ +import { useEffect, useRef, useState } from 'react' +import { AlertCircle } from 'lucide-react' +import App from '@/App' +import { Button } from '@/components/ui/button' +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card' +import { LoadingSpinner } from '@/components/ui/LoadingSpinner' +import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' +import keycloak, { KEYCLOAK_URL, TOKEN_REFRESH_MIN_VALIDITY_SECONDS } from '@/lib/keycloak' +import { + AUTH_INIT_TIMEOUT_MS, + classifyAuthError, + type AuthError, + withTimeout, +} from '@/lib/auth-bootstrap' + +function removeSplash() { + document.getElementById('splash')?.remove() +} + +export default function AuthenticatedApp() { + const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading') + const [authError, setAuthError] = useState(null) + const didInitRef = useRef(false) + + useEffect(() => { + removeSplash() + }, []) + + useEffect(() => { + if (didInitRef.current) { + return + } + + didInitRef.current = true + + async function initializeAuth() { + try { + const authenticated = await withTimeout( + keycloak.init({ + onLoad: 'check-sso', + silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html', + pkceMethod: 'S256', + checkLoginIframe: false, + }), + AUTH_INIT_TIMEOUT_MS, + ) + + if (!authenticated) { + await keycloak.login() + return + } + + setAuthError(null) + setStatus('ready') + } catch (error) { + setAuthError(classifyAuthError(error)) + setStatus('error') + } + } + + void initializeAuth() + + return () => { + } + }, []) + + useEffect(() => { + if (status !== 'ready') { + return + } + + keycloak.onTokenExpired = () => { + keycloak.updateToken(TOKEN_REFRESH_MIN_VALIDITY_SECONDS).catch(() => undefined) + } + + return () => { + keycloak.onTokenExpired = undefined + } + }, [status]) + + if (status === 'loading') { + return ( +
+ +
+ ) + } + + if (status === 'error') { + const messages: Record = { + network: { + title: 'Cannot reach authentication server', + description: 'Check your connection and try again.', + }, + config: { + title: 'Authentication misconfigured', + description: 'Contact your administrator — the auth server may be misconfigured.', + }, + timeout: { + title: 'Authentication is stuck', + description: `Sign-in never finished. Verify that Keycloak is reachable at ${KEYCLOAK_URL} and that the login redirect is not being blocked.`, + }, + unknown: { + title: 'Authentication failed', + description: 'An unexpected error occurred. Please try again.', + }, + } + const { title, description } = messages[authError ?? 'unknown'] + + return ( +
+ + + {title} + {description} + + + + + Sign-in error + + If this keeps happening, contact support. + + + + + + + +
+ ) + } + + return +} diff --git a/web-client/src/__tests__/AuthenticatedApp.test.tsx b/web-client/src/__tests__/AuthenticatedApp.test.tsx new file mode 100644 index 0000000..7067530 --- /dev/null +++ b/web-client/src/__tests__/AuthenticatedApp.test.tsx @@ -0,0 +1,196 @@ +import { StrictMode, act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const keycloakMock = { + init: vi.fn<(options?: unknown) => Promise>(), + login: vi.fn<() => Promise>(), + updateToken: vi.fn<(minValidity: number) => Promise>(), + onTokenExpired: undefined as (() => void) | undefined, +} + +vi.mock('@/App', () => ({ + default: () =>
App ready
, +})) + +vi.mock('@/lib/keycloak', () => ({ + KEYCLOAK_URL: 'http://keycloak.test', + TOKEN_REFRESH_MIN_VALIDITY_SECONDS: 30, + default: keycloakMock, +})) + +const { default: AuthenticatedApp } = await import('@/AuthenticatedApp') + +describe('AuthenticatedApp', () => { + let container: HTMLDivElement + let root: Root + + beforeEach(() => { + vi.clearAllMocks() + keycloakMock.onTokenExpired = undefined + keycloakMock.login.mockResolvedValue(undefined) + keycloakMock.updateToken.mockResolvedValue(true) + document.body.innerHTML = '
' + container = document.getElementById('root') as HTMLDivElement + root = createRoot(container) + }) + + afterEach(async () => { + await act(async () => { + root.unmount() + }) + document.body.innerHTML = '' + }) + + async function render() { + await act(async () => { + root.render() + // flush microtasks so the async init chain resolves + await new Promise((r) => setTimeout(r, 0)) + }) + } + + async function renderInStrictMode() { + await act(async () => { + root.render( + + + , + ) + await new Promise((r) => setTimeout(r, 0)) + }) + } + + // --------------------------------------------------------------------------- + // Happy path + // --------------------------------------------------------------------------- + + it('renders the app when keycloak init resolves authenticated', async () => { + keycloakMock.init.mockResolvedValue(true) + + await render() + + expect(container.textContent).toContain('App ready') + }) + + it('removes the splash screen on successful auth', async () => { + keycloakMock.init.mockResolvedValue(true) + + await render() + + expect(document.getElementById('splash')).toBeNull() + }) + + it('registers onTokenExpired handler after successful auth', async () => { + keycloakMock.init.mockResolvedValue(true) + + await render() + + expect(keycloakMock.onTokenExpired).toBeTypeOf('function') + }) + + it('re-registers onTokenExpired after StrictMode effect cleanup without re-initializing auth', async () => { + keycloakMock.init.mockResolvedValue(true) + + await renderInStrictMode() + + expect(keycloakMock.init).toHaveBeenCalledTimes(1) + expect(keycloakMock.onTokenExpired).toBeTypeOf('function') + }) + + it('calls updateToken(30) when token expires', async () => { + keycloakMock.init.mockResolvedValue(true) + + await render() + + await act(async () => { + keycloakMock.onTokenExpired?.() + await new Promise((r) => setTimeout(r, 0)) + }) + + expect(keycloakMock.updateToken).toHaveBeenCalledWith(30) + }) + + it('does not call login() when token refresh fails on expiry', async () => { + keycloakMock.init.mockResolvedValue(true) + keycloakMock.updateToken.mockRejectedValue(new Error('session gone')) + + await render() + + await act(async () => { + keycloakMock.onTokenExpired?.() + await new Promise((r) => setTimeout(r, 0)) + }) + + expect(keycloakMock.login).not.toHaveBeenCalled() + }) + + // --------------------------------------------------------------------------- + // Not-authenticated path (redirect) + // --------------------------------------------------------------------------- + + it('calls keycloak.login() when init resolves not-authenticated', async () => { + keycloakMock.init.mockResolvedValue(false) + + await render() + + expect(keycloakMock.login).toHaveBeenCalledTimes(1) + expect(container.textContent).not.toContain('App ready') + }) + + // --------------------------------------------------------------------------- + // Error paths — each error type maps to the right heading + // --------------------------------------------------------------------------- + + it('shows network error heading when fetch fails', async () => { + keycloakMock.init.mockRejectedValue(new Error('Failed to fetch')) + + await render() + + expect(container.textContent).toContain('Cannot reach authentication server') + expect(container.textContent).not.toContain('App ready') + }) + + it('shows config error heading when realm is invalid', async () => { + keycloakMock.init.mockRejectedValue(new Error('realm not found')) + + await render() + + expect(container.textContent).toContain('Authentication misconfigured') + }) + + it('shows timeout error heading and keycloak URL when init times out', async () => { + keycloakMock.init.mockRejectedValue( + new Error('Authentication timed out after 15000ms while contacting http://keycloak.test'), + ) + + await render() + + expect(container.textContent).toContain('Authentication is stuck') + expect(container.textContent).toContain('http://keycloak.test') + }) + + it('shows generic error heading for unknown failures', async () => { + keycloakMock.init.mockRejectedValue(new Error('something completely unexpected')) + + await render() + + expect(container.textContent).toContain('Authentication failed') + }) + + it('removes the splash screen on auth error', async () => { + keycloakMock.init.mockRejectedValue(new Error('Failed to fetch')) + + await render() + + expect(document.getElementById('splash')).toBeNull() + }) + + it('shows a retry button on auth error', async () => { + keycloakMock.init.mockRejectedValue(new Error('Failed to fetch')) + + await render() + + expect(container.textContent).toContain('Try again') + }) +}) diff --git a/web-client/src/__tests__/auth-bootstrap.test.ts b/web-client/src/__tests__/auth-bootstrap.test.ts new file mode 100644 index 0000000..5e3110f --- /dev/null +++ b/web-client/src/__tests__/auth-bootstrap.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { + AUTH_INIT_TIMEOUT_MS, + classifyAuthError, + createAuthInitTimeoutError, + withTimeout, +} from '@/lib/auth-bootstrap' + +describe('auth bootstrap helpers', () => { + it('classifies auth init timeouts explicitly', () => { + expect(classifyAuthError(createAuthInitTimeoutError())).toBe('timeout') + }) + + it('preserves existing network classification', () => { + expect(classifyAuthError(new Error('Failed to fetch'))).toBe('network') + }) + + it('rejects when a promise never settles', async () => { + await expect( + withTimeout(new Promise(() => {}), 1, () => createAuthInitTimeoutError(1)), + ).rejects.toThrow('Authentication timed out after 1ms') + }) + + it('exposes the configured auth timeout constant', () => { + expect(AUTH_INIT_TIMEOUT_MS).toBe(15000) + }) +}) diff --git a/web-client/src/__tests__/keycloak.test.ts b/web-client/src/__tests__/keycloak.test.ts index cf11315..aa38d81 100644 --- a/web-client/src/__tests__/keycloak.test.ts +++ b/web-client/src/__tests__/keycloak.test.ts @@ -1,9 +1,10 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import type { InternalAxiosRequestConfig } from 'axios' +import type { AxiosError, InternalAxiosRequestConfig } from 'axios' // Shared mock state — must be declared before vi.mock so the factory can close over it. const mock = { token: 'mock-token' as string | undefined, + tokenParsed: { exp: Math.floor(Date.now() / 1000) + 10 } as { exp?: number } | undefined, updateToken: vi.fn<(n: number) => Promise>(), login: vi.fn<() => Promise>(), } @@ -14,7 +15,7 @@ vi.mock('keycloak-js', () => ({ default: vi.fn(() => mock), })) -const { createApiClient } = await import('@/lib/keycloak') +const { createApiClient, resetKeycloakRefreshStateForTests } = await import('@/lib/keycloak') /** Minimal axios adapter that captures the final request config. */ function captureAdapter(captured: { config?: InternalAxiosRequestConfig }) { @@ -24,10 +25,22 @@ function captureAdapter(captured: { config?: InternalAxiosRequestConfig }) { } } +function deferred() { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + describe('createApiClient', () => { beforeEach(() => { vi.clearAllMocks() + resetKeycloakRefreshStateForTests() mock.token = 'mock-token' + mock.tokenParsed = { exp: Math.floor(Date.now() / 1000) + 10 } mock.updateToken.mockResolvedValue(true) mock.login.mockResolvedValue(undefined) }) @@ -42,6 +55,34 @@ describe('createApiClient', () => { expect(mock.updateToken).toHaveBeenCalledWith(30) }) + it('skips updateToken() when the token is not close to expiring', async () => { + mock.tokenParsed = { exp: Math.floor(Date.now() / 1000) + 120 } + const client = createApiClient('/api/test') + const captured: { config?: InternalAxiosRequestConfig } = {} + client.defaults.adapter = captureAdapter(captured) + + await client.get('/something') + + expect(mock.updateToken).not.toHaveBeenCalled() + }) + + it('deduplicates concurrent refreshes for near-expiry requests', async () => { + const refresh = deferred() + mock.updateToken.mockReturnValue(refresh.promise) + const client = createApiClient('/api/test') + client.defaults.adapter = captureAdapter({}) + + const firstRequest = client.get('/first') + const secondRequest = client.get('/second') + await Promise.resolve() + + expect(mock.updateToken).toHaveBeenCalledTimes(1) + + refresh.resolve(true) + + await Promise.all([firstRequest, secondRequest]) + }) + it('sets Authorization: Bearer header when token is present', async () => { const client = createApiClient('/api/test') const captured: { config?: InternalAxiosRequestConfig } = {} @@ -52,7 +93,7 @@ describe('createApiClient', () => { expect(captured.config?.headers?.Authorization).toBe('Bearer mock-token') }) - it('calls login() when updateToken rejects', async () => { + it('does not call login() when updateToken rejects', async () => { mock.updateToken.mockRejectedValue(new Error('session expired')) const client = createApiClient('/api/test') const captured: { config?: InternalAxiosRequestConfig } = {} @@ -60,7 +101,7 @@ describe('createApiClient', () => { await client.get('/something') - expect(mock.login).toHaveBeenCalled() + expect(mock.login).not.toHaveBeenCalled() }) it('does not set Authorization header when token is undefined', async () => { @@ -73,4 +114,20 @@ describe('createApiClient', () => { expect(captured.config?.headers?.Authorization).toBeUndefined() }) + + it('calls login() when the API responds with 401', async () => { + const client = createApiClient('/api/test') + client.defaults.adapter = async (config) => { + const error = new Error('Unauthorized') as AxiosError + error.config = config + error.response = { data: {}, status: 401, statusText: 'Unauthorized', headers: {}, config } + throw error + } + + await expect(client.get('/something')).rejects.toMatchObject({ + response: { status: 401 }, + }) + + expect(mock.login).toHaveBeenCalledTimes(1) + }) }) diff --git a/web-client/src/app/layout/AppShell.tsx b/web-client/src/app/layout/AppShell.tsx index cd4ba32..f9cd5f2 100644 --- a/web-client/src/app/layout/AppShell.tsx +++ b/web-client/src/app/layout/AppShell.tsx @@ -1,7 +1,16 @@ import { NavLink, Outlet } from 'react-router-dom' -import { LayoutGrid } from 'lucide-react' +import { ChevronsUpDown, HelpCircle, LayoutGrid, LogOut, Settings, User } from 'lucide-react' import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { ThemeToggle } from '@/app/theme/ThemeToggle' +import { useAuth } from '@/features/auth' +import { Avatar, AvatarFallback } from '@/components/ui/avatar' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu' import { Sidebar, SidebarContent, @@ -26,6 +35,9 @@ const NAV_ITEMS = [ ] export function AppShell() { + const { user, logout } = useAuth() + const userInitial = user.name.trim().charAt(0).toUpperCase() || 'U' + return ( @@ -58,8 +70,54 @@ export function AppShell() { -
- +
+ + + + + + + + Profile + + + + Settings + + + + Help + + + + + Log out + + + +
@@ -67,6 +125,9 @@ export function AppShell() {
+
+ +
diff --git a/web-client/src/app/theme/ThemeToggle.tsx b/web-client/src/app/theme/ThemeToggle.tsx index 57757b5..88281c6 100644 --- a/web-client/src/app/theme/ThemeToggle.tsx +++ b/web-client/src/app/theme/ThemeToggle.tsx @@ -1,4 +1,4 @@ -import { Moon, Sun } from 'lucide-react' +import { Monitor, Moon, Sun } from 'lucide-react' import { useTheme } from './useTheme' import { Button } from '@/components/ui/button' @@ -14,8 +14,9 @@ export function ThemeToggle() { title={`Toggle theme (current: ${theme})`} className="relative border border-border bg-background/70" > - - + + + ) } diff --git a/web-client/src/components/ui/alert.tsx b/web-client/src/components/ui/alert.tsx new file mode 100644 index 0000000..53e3465 --- /dev/null +++ b/web-client/src/components/ui/alert.tsx @@ -0,0 +1,75 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '@/lib/utils' + +const alertVariants = cva( + 'group/alert relative grid w-full gap-1 border bg-background px-4 py-3 text-left text-sm after:absolute after:-inset-y-px after:-left-px after:w-0.5 has-data-[slot=alert-action]:relative has-data-[slot=alert-action]:pr-18 has-[>svg]:grid-cols-[auto_1fr] has-[>svg]:gap-x-2.5 *:[svg]:row-span-2 *:[svg]:translate-y-0.5 *:[svg]:text-current *:[svg:not([class*=size-])]:size-4', + { + variants: { + variant: { + default: 'bg-card text-card-foreground after:bg-foreground', + destructive: + 'bg-card text-destructive after:bg-destructive *:data-[slot=alert-description]:text-destructive/90 *:[svg]:text-current', + }, + }, + defaultVariants: { + variant: 'default', + }, + }, +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
svg]/alert:col-start-2 [&_a]:underline [&_a]:underline-offset-3 [&_a]:hover:text-foreground', + className, + )} + {...props} + /> + ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function AlertAction({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +export { Alert, AlertAction, AlertDescription, AlertTitle } diff --git a/web-client/src/components/ui/avatar.tsx b/web-client/src/components/ui/avatar.tsx new file mode 100644 index 0000000..b367ee8 --- /dev/null +++ b/web-client/src/components/ui/avatar.tsx @@ -0,0 +1,107 @@ +import * as React from 'react' +import { Avatar as AvatarPrimitive } from 'radix-ui' + +import { cn } from '@/lib/utils' + +function Avatar({ + className, + size = 'default', + ...props +}: React.ComponentProps & { + size?: 'default' | 'sm' | 'lg' +}) { + return ( + + ) +} + +function AvatarImage({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarFallback({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function AvatarBadge({ className, ...props }: React.ComponentProps<'span'>) { + return ( + svg]:hidden', + 'group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2', + 'group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2', + className + )} + {...props} + /> + ) +} + +function AvatarGroup({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ) +} + +function AvatarGroupCount({ + className, + ...props +}: React.ComponentProps<'div'>) { + return ( +
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3', + className + )} + {...props} + /> + ) +} + +export { + Avatar, + AvatarImage, + AvatarFallback, + AvatarGroup, + AvatarGroupCount, + AvatarBadge, +} diff --git a/web-client/src/components/ui/button.tsx b/web-client/src/components/ui/button.tsx index 1d8bb90..90348e6 100644 --- a/web-client/src/components/ui/button.tsx +++ b/web-client/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { Slot } from "radix-ui" import { cn } from "@/lib/utils" const buttonVariants = cva( - "group/button inline-flex shrink-0 items-center justify-center rounded-none border border-transparent bg-clip-padding text-xs font-semibold tracking-widest whitespace-nowrap uppercase transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5", + "group/button inline-flex shrink-0 cursor-pointer items-center justify-center rounded-none border border-transparent bg-clip-padding text-xs font-semibold tracking-widest whitespace-nowrap uppercase transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/30 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-2 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-3.5", { variants: { variant: { diff --git a/web-client/src/components/ui/dropdown-menu.tsx b/web-client/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..934e6a3 --- /dev/null +++ b/web-client/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,271 @@ +import * as React from 'react' +import { DropdownMenu as DropdownMenuPrimitive } from 'radix-ui' +import { CheckIcon, ChevronRightIcon } from 'lucide-react' + +import { cn } from '@/lib/utils' + +function DropdownMenu({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuContent({ + className, + align = 'start', + sideOffset = 4, + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function DropdownMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuItem({ + className, + inset, + variant = 'default', + ...props +}: React.ComponentProps & { + inset?: boolean + variant?: 'default' | 'destructive' +}) { + return ( + + ) +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + + + + + + {children} + + ) +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + ) +} + +function DropdownMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function DropdownMenuShortcut({ + className, + ...props +}: React.ComponentProps<'span'>) { + return ( + + ) +} + +function DropdownMenuSub({ + ...props +}: React.ComponentProps) { + return +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean +}) { + return ( + + {children} + + + ) +} + +function DropdownMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +} diff --git a/web-client/src/features/auth/index.ts b/web-client/src/features/auth/index.ts new file mode 100644 index 0000000..aefa664 --- /dev/null +++ b/web-client/src/features/auth/index.ts @@ -0,0 +1 @@ +export { useAuth } from './useAuth' diff --git a/web-client/src/features/auth/useAuth.ts b/web-client/src/features/auth/useAuth.ts new file mode 100644 index 0000000..aa115c5 --- /dev/null +++ b/web-client/src/features/auth/useAuth.ts @@ -0,0 +1,20 @@ +import type { KeycloakTokenParsed } from 'keycloak-js' +import keycloak from '@/lib/keycloak' +import type { AuthUser } from '@/types' + +type AuthTokenSnapshot = KeycloakTokenParsed & { + email?: string + name?: string + preferred_username?: string +} + +export function useAuth(): { user: AuthUser; logout: () => void } { + // This is a render-time snapshot of the current token, not reactive auth state. + const parsed = keycloak.tokenParsed as AuthTokenSnapshot | undefined + const user: AuthUser = { + name: parsed?.name ?? parsed?.preferred_username ?? parsed?.email ?? 'Unknown', + email: parsed?.email ?? '', + } + const logout = () => keycloak.logout({ redirectUri: window.location.origin }) + return { user, logout } +} diff --git a/web-client/src/lib/auth-bootstrap.ts b/web-client/src/lib/auth-bootstrap.ts new file mode 100644 index 0000000..032606f --- /dev/null +++ b/web-client/src/lib/auth-bootstrap.ts @@ -0,0 +1,55 @@ +import { KEYCLOAK_URL } from '@/lib/keycloak' + +export type AuthError = 'network' | 'config' | 'timeout' | 'unknown' + +export const AUTH_INIT_TIMEOUT_MS = 15000 + +export function createAuthInitTimeoutError(timeoutMs = AUTH_INIT_TIMEOUT_MS): Error { + return new Error( + `Authentication timed out after ${timeoutMs}ms while contacting ${KEYCLOAK_URL}`, + ) +} + +export function classifyAuthError(error: unknown): AuthError { + if (!(error instanceof Error)) { + return 'unknown' + } + + const message = error.message.toLowerCase() + + if (message.includes('timed out') || message.includes('timeout')) { + return 'timeout' + } + + if ( + error.message === 'Failed to fetch' || + message.includes('network') || + message.includes('fetch') + ) { + return 'network' + } + + if ( + message.includes('realm') || + message.includes('client') || + message.includes('url') || + message.includes('config') + ) { + return 'config' + } + + return 'unknown' +} + +export async function withTimeout( + promise: Promise, + timeoutMs: number, + errorFactory: () => Error = () => createAuthInitTimeoutError(timeoutMs), +): Promise { + return await Promise.race([ + promise, + new Promise((_, reject) => { + window.setTimeout(() => reject(errorFactory()), timeoutMs) + }), + ]) +} diff --git a/web-client/src/lib/keycloak.ts b/web-client/src/lib/keycloak.ts index a52d698..a76ee44 100644 --- a/web-client/src/lib/keycloak.ts +++ b/web-client/src/lib/keycloak.ts @@ -1,20 +1,59 @@ import Keycloak from 'keycloak-js' -import axios, { type AxiosInstance } from 'axios' +import axios, { AxiosError, type AxiosInstance } from 'axios' + +export const KEYCLOAK_URL = import.meta.env.VITE_KEYCLOAK_URL || 'http://localhost:8081/auth' +export const TOKEN_REFRESH_MIN_VALIDITY_SECONDS = 30 const keycloak = new Keycloak({ - url: import.meta.env.VITE_KEYCLOAK_URL || 'http://localhost:8081/auth', + url: KEYCLOAK_URL, realm: 'devops', clientId: 'devops-client', }) +let inFlightRefresh: Promise | null = null + +export function resetKeycloakRefreshStateForTests(): void { + inFlightRefresh = null +} + +function shouldRefreshToken(minValidity: number): boolean { + if (!keycloak.token) { + return false + } + + const expiresAt = keycloak.tokenParsed?.exp + if (typeof expiresAt !== 'number') { + return true + } + + const nowInSeconds = Math.floor(Date.now() / 1000) + return expiresAt - nowInSeconds <= minValidity +} + +async function refreshTokenIfNeeded(minValidity: number): Promise { + if (!shouldRefreshToken(minValidity)) { + return + } + + // Concurrent requests can all observe the same near-expiry token, so they + // share one refresh instead of racing separate updateToken() calls. + if (!inFlightRefresh) { + inFlightRefresh = keycloak.updateToken(minValidity).finally(() => { + inFlightRefresh = null + }) + } + + await inFlightRefresh +} + export function createApiClient(baseURL: string): AxiosInstance { const client = axios.create({ baseURL }) client.interceptors.request.use(async (config) => { try { - await keycloak.updateToken(30) + await refreshTokenIfNeeded(TOKEN_REFRESH_MIN_VALIDITY_SECONDS) } catch { - await keycloak.login() + // Let the backend's 401 decide when we need a full re-auth redirect. } if (keycloak.token) { config.headers.Authorization = `Bearer ${keycloak.token}` @@ -22,6 +61,17 @@ export function createApiClient(baseURL: string): AxiosInstance { return config }) + client.interceptors.response.use( + (response) => response, + async (error: AxiosError) => { + if (error.response?.status === 401) { + await keycloak.login() + } + + return Promise.reject(error) + }, + ) + return client } diff --git a/web-client/src/main.tsx b/web-client/src/main.tsx index c786e9f..b78d8e9 100644 --- a/web-client/src/main.tsx +++ b/web-client/src/main.tsx @@ -2,10 +2,9 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { AxiosError } from 'axios' -import keycloak from '@/lib/keycloak' import { ThemeProvider } from '@/app/theme/ThemeProvider' +import AuthenticatedApp from './AuthenticatedApp.tsx' import '@/index.css' -import App from './App.tsx' const queryClient = new QueryClient({ defaultOptions: { @@ -27,19 +26,12 @@ const queryClient = new QueryClient({ }, }) -keycloak.init({ onLoad: 'login-required', pkceMethod: 'S256' }).then((authenticated) => { - if (!authenticated) { - keycloak.login() - return - } - - createRoot(document.getElementById('root')!).render( - - - - - - - , - ) -}) +createRoot(document.getElementById('root')!).render( + + + + + + + , +) diff --git a/web-client/src/setupTests.ts b/web-client/src/setupTests.ts new file mode 100644 index 0000000..07b1113 --- /dev/null +++ b/web-client/src/setupTests.ts @@ -0,0 +1,6 @@ +// Tell React's act() scheduler that it's running inside a test environment. +// Without this, any state update outside an act() boundary logs a warning. +// @testing-library/react sets this automatically; we set it manually since +// this project uses raw React DOM APIs in tests. +// @ts-expect-error — IS_REACT_ACT_ENVIRONMENT is not in the TS globals +globalThis.IS_REACT_ACT_ENVIRONMENT = true diff --git a/web-client/src/types.ts b/web-client/src/types.ts index ca5bb0b..1232ce0 100644 --- a/web-client/src/types.ts +++ b/web-client/src/types.ts @@ -29,3 +29,8 @@ export type Transaction = S['Transaction'] export type TransactionCreate = S['TransactionCreate'] export type TransactionPartialUpdate = S['TransactionPartialUpdate'] export type Balance = S['Balance'] + +export interface AuthUser { + name: string + email: string +} diff --git a/web-client/vite.config.ts b/web-client/vite.config.ts index 5501bb8..06fd91e 100644 --- a/web-client/vite.config.ts +++ b/web-client/vite.config.ts @@ -33,6 +33,7 @@ export default defineConfig({ environment: 'jsdom', globals: true, passWithNoTests: true, + setupFiles: ['src/setupTests.ts'], include: ['src/**/*.{test,spec}.{js,jsx,ts,tsx}'], coverage: { provider: 'v8',