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',