Project: vilbert/skills Commit: 90b4eecfdf542c30001b59b8e83be6e34359403d Message: Add internal-dashboard-frontend skill with Next.js 16, Mantine 9.4, and Better Auth New skill templ --- AGENTS.md --- @@ -17,6 +17,7 @@ OpenCode skill collection for reusable project templates and specialized develop | Directory | Description | |-----------|-------------| | `backend-dev/` | Go hexagonal architecture template with Keycloak JWT auth | +| `internal-dashboard-frontend/` | Next.js 16 + Mantine 9.4 + Better Auth dashboard template | | `orchestrator/` | End-to-end development orchestration | --- @@ -116,6 +117,72 @@ See [orchestrator/SKILL.md](orchestrator/SKILL.md) for full documentation. --- +# internal-dashboard-frontend Skill + +Internal dashboard frontend template using Next.js 16, Mantine 9.4, and Better Auth 1.6+ as a Keycloak OIDC client. + +## Tech Stack +- Next.js 16+ (App Router, RSC) +- Mantine 9.4+ (UI, theme, notifications, modals, forms) +- Better Auth 1.6+ with genericOAuth plugin → Keycloak +- axios (auth interceptor + TanStack Query integration) +- Zod (shared schemas between API and forms) +- TanStack Table v8, Recharts +- Vitest + Testing Library + Playwright +- bun package manager + +## Key Environment Variables +```env +NEXT_PUBLIC_API_BASE_URL=http://localhost:8080 +BETTER_AUTH_SECRET=<32-byte-random> +BETTER_AUTH_URL=http://localhost:3000 +DATABASE_URL=postgres://user:pass@localhost:5432/dashboard_auth?sslmode=disable +KEYCLOAK_ISSUER=https://auth.example.com/realms/production +KEYCLOAK_CLIENT_ID=dashboard-fe +KEYCLOAK_CLIENT_SECRET= +``` + +## Authentication Flow +1. User clicks "Continue with Keycloak" on `/login` +2. Better Auth redirects to Keycloak via OIDC (PKCE) +3. Keycloak issues code → Better Auth exchanges for tokens +4. Better Auth stores session in Postgres, sets session cookie +5. axios interceptor reads Keycloak access token via Better Auth server helper +6. Go backend validates `Authorization: Bearer ` via JWKS (see backend-dev) + +## Documentation + +Main entry: [internal-dashboard-frontend/SKILL.md](internal-dashboard-frontend/SKILL.md) + +Detailed references: +- [ARCHITECTURE.md](internal-dashboard-frontend/references/ARCHITECTURE.md) - App Router, providers tree, server/client boundaries, feature-sliced layout +- [AUTHENTICATION.md](internal-dashboard-frontend/references/AUTHENTICATION.md) - Better Auth + Keycloak OIDC, middleware, axios interceptor +- [UI-AND-THEMING.md](internal-dashboard-frontend/references/UI-AND-THEMING.md) - Mantine 9.4 setup, AppShell, theme, Notifications, ModalsProvider +- [DATA-FETCHING.md](internal-dashboard-frontend/references/DATA-FETCHING.md) - axios setup, TanStack Query, query keys factory +- [FORMS-AND-VALIDATION.md](internal-dashboard-frontend/references/FORMS-AND-VALIDATION.md) - Mantine form + Zod, shared schemas +- [TABLES-AND-CHARTS.md](internal-dashboard-frontend/references/TABLES-AND-CHARTS.md) - TanStack Table, Recharts +- [TESTING.md](internal-dashboard-frontend/references/TESTING.md) - Vitest + Testing Library + Playwright + MSW +- [CODING-STANDARDS.md](internal-dashboard-frontend/references/CODING-STANDARDS.md) - TypeScript strict, ESLint, Prettier, accessibility +- [DEPLOYMENT.md](internal-dashboard-frontend/references/DEPLOYMENT.md) - Docker, CI/CD, env management + +## Design Principles + +- **Server-First**: Default to RSC; client components only when needed +- **Route Group Boundaries**: `(public)` for login/logout, `(authed)` for everything else +- **Providers Tree at Root**: Single composition in `providers.tsx` +- **Feature-Sliced Layout**: Each feature owns its api/components/hooks/schemas/types +- **Strict TypeScript**: `strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes` +- **Env Safety**: Zod-validated at boot; `NEXT_PUBLIC_*` only for client +- **Accessibility Baseline**: WCAG AA, semantic HTML, keyboard-reachable + +## Integration with backend-dev + +- Forwards Keycloak access tokens to Go backend (no API changes required) +- Reuses backend-dev's OpenBao configuration for secrets management +- Mirrors the backend-dev `ApiError` shape for typed error handling + +--- + ## Skill Documentation Conventions - Use Go-style code blocks with line numbers for large sections --- README.md --- @@ -37,11 +37,62 @@ PERSISTENCE_DSN=postgres://user:pass@localhost:5432/dbname?sslmode=disable See [backend-dev/SKILL.md](backend-dev/SKILL.md) for full documentation. +### internal-dashboard-frontend + +Internal dashboard frontend template using Next.js 16, Mantine 9.4, and Better Auth 1.6+ as a Keycloak OIDC client. Pairs with `backend-dev` — forwards Keycloak access tokens to the Go API, which validates them via JWKS. + +**Features:** +- App Router with server-first components and route group boundaries +- Mantine 9.4 UI (AppShell, theme, Notifications, ModalsProvider) +- Better Auth + Keycloak OIDC with PKCE and Postgres-backed sessions +- axios layer with auth interceptor and `ApiError` normalization +- TanStack Query + TanStack Table + Recharts +- Mantine `useForm` + Zod with shared schemas between API and forms +- Vitest + Testing Library + Playwright + MSW +- Feature-sliced layout (`src/features//{api,components,hooks,schemas,types}`) + +**Quick start:** +```bash +bun create next-app dashboard-fe --typescript --app --src-dir +cd dashboard-fe +bun add @mantine/core@^9.4 @mantine/hooks@^9.4 @mantine/form@^9.4 \ + @mantine/notifications@^9.4 @mantine/modals@^9.4 @mantine/dates@^9.4 \ + better-auth@^1.6 axios zod @tanstack/react-query @tanstack/react-table recharts +cp .env.example .env.local +bun run dev +``` + +**Key env vars:** +```env +NEXT_PUBLIC_API_BASE_URL=http://localhost:8080 +BETTER_AUTH_SECRET=<32-byte-random> +BETTER_AUTH_URL=http://localhost:3000 +DATABASE_URL=postgres://user:pass@localhost:5432/dashboard_auth?sslmode=disable +KEYCLOAK_ISSUER=https://auth.example.com/realms/production +KEYCLOAK_CLIENT_ID=dashboard-fe +KEYCLOAK_CLIENT_SECRET= +``` + +See [internal-dashboard-frontend/SKILL.md](internal-dashboard-frontend/SKILL.md) for full documentation. + +### orchestrator + +End-to-end software development orchestration by delegating to specialized subagents (`frontend-dev`, `backend-dev`, `devops`, `qa`, `infra`, `reviewer`). Enforces the `understand → plan → validate → delegate → review → integrate → commit → deliver` pipeline and a max-3-round review escalation rule. + +**Features:** +- Delegation prompt template with Context / Tasks / Scope / Success Criteria / Constraints +- Per-task test requirement (≥80% coverage) +- Reviewer subagent gate before commit +- Rollback and escalation rules + +See [orchestrator/SKILL.md](orchestrator/SKILL.md) for full documentation. + ## Adding a New Skill 1. Create skill directory: `mkdir new-skill/` 2. Add `SKILL.md` with comprehensive documentation 3. Add entry to AGENTS.md +4. Add section to this README ## Documentation Conventions --- internal-dashboard-frontend/SKILL.md --- @@ -0,0 +1,260 @@ +# Internal Dashboard Frontend + +Production-ready Next.js dashboard template with Mantine UI, Better Auth (Keycloak OIDC), and a typed axios data layer. Designed for internal tooling that talks to a Go backend (see [backend-dev](../backend-dev/SKILL.md)). + +## Table of Contents + +1. [Quick Start](#quick-start) +2. [Documentation](#documentation) +3. [Project Structure](#project-structure) +4. [Core Principles](#core-principles) +5. [Commands](#commands) +6. [Environment Variables](#environment-variables) +7. [Authentication](#authentication) +8. [UI and Theming](#ui-and-theming) +9. [Data Fetching](#data-fetching) +10. [Next Steps](#next-steps) + +--- + +## Quick Start + +### 1. Prerequisites + +- Node.js 20+ +- bun 1.1+ +- PostgreSQL 15+ (Better Auth session/user storage) +- Keycloak realm reachable from dev machine +- A Go backend implementing the backend-dev contract (Keycloak JWT validation) + +### 2. Scaffold + +```bash +bun create next-app dashboard-fe \ + --typescript \ + --eslint \ + --app \ + --src-dir \ + --import-alias "@/*" +cd dashboard-fe +``` + +### 3. Install Dependencies + +```bash +bun add @mantine/core@^9.4 @mantine/hooks@^9.4 @mantine/form@^9.4 \ + @mantine/notifications@^9.4 @mantine/modals@^9.4 @mantine/dates@^9.4 \ + @tabler/icons-react \ + better-auth@^1.6 \ + axios \ + zod \ + @tanstack/react-query \ + @tanstack/react-table \ + recharts + +bun add -d postcss postcss-preset-mantine postcss-simple-vars \ + @types/node vitest @vitest/coverage-v8 \ + @testing-library/react @testing-library/jest-dom @testing-library/user-event \ + jsdom @playwright/test msw prettier eslint-config-prettier +``` + +### 4. Configure Environment + +Copy and edit `.env.local`: + +```env +# Public (browser-visible) +NEXT_PUBLIC_API_BASE_URL=http://localhost:8080 +NEXT_PUBLIC_APP_NAME=Dashboard + +# Better Auth +BETTER_AUTH_SECRET=replace-with-32-byte-random +BETTER_AUTH_URL=http://localhost:3000 + +# Better Auth session DB +DATABASE_URL=postgres://user:pass@localhost:5432/dashboard_auth?sslmode=disable + +# Keycloak OIDC (server-only — never NEXT_PUBLIC_) +KEYCLOAK_ISSUER=https://auth.example.com/realms/production +KEYCLOAK_CLIENT_ID=dashboard-fe +KEYCLOAK_CLIENT_SECRET=replace-with-realm-client-secret +``` + +### 5. Run + +```bash +bun run dev +``` + +Open . Sign in redirects to Keycloak; on success you land on `/dashboard`. + +--- + +## Documentation + +| File | Contents | +|------|----------| +| [references/ARCHITECTURE.md](references/ARCHITECTURE.md) | App Router structure, providers tree, server/client boundaries, feature-sliced layout | +| [references/AUTHENTICATION.md](references/AUTHENTICATION.md) | Better Auth + Keycloak OIDC, route groups, axios auth interceptor, middleware | +| [references/UI-AND-THEMING.md](references/UI-AND-THEMING.md) | Mantine 9.4 setup, AppShell, theme, Notifications, ModalsProvider | +| [references/DATA-FETCHING.md](references/DATA-FETCHING.md) | axios setup, interceptors, TanStack Query integration | +| [references/FORMS-AND-VALIDATION.md](references/FORMS-AND-VALIDATION.md) | Mantine form + Zod resolver, shared schemas with API | +| [references/TABLES-AND-CHARTS.md](references/TABLES-AND-CHARTS.md) | TanStack Table + Recharts patterns | +| [references/TESTING.md](references/TESTING.md) | Vitest + Testing Library + Playwright | +| [references/CODING-STANDARDS.md](references/CODING-STANDARDS.md) | TypeScript strict, ESLint, Prettier, accessibility | +| [references/DEPLOYMENT.md](references/DEPLOYMENT.md) | Docker, CI/CD, env management | + +--- + +## Project Structure + +``` +src/ +├── app/ +│ ├── (public)/ # Public routes — login page lives here +│ ├── (authed)/ # Protected routes — wrapped by middleware +│ │ └── dashboard/ +│ ├── api/ +│ │ └── auth/[...all]/ # Better Auth catch-all +│ ├── layout.tsx # Root providers (Mantine, Query, Notifications, Modals) +│ └── providers.tsx # Client-side providers tree +├── components/ # Shared, feature-agnostic UI +├── features/ # Feature-sliced modules +│ └── / +│ ├── api/ # axios calls for this feature +│ ├── components/ # Feature components +│ ├── hooks/ # Feature hooks (queries, mutations) +│ ├── schemas/ # Zod schemas +│ └── types/ # Feature-specific types +├── lib/ +│ ├── axios.ts # Configured axios instance + interceptors +│ ├── auth/ +│ │ ├── server.ts # Better Auth server instance + helpers +│ │ └── client.ts # Better Auth client instance +│ └── env.ts # Zod-validated env loader +├── server/ # Server-only utilities (RSC helpers) +└── middleware.ts # Route protection via Better Auth session +``` + +--- + +## Core Principles + +### Server-First + +- Default to React Server Components. +- Promote to client components only when you need state, effects, or browser APIs. +- Never put secrets in `NEXT_PUBLIC_*` — server-only env lives in `src/lib/env.ts`. + +### Route Group Boundaries + +- `(public)` for login, logout, error pages — no auth required. +- `(authed)` for everything else — guarded by `middleware.ts`. + +### Providers Tree at Root + +A single `providers.tsx` client component composes, in order: `MantineProvider` → `QueryClientProvider` → `ModalsProvider` → `Notifications`. Never instantiate providers inside feature components. + +### Feature-Sliced Layout + +Each feature owns its API calls, components, hooks, schemas, and types. Cross-feature imports go through `src/components` or a public barrel — never reach into another feature's internals. + +### Strict TypeScript + +- `strict: true`, `noUncheckedIndexedAccess: true`, `exactOptionalPropertyTypes: true`. +- No `any`. Use `unknown` and narrow. +- API response types are inferred from Zod schemas; do not duplicate. + +### Accessibility Baseline + +- Semantic HTML first. +- All interactive elements keyboard-reachable. +- Color contrast meets WCAG AA. +- Mantine `Label` + `aria-describedby` for every form field. + +### Env Safety + +- `src/lib/env.ts` validates all env vars with Zod at boot; missing or malformed values fail loud. +- Only `NEXT_PUBLIC_*` vars are imported by client code. + +--- + +## Commands + +```bash +bun run dev # Next dev server with HMR +bun run build # Production build (standalone output) +bun run start # Run standalone build +bun run lint # ESLint +bun run typecheck # tsc --noEmit +bun run format # Prettier write +bun run format:check # Prettier check +bun run test # Vitest unit + integration +bun run test:coverage # Vitest with coverage +bun run test:e2e # Playwright +bun run test:e2e:ui # Playwright UI mode +``` + +--- + +## Environment Variables + +| Variable | Required | Scope | Description | +|----------|----------|-------|-------------| +| `NEXT_PUBLIC_API_BASE_URL` | Yes | Public | Base URL of the Go backend | +| `NEXT_PUBLIC_APP_NAME` | No | Public | Display name in header (default: `Dashboard`) | +| `BETTER_AUTH_SECRET` | Yes | Server | 32+ byte random secret; used to sign Better Auth sessions | +| `BETTER_AUTH_URL` | Yes | Server | Public URL of this app (e.g. `https://dashboard.example.com`) | +| `DATABASE_URL` | Yes | Server | Postgres DSN for Better Auth session/user tables | +| `KEYCLOAK_ISSUER` | Yes | Server | Keycloak issuer URL (no trailing slash) | +| `KEYCLOAK_CLIENT_ID` | Yes | Server | Keycloak client ID registered for this app | +| `KEYCLOAK_CLIENT_SECRET` | Yes | Server | Keycloak client secret | +| `LOG_LEVEL` | No | Server | `debug` / `info` / `warn` / `error` (default `info`) | + +### Configuration Strategy + +| Environment | Source | +|-------------|--------| +| `local` | `.env.local` | +| `staging` | Container env injected at runtime (secrets from OpenBao) | +| `production` | Container env injected at runtime (secrets from OpenBao) | + +See [references/DEPLOYMENT.md](references/DEPLOYMENT.md) for env injection patterns. + +--- + +## Authentication + +Better Auth runs as a Keycloak OIDC client. On login, Better Auth exchanges the Keycloak code, stores tokens in its Postgres-backed session, and exposes helpers to read the Keycloak access token at request time. The axios layer attaches this token to every request to the Go backend, which validates it via JWKS (see [backend-dev AUTHENTICATION](../backend-dev/references/AUTHENTICATION.md)). + +``` +Browser → Next.js → Keycloak → Better Auth (stores session) → Go backend (validates JWT via JWKS) +``` + +See [references/AUTHENTICATION.md](references/AUTHENTICATION.md) for full setup, middleware, and interceptor details. + +--- + +## UI and Theming + +Mantine 9.4 is wired with SSR-safe color scheme handling, a single primary color, default radius, and `Notifications` + `ModalsProvider` registered at the root. The dashboard chrome uses `AppShell` with a header (user menu, color scheme toggle) and a collapsible navbar. + +See [references/UI-AND-THEMING.md](references/UI-AND-THEMING.md). + +--- + +## Data Fetching + +A single configured axios instance lives in `src/lib/axios.ts`. It attaches the Keycloak access token (read from the Better Auth session) on every request and normalizes Go-backend `ApiError` responses into typed exceptions. TanStack Query handles caching, retries, and optimistic updates. + +See [references/DATA-FETCHING.md](references/DATA-FETCHING.md). + +--- + +## Next Steps + +1. [references/ARCHITECTURE.md](references/ARCHITECTURE.md) — understand the layout and providers tree. +2. [references/AUTHENTICATION.md](references/AUTHENTICATION.md) — wire Better Auth + Keycloak. +3. [references/UI-AND-THEMING.md](references/UI-AND-THEMING.md) — bring up Mantine and AppShell. +4. [references/DATA-FETCHING.md](references/DATA-FETCHING.md) — configure axios and React Query. +5. [references/DEPLOYMENT.md](references/DEPLOYMENT.md) — Dockerize and ship. \ No newline at end of file --- internal-dashboard-frontend/references/ARCHITECTURE.md --- @@ -0,0 +1,257 @@ +# Architecture + +App Router-first Next.js 16 layout with feature-sliced modules, a single root providers tree, and clear server/client boundaries. + +## App Router File Conventions + +Every route segment is a folder. Special files: + +| File | Purpose | +|------|---------| +| `page.tsx` | Route UI (server component by default) | +| `layout.tsx` | Persistent wrapper (auth layout, dashboard layout) | +| `loading.tsx` | Suspense fallback | +| `error.tsx` | Error boundary | +| `not-found.tsx` | 404 | +| `route.ts` | API endpoint (Better Auth catch-all lives here) | + +## Route Groups + +Two top-level groups enforce auth boundaries: + +``` +src/app/ +├── (public)/ +│ ├── login/page.tsx +│ ├── logout/page.tsx +│ └── error/page.tsx +└── (authed)/ + ├── layout.tsx # AppShell + auth check + └── dashboard/ + ├── page.tsx + ├── users/ + │ ├── page.tsx + │ └── [id]/page.tsx + └── settings/page.tsx +``` + +Route groups (parenthesized folders) do not appear in URLs — `(authed)/dashboard/page.tsx` resolves to `/dashboard`. + +## Providers Tree + +A single client component composes all providers in `src/app/providers.tsx`. Order matters: + +```tsx +// src/app/providers.tsx:1 +"use client"; + +import { MantineProvider, createTheme } from "@mantine/core"; +import { ModalsProvider } from "@mantine/modals"; +import { Notifications } from "@mantine/notifications"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useState, type ReactNode } from "react"; + +const theme = createTheme({ + primaryColor: "indigo", + defaultRadius: "md", +}); + +export function Providers({ children }: { children: ReactNode }) { + const [queryClient] = useState( + () => + new QueryClient({ + defaultOptions: { + queries: { + staleTime: 30_000, + retry: 1, + refetchOnWindowFocus: false, + }, + }, + }), + ); + + return ( + + + + + {children} + + + + ); +} +``` + +Then mount in root layout: + +```tsx +// src/app/layout.tsx:1 +import "@mantine/core/styles.css"; +import "@mantine/notifications/styles.css"; +import "./globals.css"; + +import { ColorSchemeScript, mantineHtmlProps } from "@mantine/core"; +import { Providers } from "./providers"; + +export default function RootLayout({ children }: { children: ReactNode }) { + return ( + + + + + + {children} + + + ); +} +``` + +## Server vs Client Components + +Use this decision matrix: + +| Need | Component type | +|------|----------------| +| Read env or DB directly | Server | +| Fetch on the server with no user interaction | Server | +| Static markup, no interactivity | Server | +| `useState`, `useEffect`, event handlers | Client | +| Mantine components with state (most inputs) | Client | +| Browser-only APIs (`localStorage`, `window`) | Client | + +Push client boundaries as low as possible. A page can be a server component that renders a small client island for an interactive widget. + +Mark client components explicitly: + +```tsx +"use client"; + +import { useState } from "react"; +// ... +``` + +Never mark a component `"use client"` unless it actually needs it — it forces every import to be evaluated on the client. + +## Feature-Sliced Layout + +Each feature owns its full vertical slice. Cross-feature imports go through `src/components` only. + +``` +src/features/users/ +├── api/ +│ └── users.api.ts # axios calls +├── components/ +│ ├── UserTable.tsx +│ └── UserForm.tsx +├── hooks/ +│ ├── useUsers.ts +│ └── useUpdateUser.ts +├── schemas/ +│ └── user.schema.ts # Zod +└── types.ts # re-exports inferred types +``` + +Rules: + +- `features/users` may import from `src/components`, `src/lib`, `src/server` — never from `src/features/billing`. +- Public surface per feature is a single `index.ts` barrel — internal imports are private. +- Two features needing shared logic belong in `src/components` or a new shared `src/lib/` module. + +## Shared vs Feature Code + +| Lives in | When | +|----------|------| +| `src/components` | Used by 2+ features, no business logic | +| `src/lib` | Stateless utilities (axios, env loader, formatters) | +| `src/lib/auth` | Better Auth instances (server + client) | +| `src/server` | Server-only helpers (RSC data fetchers, permission checks) | +| `src/features/` | Anything scoped to one feature | + +## Env Loader + +`src/lib/env.ts` validates at boot with Zod. Importing the module throws if env is malformed. + +```ts +// src/lib/env.ts:1 +import { z } from "zod"; + +const serverSchema = z.object({ + BETTER_AUTH_SECRET: z.string().min(32), + BETTER_AUTH_URL: z.string().url(), + DATABASE_URL: z.string().url(), + KEYCLOAK_ISSUER: z.string().url(), + KEYCLOAK_CLIENT_ID: z.string().min(1), + KEYCLOAK_CLIENT_SECRET: z.string().min(1), + LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"), +}); + +const publicSchema = z.object({ + NEXT_PUBLIC_API_BASE_URL: z.string().url(), + NEXT_PUBLIC_APP_NAME: z.string().default("Dashboard"), +}); + +export const serverEnv = serverSchema.parse(process.env); +export const publicEnv = publicSchema.parse({ + NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL, + NEXT_PUBLIC_APP_NAME: process.env.NEXT_PUBLIC_APP_NAME, +}); +``` + +Server code imports from `src/lib/env` (this module transitively reads `process.env` and must never be imported from a `"use client"` file). Client code imports only `publicEnv`. + +## Middleware + +`src/middleware.ts` runs on every request matching `config.matcher`. It delegates auth checks to Better Auth. + +```ts +// src/middleware.ts:1 +import { NextResponse, type NextRequest } from "next/server"; +import { getSession } from "@/lib/auth/server"; + +const PUBLIC_PATHS = new Set(["/login", "/error"]); + +export async function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + if (PUBLIC_PATHS.has(pathname) || pathname.startsWith("/api/auth")) { + return NextResponse.next(); + } + + const session = await getSession(request); + + if (!session) { + const loginUrl = new URL("/login", request.url); + loginUrl.searchParams.set("redirectTo", pathname); + return NextResponse.redirect(loginUrl); + } + + return NextResponse.next(); +} + +export const config = { + matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], +}; +``` + +## Layouts + +`(authed)/layout.tsx` is the dashboard shell. It calls `getSession()` server-side to gate render and renders `AppShell`: + +```tsx +// src/app/(authed)/layout.tsx:1 +import { redirect } from "next/navigation"; +import { AppShell } from "@/components/AppShell"; +import { getSession } from "@/lib/auth/server"; + +export default async function AuthedLayout({ children }: { children: ReactNode }) { + const session = await getSession(); + + if (!session) redirect("/login"); + + return {children}; +} +``` + +See [references/UI-AND-THEMING.md](UI-AND-THEMING.md) for the `AppShell` component. \ No newline at end of file --- internal-dashboard-frontend/references/AUTHENTICATION.md --- @@ -0,0 +1,339 @@ +# Authentication + +Better Auth runs as a Keycloak OIDC client. Sessions are stored in Postgres. The Keycloak access token is read on every authenticated request and forwarded to the Go backend, which validates it via JWKS (see [backend-dev AUTHENTICATION](../../backend-dev/references/AUTHENTICATION.md)). + +## Flow + +``` +Browser + → GET /login (or auto-redirect from middleware) + → trigger signIn("keycloak") + → Better Auth generates state + PKCE + → 302 to Keycloak authorize endpoint + → user authenticates at Keycloak + → Keycloak redirects to /api/auth/callback/keycloak with code + → Better Auth exchanges code → access + refresh tokens + → Better Auth stores session in Postgres (cookie = session id) + → redirect to original destination + → client mounts → axios interceptor reads token via auth server helper + → Authorization: Bearer sent to Go backend +``` + +## Better Auth Server Instance + +```ts +// src/lib/auth/server.ts:1 +import { betterAuth } from "better-auth"; +import { genericOAuth } from "better-auth/plugins"; +import { Pool } from "pg"; +import { serverEnv } from "@/lib/env"; + +export const auth = betterAuth({ + secret: serverEnv.BETTER_AUTH_SECRET, + baseURL: serverEnv.BETTER_AUTH_URL, + + database: new Pool({ + connectionString: serverEnv.DATABASE_URL, + }), + + plugins: [ + genericOAuth({ + config: [ + { + providerId: "keycloak", + discoveryUrl: `${serverEnv.KEYCLOAK_ISSUER}/.well-known/openid-configuration`, + clientId: serverEnv.KEYCLOAK_CLIENT_ID, + clientSecret: serverEnv.KEYCLOAK_CLIENT_SECRET, + scopes: ["openid", "email", "profile"], + pkce: true, + }, + ], + }), + ], + + user: { + additionalFields: { + nip: { type: "string", required: false }, + roles: { type: "string[]", required: false, defaultValue: [] }, + }, + }, + + session: { + expiresIn: 60 * 60 * 8, // 8h + updateAge: 60 * 60, // refresh sliding window every hour + cookieCache: { enabled: true, maxAge: 5 * 60 }, + }, + + advanced: { + cookiePrefix: "dashboard", + }, +}); + +export type Session = Awaited>; + +export async function getSession(request?: Request) { + return auth.api.getSession({ + headers: request ?? (await import("next/headers")).headers(), + }); +} + +export async function getAccessToken(request?: Request) { + const session = await getSession(request); + if (!session) return null; + + const tokens = await auth.api.getAccessToken({ + body: { providerId: "keycloak" }, + headers: request ?? (await import("next/headers")).headers(), + }); + + return tokens?.accessToken ?? null; +} +``` + +## Better Auth Catch-All Route + +```ts +// src/app/api/auth/[...all]/route.ts:1 +import { auth } from "@/lib/auth/server"; +import { toNextJsHandler } from "better-auth/next-js"; + +export const { GET, POST } = toNextJsHandler(auth); +``` + +## Better Auth Client + +```ts +// src/lib/auth/client.ts:1 +import { createAuthClient } from "better-auth/react"; +import { genericOAuthClient } from "better-auth/client/plugins"; + +export const authClient = createAuthClient({ + baseURL: process.env.NEXT_PUBLIC_BETTER_AUTH_URL ?? "", + plugins: [genericOAuthClient()], +}); + +export const { signIn, signOut, useSession } = authClient; +``` + +## Login Page + +```tsx +// src/app/(public)/login/page.tsx:1 +"use client"; + +import { Button, Center, Stack, Title } from "@mantine/core"; +import { IconShieldLock } from "@tabler/icons-react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { signIn } from "@/lib/auth/client"; + +export default function LoginPage() { + const router = useRouter(); + const search = useSearchParams(); + const redirectTo = search.get("redirectTo") ?? "/dashboard"; + + async function handleLogin() { + await signIn.oauth2({ + providerId: "keycloak", + callbackURL: redirectTo, + }); + } + + return ( +
+ + + Sign in to Dashboard + + +
+ ); +} +``` + +## Logout + +```tsx +// src/app/(public)/logout/page.tsx:1 +"use client"; + +import { useEffect } from "react"; +import { Center, Loader } from "@mantine/core"; +import { signOut } from "@/lib/auth/client"; + +export default function LogoutPage() { + useEffect(() => { + signOut().then(() => { + window.location.href = "/login"; + }); + }, []); + + return ( +
+ +
+ ); +} +``` + +## Route Protection + +Two layers: + +1. **Middleware** (`src/middleware.ts`) — fast cookie check, redirects unauthenticated requests to `/login`. See [ARCHITECTURE.md](ARCHITECTURE.md#middleware). +2. **Layout guard** — `(authed)/layout.tsx` calls `getSession()` server-side. Defense in depth: if middleware is bypassed (edge runtime quirks), the layout still redirects. + +## Axios Auth Interceptor + +Reads the Keycloak access token via Better Auth's server helper. The interceptor runs only on the client (axios is only used client-side in this template); for RSC fetches, call `getAccessToken()` directly. + +```ts +// src/lib/axios.ts:1 +import axios, { AxiosError, type InternalAxiosRequestConfig } from "axios"; +import { publicEnv } from "@/lib/env"; + +export const api = axios.create({ + baseURL: publicEnv.NEXT_PUBLIC_API_BASE_URL, + timeout: 30_000, +}); + +export class ApiError extends Error { + constructor( + message: string, + public readonly status: number, + public readonly code?: string, + public readonly details?: unknown, + ) { + super(message); + this.name = "ApiError"; + } +} + +let memToken: { value: string | null; expiresAt: number } | null = null; + +async function fetchAccessToken(): Promise { + if (memToken && memToken.expiresAt > Date.now()) { + return memToken.value; + } + + const res = await fetch("/api/auth/keycloak/access-token", { + credentials: "include", + }); + + if (!res.ok) return null; + + const data = (await res.json()) as { accessToken: string; expiresAt: number }; + memToken = { value: data.accessToken, expiresAt: data.expiresAt - 60_000 }; + return memToken.value; +} + +api.interceptors.request.use(async (config: InternalAxiosRequestConfig) => { + const token = await fetchAccessToken(); + if (token) { + config.headers.set("Authorization", `Bearer ${token}`); + } + return config; +}); + +api.interceptors.response.use( + (response) => response, + (error: AxiosError<{ code?: string; message?: string; details?: unknown }>) => { + if (error.response?.status === 401) { + memToken = null; + const loginUrl = new URL("/login", window.location.origin); + loginUrl.searchParams.set("redirectTo", window.location.pathname); + window.location.href = loginUrl.toString(); + return Promise.reject(new ApiError("Unauthenticated", 401)); + } + + const data = error.response?.data; + throw new ApiError( + data?.message ?? error.message, + error.response?.status ?? 0, + data?.code, + data?.details, + ); + }, +); +``` + +### Token Endpoint + +Better Auth does not expose a Keycloak access-token endpoint out of the box; add a thin route handler that uses `auth.api.getAccessToken`: + +```ts +// src/app/api/auth/keycloak/access-token/route.ts:1 +import { NextResponse, type NextRequest } from "next/server"; +import { auth } from "@/lib/auth/server"; + +export async function GET(request: NextRequest) { + const tokens = await auth.api.getAccessToken({ + body: { providerId: "keycloak" }, + headers: request.headers, + }); + + if (!tokens?.accessToken) { + return NextResponse.json({ error: "no_token" }, { status: 401 }); + } + + return NextResponse.json({ + accessToken: tokens.accessToken, + expiresAt: tokens.accessTokenExpiresAt?.getTime() ?? Date.now() + 60_000, + }); +} +``` + +## Role-Based UI Gating + +The Go backend reads roles from `resource_access.{client_id}.roles` (per backend-dev). Mirror them on the session so UI can gate buttons: + +```ts +// src/server/permissions.ts:1 +import { getSession } from "@/lib/auth/server"; + +export async function requireRole(role: string) { + const session = await getSession(); + if (!session) throw new Error("unauthenticated"); + + const roles = (session.user as { roles?: string[] }).roles ?? []; + if (!roles.includes(role)) throw new Error("forbidden"); +} + +export function hasRole(session: Session, role: string): boolean { + const roles = (session.user as { roles?: string[] }).roles ?? []; + return roles.includes(role); +} +``` + +Use in server components: + +```tsx +// src/app/(authed)/dashboard/users/page.tsx:1 +import { hasRole } from "@/server/permissions"; +import { redirect } from "next/navigation"; +import { getSession } from "@/lib/auth/server"; + +export default async function UsersPage() { + const session = await getSession(); + if (!session || !hasRole(session, "dashboard.admin")) { + redirect("/dashboard"); + } + return ; +} +``` + +## Database Schema for Better Auth + +Better Auth manages its own tables. Generate them once: + +```bash +bun x @better-auth/cli@latest generate --output ./db/migrations +bun x @better-auth/cli@latest migrate +``` + +The CLI produces a SQL migration you apply to the `DATABASE_URL` database. Do not manually edit these tables. + +## Testing Auth + +Use MSW to mock `/api/auth/*` and `/api/auth/keycloak/access-token` in unit tests. Playwright E2E tests run against a real Keycloak realm — use a dedicated test realm, never production. See [references/TESTING.md](TESTING.md). \ No newline at end of file --- internal-dashboard-frontend/references/CODING-STANDARDS.md --- @@ -0,0 +1,210 @@ +# Coding Standards + +TypeScript strict, ESLint + Prettier, accessibility baseline, and naming conventions for the dashboard template. + +## TypeScript + +`tsconfig.json` baseline: + +```jsonc +// tsconfig.json:1 +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["dom", "dom.iterable", "ES2022"], + "module": "esnext", + "moduleResolution": "bundler", + "jsx": "preserve", + "strict": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "noImplicitOverride": true, + "noFallthroughCasesInSwitch": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["next-env.d.ts", "src/**/*", ".next/types/**/*.ts"], + "exclude": ["node_modules", ".next", "dist", "coverage", "e2e"] +} +``` + +Rules: + +- No `any`. Use `unknown` and narrow with Zod, type guards, or `instanceof`. +- No non-null assertions (`!`) outside tests. Validate at the boundary. +- Prefer `type` imports (`import type { Foo } from "..."`) for type-only symbols. +- Use `satisfies` for object literals that should match a contract without losing inference. + +```ts +const config = { + retries: 3, + endpoint: "/api/users", +} satisfies Record; +``` + +## ESLint + +```js +// eslint.config.mjs:1 +import next from "eslint-config-next"; +import prettier from "eslint-config-prettier"; + +export default [ + ...next, + prettier, + { + rules: { + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/consistent-type-imports": [ + "error", + { prefer: "type-imports" }, + ], + "react/jsx-no-leaked-render": "error", + "no-console": ["warn", { allow: ["warn", "error"] }], + }, + }, + { + ignores: [".next/**", "node_modules/**", "coverage/**", "dist/**"], + }, +]; +``` + +`bun run lint` runs ESLint. CI fails on any error. + +## Prettier + +```jsonc +// .prettierrc.json:1 +{ + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "arrowParens": "always" +} +``` + +```sh +bun run format # write +bun run format:check # check (CI) +``` + +Add a pre-commit hook (via `lefthook` or `husky`) running `format:check` and `lint` on staged files. + +## Folder and File Naming + +| Kind | Convention | Example | +|------|-----------|---------| +| Folder | kebab-case | `src/features/user-management/` | +| Component file | PascalCase.tsx | `UsersTable.tsx` | +| Hook file | camelCase.ts | `useUsers.ts` | +| Schema file | camelCase.schema.ts | `user.schema.ts` | +| API module | camelCase.api.ts | `users.api.ts` | +| Test file | `.test.tsx` co-located | `UsersTable.test.tsx` | +| Server Action | camelCase.action.ts | `createUser.action.ts` | + +## Component Conventions + +- One component per file, exported as named export. Default exports only for route files (`page.tsx`, `layout.tsx`). +- Props type defined inline if used once; extract to `types.ts` if shared. +- Destructure props in the signature when more than three; otherwise pass `props` through. +- Never use `React.FC`. Type props directly: + +```tsx +type ButtonProps = { + label: string; + onClick: () => void; + loading?: boolean; +}; + +export function SubmitButton({ label, onClick, loading = false }: ButtonProps) { + return ; +} +``` + +## Hook Conventions + +- Always start with `use`. +- Return objects for hooks with multiple values, tuples for 1–2 related values. +- Never call hooks conditionally; never call hooks inside loops. +- Side effects go in `useEffect` with explicit dependencies. Lint will catch missing deps. + +## API Conventions + +- API modules return parsed, typed data — never raw axios responses. +- Schemas live in `schemas/`, parsed at the API boundary. +- Errors throw `ApiError`. Never swallow. +- No try/catch unless you're transforming the error or adding context. + +## Logging + +- Server: `console.error` for caught errors with full context. Use the team's logger if available. +- Client: `console.warn` and `console.error` only. No `console.log` in committed code. +- For user-facing errors, use `notifications.show(...)` from Mantine. + +## Accessibility Checklist + +Every interactive component must satisfy: + +- [ ] Semantic HTML element (` + + + + ); +} +``` + +## Field Components + +For consistency, wrap `TextInput`, `Select`, etc. in feature-specific components that always include `label` and `description`: + +```tsx +// src/components/forms/TextField.tsx:1 +"use client"; + +import { TextInput, type TextInputProps } from "@mantine/core"; +import { useFormContext } from "./FormContext"; + +export function TextField({ name, ...rest }: TextInputProps & { name: string }) { + const form = useFormContext(); + return ; +} +``` + +Use a form context when nesting: + +```tsx +// src/components/forms/FormContext.ts:1 +"use client"; + +import { createFormContext } from "@mantine/form"; +import type { UserFormValues } from "@/features/users/schemas/user.schema"; + +export const [FormProvider, useFormContext, useForm] = + createFormContext(); +``` + +## Async Validation + +For fields that need server-side checks (e.g. NIP uniqueness), wrap the Zod schema: + +```ts +// src/features/users/schemas/user.schema.ts (excerpt):14 +export const userFormSchema = z.object({ + nip: nipSchema.refine( + async (nip) => { + if (nip.length < 8) return true; // skip while too short + try { + await api.head(`/api/users/check-nip/${nip}`); + return true; + } catch (err) { + if (err instanceof ApiError && err.status === 404) return true; + return false; + } + }, + { message: "NIP already exists" }, + ), + // ... +}); +``` + +`useForm` with `mode: "controlled"` and Zod async resolvers handles this automatically — validation runs on blur and submit. + +## Select, MultiSelect, Combobox + +```tsx +import { Select, MultiSelect } from "@mantine/core"; + +