Shadboard starter-kit (Next.js 15 + React 19 + shadcn/ui + Tailwind 4) Sprint 4.a — Admin Dashboard: - Auth: NextAuth.js v5, login page, middleware, token rotation - Dashboard: KPI cards, Recharts stock chart, quick actions - Members: TanStack Table (search/sort/paginate), add/edit forms - Distributions: multi-step form, real-time quota check, history - Stock: batch management, recall dialog, bar chart - Reports: monthly/member-list/recall, PDF/CSV download, preview Sprint 4.b — Member Portal: - Separate route group with top-nav layout (mobile-first) - Quota dashboard with radial SVG progress indicators - Distribution history with month filter - Profile/settings with password change Cross-cutting: - i18n: German (default) + English via next-intl - Dark + light mode (next-themes, user-togglable) - Playwright E2E tests (6/6 green) - Docker multi-stage build (node:22-alpine) - API proxy via Next.js rewrites Tech: Next.js 15.2.8, React 19, Tailwind 4, NextAuth v5, TanStack Table, Recharts, Zod, React Hook Form, Playwright
62 KiB
CannaManage — Sprint 4 Implementation Plan
Date: 2026-06-12
Author: Patrick Plate / Lumen (Planner)
Status: Draft v3
Base Branch: main
Sprint Branch: sprint/4-frontend
Sprint Goal: Frontend MVP — Admin Dashboard + Member Portal using Shadboard starter-kit (Next.js 15 + shadcn/ui)
Sprint Structure: Sprint 4 is split into two sub-sprints:
- Sprint 4.a — Phases 1–7: Admin Dashboard MVP (~7.5 days)
- Sprint 4.b — Phases 8–9: Member Portal (quota view, distribution history, profile/settings) (~2.5 days)
Both are delivered within Sprint 4, organized as sequential sub-sprints (~10 days total).
0. Decisions (Confirmed by Patrick)
| # | Decision | Detail |
|---|---|---|
| D1 | Frontend framework | Shadboard starter-kit (MIT license). Next.js 15 + React 19 + Tailwind CSS 4 + shadcn/ui. Dark theme with green cannabis branding. |
| D2 | Auth strategy | NextAuth.js CredentialsProvider → hits our /api/v1/auth/login endpoint. Tokens stored server-side in NextAuth session. Client never sees raw JWT. |
| D3 | API proxy | Next.js rewrites in next.config.ts — /api/backend/** → http://localhost:8080/api/v1/**. No CORS needed in dev. Production: reverse proxy (nginx/caddy). |
| D4 | Brand theme | Dark mode default. Background #0D1117, card #161B22, accent #2ECC71 / #27AE60. Matches VS Code dark theme. |
| D5 | Package manager | pnpm (Shadboard default). Lockfile committed. |
| D6 | Directory | Separate pnpm project in cannamanage-frontend/ at monorepo root, sibling to cannamanage-api/. Not a workspace root — standalone project with its own package.json and pnpm-lock.yaml. |
| D7 | Deployment | Dev: pnpm dev on port 3000. Docker: multi-stage build (node:22-alpine → standalone output). Docker Compose service added. |
| D8 | i18n | From Day 1 — German (default) + English. Uses next-intl (already bundled in Shadboard). All UI strings use translation keys via useTranslations() hook. Locale files: messages/de.json, messages/en.json. |
| D9 | Node.js version | Node 22 LTS (not Node 20). All Dockerfiles and CI use node:22-alpine. |
1. Sprint 3 Recap (Context)
| Delivered | Status |
|---|---|
| Staff permission model (8 granular perms, JSONB) | ✅ |
| Token revocation (Caffeine cache + DB blacklist) | ✅ |
| Club settings controller (email whitelist, prevention officer limit) | ✅ |
| Staff management + invite flow (Spring Mail, 72h token) | ✅ |
| Report controller (PDF/CSV/JSON — monthly, member list, recall) | ✅ |
| Member portal (dual SecurityFilterChain, session auth, JSON API) | ✅ |
| Prevention officer capability (configurable limit, under-21 gate) | ✅ |
| Integration tests (Testcontainers PostgreSQL 16, 30+ tests) | ✅ |
Deferred from Sprint 3: React frontend SPA, Stripe payments, grow calendar, schema-per-tenant, DSGVO consent UI.
2. Sprint 4 Scope
✅ IN Scope — Sprint 4.a (Admin Dashboard MVP)
| # | Feature | Priority | Effort |
|---|---|---|---|
| 1 | Project setup + i18n — Shadboard starter-kit scaffolding, Next.js config, pnpm, Docker, next-intl locale setup (de/en) |
P0 | 0.5 days |
| 2 | Auth pages — Login, NextAuth.js integration, token rotation, role-based redirects | P0 | 1 day |
| 3 | Admin dashboard — Overview page with club stats, sidebar nav, quick actions | P0 | 1 day |
| 4 | Member management — List (TanStack Table), detail/edit form, add member | P1 | 1.5 days |
| 5 | Distribution recording — Form with quota check, member search, batch select, history | P1 | 1.5 days |
| 6 | Stock/batch management — Batch list, add batch form, stock chart (Recharts) | P1 | 1 day |
| 7 | Reports — Report triggers, PDF/CSV download, inline preview | P2 | 1 day |
Sprint 4.a effort: ~7.5 days
✅ IN Scope — Sprint 4.b (Member Portal)
| # | Feature | Priority | Effort |
|---|---|---|---|
| 8 | Member portal layout & auth — Session-based auth for members, portal layout shell, locale support | P1 | 1 day |
| 9 | Member portal pages — Dashboard/quota view, distribution history, profile/settings | P1 | 1.5 days |
Sprint 4.b effort: ~2.5 days
Total estimated effort: ~10.5 days (single worker, sequential: 7.5 + 3)
❌ OUT of Scope (Sprint 5+)
- Staff management UI (invite, permissions editor)
- Stripe payment integration
- Grow calendar / cultivation tracking
- DSGVO consent management UI
- Push notifications (WebSocket)
- PWA / offline mode
3. Architecture Decisions
3.1 Shadboard Starter-Kit Integration
Source: https://github.com/Qualiora/shadboard (MIT license)
We use the starter-kit/ directory (lean base without demo pages). Key directories:
cannamanage-frontend/
├── app/ # Next.js App Router
│ ├── [locale]/ # Locale segment (de/en)
│ │ ├── (auth)/ # Auth layout group (login page)
│ │ ├── (dashboard)/ # Dashboard layout group (authenticated)
│ │ │ ├── layout.tsx # Sidebar + header + breadcrumbs
│ │ │ ├── page.tsx # Overview dashboard
│ │ │ ├── members/ # Member management
│ │ │ ├── distributions/ # Distribution recording
│ │ │ ├── stock/ # Batch/stock management
│ │ │ └── reports/ # Report downloads
│ │ ├── (portal)/ # Member portal layout group (Sprint 4.b)
│ │ │ ├── layout.tsx # Portal layout shell
│ │ │ ├── page.tsx # Portal dashboard / quota view
│ │ │ ├── history/ # Distribution history
│ │ │ └── settings/ # Profile / settings
│ │ └── layout.tsx # Root locale layout (providers, theme)
│ ├── api/ # NextAuth route handlers
│ └── layout.tsx # Root layout (html, body)
├── messages/
│ ├── de.json # German translations (default locale)
│ └── en.json # English translations
├── components/
│ ├── ui/ # shadcn/ui components (Button, Card, Table, etc.)
│ ├── layout/ # Sidebar, Header, Breadcrumbs (from Shadboard)
│ └── cannamanage/ # Our custom components
├── lib/
│ ├── api-client.ts # Typed fetch wrapper for backend API
│ ├── auth.ts # NextAuth config
│ └── utils.ts # Shadboard utility (cn() helper)
├── hooks/ # Custom React hooks (useMembers, useDistributions, etc.)
├── types/ # TypeScript types mirroring backend DTOs
├── public/ # Static assets (logo, favicon)
├── i18n/
│ ├── request.ts # next-intl request config (getRequestConfig)
│ └── routing.ts # Locale routing config (defaultLocale, locales)
├── next.config.ts # API rewrites, standalone output, next-intl plugin
├── tailwind.config.ts # Brand colors, dark theme
├── .env.local # Local dev environment vars
└── pnpm-lock.yaml
3.2 NextAuth.js Configuration (Decision D2)
// lib/auth.ts
import NextAuth from "next-auth"
import Credentials from "next-auth/providers/credentials"
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const res = await fetch(`${process.env.BACKEND_URL}/api/v1/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: credentials.email,
password: credentials.password,
}),
})
if (!res.ok) return null
const data = await res.json()
// data: { accessToken, refreshToken, expiresIn, role }
return {
id: data.accessToken, // we'll decode sub from JWT
accessToken: data.accessToken,
refreshToken: data.refreshToken,
expiresIn: data.expiresIn,
role: data.role,
}
},
}),
],
callbacks: {
async jwt({ token, user }) {
if (user) {
token.accessToken = user.accessToken
token.refreshToken = user.refreshToken
token.role = user.role
token.expiresAt = Date.now() + user.expiresIn * 1000
}
// Auto-refresh if expired
if (Date.now() > (token.expiresAt as number)) {
return await refreshAccessToken(token)
}
return token
},
async session({ session, token }) {
session.accessToken = token.accessToken as string
session.user.role = token.role as string
return session
},
},
pages: {
signIn: "/login",
},
})
Token refresh logic:
async function refreshAccessToken(token: JWT) {
const res = await fetch(`${process.env.BACKEND_URL}/api/v1/auth/refresh`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ refreshToken: token.refreshToken }),
})
if (!res.ok) {
return { ...token, error: "RefreshAccessTokenError" }
}
const data = await res.json()
return {
...token,
accessToken: data.accessToken,
refreshToken: data.refreshToken,
expiresAt: Date.now() + data.expiresIn * 1000,
}
}
3.3 API Client Pattern
// lib/api-client.ts
import { auth } from "@/lib/auth"
const BACKEND_BASE = process.env.BACKEND_URL || "http://localhost:8080"
export async function apiClient<T>(
path: string,
options: RequestInit = {}
): Promise<T> {
const session = await auth()
if (!session?.accessToken) throw new Error("Unauthorized")
const res = await fetch(`${BACKEND_BASE}/api/v1${path}`, {
...options,
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${session.accessToken}`,
...options.headers,
},
})
if (!res.ok) {
const error = await res.json().catch(() => ({ message: res.statusText }))
throw new ApiError(res.status, error.message)
}
return res.json()
}
3.4 Brand Theme (Decision D4)
// tailwind.config.ts (extending Shadboard's config)
export default {
darkMode: "class",
theme: {
extend: {
colors: {
background: "#0D1117",
foreground: "#E6EDF3",
card: { DEFAULT: "#161B22", foreground: "#E6EDF3" },
primary: { DEFAULT: "#2ECC71", foreground: "#0D1117" },
secondary: { DEFAULT: "#27AE60", foreground: "#FFFFFF" },
muted: { DEFAULT: "#21262D", foreground: "#8B949E" },
accent: { DEFAULT: "#2ECC71", foreground: "#0D1117" },
border: "#30363D",
input: "#21262D",
ring: "#2ECC71",
},
},
},
}
3.5 Next.js API Proxy + Security Headers (Decisions D3 + Security)
// next.config.ts
import createNextIntlPlugin from "next-intl/plugin"
const withNextIntl = createNextIntlPlugin()
const securityHeaders = [
{ key: "X-DNS-Prefetch-Control", value: "on" },
{ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" },
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "Referrer-Policy", value: "origin-when-cross-origin" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
{
key: "Content-Security-Policy",
value: [
"default-src 'self'",
"script-src 'self' 'unsafe-inline' 'unsafe-eval'", // Next.js requires unsafe-inline/eval in dev
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: blob:",
"font-src 'self'",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join("; "),
},
]
const nextConfig = {
output: "standalone",
async headers() {
return [{ source: "/(.*)", headers: securityHeaders }]
},
async rewrites() {
return [
{
source: "/api/backend/:path*",
destination: `${process.env.BACKEND_URL || "http://localhost:8080"}/api/v1/:path*`,
},
]
},
}
export default withNextIntl(nextConfig)
3.6 TypeScript Types (mirroring backend DTOs)
// types/api.ts
export interface LoginResponse {
accessToken: string
refreshToken: string
expiresIn: number
role: "ADMIN" | "STAFF" | "MEMBER" | "PREVENTION_OFFICER"
}
export interface ClubStats {
totalMembers: number
activeMembers: number
totalStaff: number
activeStaff: number
totalDistributionsThisMonth: number
totalGramsDistributedThisMonth: number
activeBatches: number
preventionOfficerCount: number
}
export interface Member {
id: string
firstName: string
lastName: string
email: string
dateOfBirth: string // ISO date
membershipDate: string
membershipNumber: string
status: "ACTIVE" | "INACTIVE" | "SUSPENDED" | "PENDING"
under21: boolean
preventionOfficer: boolean
}
export interface QuotaStatus {
totalAllowed: number
totalUsed: number
remaining: number
under21: boolean
year: number
month: number
}
export interface Batch {
id: string
strainId: string
quantityGrams: number
harvestDate: string
batchCode: string
status: "AVAILABLE" | "RECALLED" | "DEPLETED"
contaminationFlag: boolean
}
export interface CreateDistributionRequest {
memberId: string
batchId: string
quantityGrams: number
notes?: string
}
export interface Distribution {
id: string
memberId: string
memberName: string
batchId: string
batchCode: string
quantityGrams: number
distributedAt: string
distributedBy: string
notes?: string
}
4. Backend Changes Required
4.1 CORS Configuration
Add to SecurityConfig.java for development (production uses reverse proxy):
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(
"http://localhost:3000" // Next.js dev server
));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
Enable in both SecurityFilterChain beans:
http.cors(cors -> cors.configurationSource(corsConfigurationSource()))
4.2 Docker Compose Update
# Addition to docker-compose.yml
frontend:
build:
context: ./cannamanage-frontend
dockerfile: Dockerfile
container_name: cannamanage-frontend
ports:
- "3000:3000"
environment:
- BACKEND_URL=http://api:8080
- NEXTAUTH_URL=http://localhost:3000
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET}
depends_on:
- api
api:
build:
context: .
dockerfile: cannamanage-api/Dockerfile
container_name: cannamanage-api
ports:
- "8080:8080"
environment:
- SPRING_DATASOURCE_URL=jdbc:postgresql://db:5432/cannamanage
- SPRING_DATASOURCE_USERNAME=cannamanage
- SPRING_DATASOURCE_PASSWORD=${DB_PASSWORD:-dev_password}
- SPRING_PROFILES_ACTIVE=local
depends_on:
db:
condition: service_healthy
4.3 Environment Variables (.env.local)
# cannamanage-frontend/.env.local
BACKEND_URL=http://localhost:8080
NEXTAUTH_URL=http://localhost:3000
# ⚠️ PRODUCTION: Must be a cryptographically random 32+ char string
# Generate with: openssl rand -base64 32
NEXTAUTH_SECRET=<generate-with-openssl-rand-base64-32>
⚠️ Security Note: Never commit real secrets to version control. The
.env.localfile is gitignored. For production, injectNEXTAUTH_SECRETvia your CI/CD pipeline's secrets manager (e.g., GitHub Actions secrets, AWS Secrets Manager, HashiCorp Vault).
5. Implementation Phases
Phase 1: Project Setup + i18n (Sprint 4.a)
Goal: Scaffold Shadboard starter-kit, configure Next.js, establish brand theme, set up i18n with next-intl (German default + English).
Files to create/modify:
| File | Action | Description |
|---|---|---|
cannamanage-frontend/ |
Create | Entire directory from Shadboard starter-kit |
cannamanage-frontend/next.config.ts |
Modify | Add API rewrites, standalone output, createNextIntlPlugin |
cannamanage-frontend/tailwind.config.ts |
Modify | CannaManage brand colors (dark + green) |
cannamanage-frontend/.env.local |
Create | Local env vars (BACKEND_URL, NEXTAUTH) |
cannamanage-frontend/Dockerfile |
Create | Multi-stage build for production (node:22-alpine) |
cannamanage-frontend/.gitignore |
Modify | Ensure node_modules, .next, .env.local excluded |
cannamanage-frontend/eslint.config.mjs |
Create | ESLint flat config with Next.js + TypeScript rules |
cannamanage-frontend/.prettierrc |
Create | Prettier config (semi: false, singleQuote: false, tabWidth: 2) |
cannamanage-frontend/messages/de.json |
Create | German translation strings (default locale) |
cannamanage-frontend/messages/en.json |
Create | English translation strings |
cannamanage-frontend/i18n/request.ts |
Create | next-intl request configuration |
cannamanage-frontend/i18n/routing.ts |
Create | Locale routing config (locales, defaultLocale) |
cannamanage-frontend/middleware.ts |
Create | Locale detection + redirect middleware |
docker-compose.yml |
Modify | Add frontend + api services |
LICENSES |
Create | Shadboard MIT license attribution |
Implementation details:
-
Clone Shadboard starter-kit:
cd cannamanage-frontend npx degit Qualiora/shadboard/starter-kit . pnpm install -
Configure Tailwind with CannaManage brand palette (section 3.4).
-
Set up
next-intlfor i18n (Shadboard already includes the dependency):// i18n/routing.ts import { defineRouting } from "next-intl/routing" export const routing = defineRouting({ locales: ["de", "en"], defaultLocale: "de", })// i18n/request.ts import { getRequestConfig } from "next-intl/server" import { routing } from "./routing" export default getRequestConfig(async ({ requestLocale }) => { let locale = await requestLocale if (!locale || !routing.locales.includes(locale as any)) { locale = routing.defaultLocale } return { locale, messages: (await import(`../messages/${locale}.json`)).default, } })// next.config.ts (updated with next-intl plugin) import createNextIntlPlugin from "next-intl/plugin" const withNextIntl = createNextIntlPlugin() const nextConfig = { output: "standalone", async rewrites() { return [ { source: "/api/backend/:path*", destination: `${process.env.BACKEND_URL || "http://localhost:8080"}/api/v1/:path*`, }, ] }, } export default withNextIntl(nextConfig) -
Create initial locale files:
// messages/de.json { "common": { "appName": "CannaManage", "loading": "Laden...", "save": "Speichern", "cancel": "Abbrechen", "delete": "Löschen", "edit": "Bearbeiten", "add": "Hinzufügen", "search": "Suchen...", "noResults": "Keine Ergebnisse", "error": "Ein Fehler ist aufgetreten", "retry": "Erneut versuchen" }, "nav": { "overview": "Übersicht", "members": "Mitglieder", "distributions": "Ausgabe", "stock": "Bestand", "reports": "Berichte" }, "auth": { "login": "Anmelden", "logout": "Abmelden", "email": "E-Mail-Adresse", "password": "Passwort", "invalidCredentials": "Ungültige Anmeldedaten", "sessionExpired": "Sitzung abgelaufen" } }// messages/en.json { "common": { "appName": "CannaManage", "loading": "Loading...", "save": "Save", "cancel": "Cancel", "delete": "Delete", "edit": "Edit", "add": "Add", "search": "Search...", "noResults": "No results", "error": "An error occurred", "retry": "Retry" }, "nav": { "overview": "Overview", "members": "Members", "distributions": "Distributions", "stock": "Stock", "reports": "Reports" }, "auth": { "login": "Sign in", "logout": "Sign out", "email": "Email address", "password": "Password", "invalidCredentials": "Invalid credentials", "sessionExpired": "Session expired" } } -
Update
app/[locale]/layout.tsxto force dark mode with locale:import { NextIntlClientProvider } from "next-intl" import { getMessages } from "next-intl/server" export default async function LocaleLayout({ children, params }) { const { locale } = await params const messages = await getMessages() return ( <html lang={locale} className="dark"> <body> <NextIntlClientProvider messages={messages}> {children} </NextIntlClientProvider> </body> </html> ) } -
All UI text uses
useTranslations()hook — no hardcoded strings:import { useTranslations } from "next-intl" export function SidebarNav() { const t = useTranslations("nav") return ( <nav> <Link href="/">{t("overview")}</Link> <Link href="/members">{t("members")}</Link> {/* ... */} </nav> ) } -
Create multi-stage Dockerfile:
FROM node:22-alpine AS base RUN corepack enable && corepack prepare pnpm@latest --activate FROM base AS deps WORKDIR /app COPY package.json pnpm-lock.yaml ./ RUN pnpm install --frozen-lockfile FROM base AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . RUN pnpm build FROM base AS runner WORKDIR /app ENV NODE_ENV=production COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/public ./public EXPOSE 3000 CMD ["node", "server.js"] -
Configure ESLint flat config + Prettier:
// eslint.config.mjs import { dirname } from "path" import { fileURLToPath } from "url" import { FlatCompat } from "@eslint/eslintrc" const __dirname = dirname(fileURLToPath(import.meta.url)) const compat = new FlatCompat({ baseDirectory: __dirname }) export default [ ...compat.extends("next/core-web-vitals", "next/typescript"), { rules: { "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], "no-console": ["warn", { allow: ["warn", "error"] }], }, }, ]// .prettierrc { "semi": false, "singleQuote": false, "tabWidth": 2, "trailingComma": "es5", "printWidth": 100 }Add scripts to
package.json:{ "scripts": { "lint": "next lint", "format": "prettier --write .", "format:check": "prettier --check ." } } -
Add MIT license attribution to
LICENSESfile at repo root.
Acceptance criteria:
pnpm devstarts on port 3000 without errors- Dark theme with green accents renders correctly
- German is default locale — navigating to
/redirects to/de - English locale works at
/en - All UI strings come from
messages/*.json(no hardcoded text) useTranslations()hook works in client componentspnpm buildproduces standalone outputpnpm lintpasses with no errorspnpm format:checkpasses (Prettier)- Security headers (CSP, HSTS, X-Frame-Options) present in responses
- Docker build succeeds (node:22-alpine)
docker compose upstarts all 3 services (db, api, frontend)
Phase 2: Auth Pages
Goal: Login page, NextAuth.js with CredentialsProvider, token rotation, role-based routing.
Dependencies: Phase 1 complete.
Files to create/modify:
| File | Action | Description |
|---|---|---|
lib/auth.ts |
Create | NextAuth configuration (section 3.2) |
app/api/auth/[...nextauth]/route.ts |
Create | NextAuth route handler |
app/(auth)/login/page.tsx |
Create | Login form page |
app/(auth)/layout.tsx |
Create | Minimal centered layout for auth pages |
components/cannamanage/login-form.tsx |
Create | Login form component (React Hook Form + Zod) |
middleware.ts |
Create | Auth middleware — protect dashboard routes |
types/next-auth.d.ts |
Create | Extend NextAuth session/JWT types |
types/api.ts |
Create | Backend DTO type definitions |
lib/api-client.ts |
Create | Server-side API client with auth headers |
Implementation details:
-
Login form with React Hook Form + Zod validation:
const loginSchema = z.object({ email: z.string().email("Ungültige E-Mail-Adresse"), password: z.string().min(8, "Mindestens 8 Zeichen"), }) -
NextAuth middleware for route protection:
// middleware.ts export { auth as middleware } from "@/lib/auth" export const config = { matcher: ["/(dashboard)(.*)"], } -
Role-based redirect after login:
ADMIN→/(dashboard overview)STAFF→/(same dashboard, limited sidebar items)MEMBER→/portal(future — redirect to login with message for now)
-
Token refresh: automatic via NextAuth
jwtcallback (section 3.2). If refresh fails → redirect to/login. -
Error handling: display toast on invalid credentials, show "Session expired" on 401 redirect.
Backend change required:
- Add CORS config to
SecurityConfig.java(section 4.1) to allowlocalhost:3000during dev.
Acceptance criteria:
- Login page renders at
/login - Valid credentials → redirects to dashboard
- Invalid credentials → shows error toast
- Protected routes redirect to
/loginwhen unauthenticated - Token auto-refreshes before expiry (no visible interruption)
- Role stored in session and accessible in components
- Logout clears session and redirects to
/login
Phase 3: Admin Dashboard
Goal: Overview page with club stats KPIs, sidebar navigation, quick actions.
Dependencies: Phase 2 complete.
Files to create/modify:
| File | Action | Description |
|---|---|---|
app/(dashboard)/layout.tsx |
Modify | Configure Shadboard sidebar with CannaManage navigation |
app/(dashboard)/page.tsx |
Create | Dashboard overview (stats cards + quick actions) |
components/cannamanage/stats-cards.tsx |
Create | KPI cards (members, distributions, stock, quota) |
components/cannamanage/quick-actions.tsx |
Create | Quick action buttons (record distribution, add member) |
components/cannamanage/recent-distributions.tsx |
Create | Last 5 distributions mini-table |
hooks/use-club-stats.ts |
Create | SWR/fetch hook for /clubs/me/stats |
lib/navigation.ts |
Create | Sidebar nav items config |
Sidebar navigation structure:
// lib/navigation.ts
export const sidebarItems = [
{ title: "Übersicht", href: "/", icon: LayoutDashboard },
{ title: "Mitglieder", href: "/members", icon: Users },
{ title: "Ausgabe", href: "/distributions", icon: Cannabis },
{ title: "Bestand", href: "/stock", icon: Package },
{ title: "Berichte", href: "/reports", icon: FileText },
]
Dashboard overview fetches:
GET /api/v1/clubs/me/stats→ClubStats(8 KPI values)GET /api/v1/distributions?page=0&size=5&sort=distributedAt,desc→ recent distributions
Stats cards layout (2×4 grid on desktop, 1×4 on mobile):
| Card | Value | Icon | Color |
|---|---|---|---|
| Aktive Mitglieder | activeMembers / totalMembers |
Users | green |
| Ausgaben heute | totalDistributionsThisMonth |
Cannabis | blue |
| Bestand (Chargen) | activeBatches |
Package | amber |
| Monatliche Menge | totalGramsDistributedThisMonth g |
Scale | purple |
"Today's Distribution Summary" widget (below stats cards):
// components/cannamanage/today-summary.tsx
// Compact card showing:
// - Total distributions today (count)
// - Total grams distributed today
// - Which staff members recorded distributions
// - Comparison badge: "↑12% vs yesterday" or "↓5% vs last week"
interface TodaySummaryProps {
distributionCount: number
totalGrams: number
staffBreakdown: { name: string; count: number }[]
comparisonVsYesterday: number // percentage delta
}
Quick member search (global, in header — Cmd+K):
// components/cannamanage/global-search.tsx
// Type-ahead search in the dashboard header (Cmd+K shortcut)
// Searches members by name/email/membership number
// Shows top 5 results with name, status badge
// Click → navigates to /members/{id}
// Uses: GET /api/v1/members?search={term}&size=5
Low stock alert badge:
- In sidebar nav item "Bestand": show red dot badge when any batch has < 100g remaining
- Badge count = number of low-stock batches
- API:
GET /api/v1/stock/batches?status=AVAILABLE→ filter client-side where qty < 100
Acceptance criteria:
- Dashboard loads with 4 stat cards from live backend data
- "Today's summary" widget shows distribution count, grams, and staff breakdown
- Daily comparison indicator shows delta percentage vs yesterday
- Global member search (Cmd+K) in header with type-ahead results
- Low stock alert badge on sidebar "Bestand" nav item when batches < 100g
- Sidebar navigation works (all links navigate correctly)
- Quick action "Neue Ausgabe" navigates to
/distributions/new - Quick action "Mitglied hinzufügen" navigates to
/members/new - Recent distributions table shows last 5 entries
- Loading states shown while data fetches
- Error state shown if API unreachable
- Responsive: mobile collapses sidebar to hamburger menu
Phase 4: Member Management
Goal: Member list with TanStack Table, detail/edit view, add member form.
Dependencies: Phase 3 complete (sidebar navigation exists).
Files to create/modify:
| File | Action | Description |
|---|---|---|
app/(dashboard)/members/page.tsx |
Create | Member list page |
app/(dashboard)/members/[id]/page.tsx |
Create | Member detail/edit page |
app/(dashboard)/members/new/page.tsx |
Create | Add member form |
components/cannamanage/members/member-table.tsx |
Create | TanStack Table with columns |
components/cannamanage/members/member-columns.tsx |
Create | Column definitions |
components/cannamanage/members/member-form.tsx |
Create | Shared create/edit form |
components/cannamanage/members/member-status-badge.tsx |
Create | Status badge component |
hooks/use-members.ts |
Create | Data fetching + pagination hook |
lib/validations/member.ts |
Create | Zod schemas for member forms |
TanStack Table columns:
| Column | Field | Features |
|---|---|---|
| Name | lastName, firstName |
Sortable, clickable (→ detail) |
email |
Sortable | |
| Mitgliedsnr. | membershipNumber |
Sortable |
| Status | status |
Filter (dropdown), badge |
| Alter | calculated from dateOfBirth |
— |
| U21 | under21 |
Badge (warning color) |
| Mitglied seit | membershipDate |
Sortable |
Features:
- Server-side pagination:
?page=0&size=20&sort=lastName,asc - Client-side column visibility toggle
- Search: debounced filter by name/email (query param
?search=) - Status filter dropdown (ACTIVE, INACTIVE, SUSPENDED, PENDING)
Member form (shared for create + edit):
const memberSchema = z.object({
firstName: z.string().min(2, "Mindestens 2 Zeichen"),
lastName: z.string().min(2, "Mindestens 2 Zeichen"),
email: z.string().email("Ungültige E-Mail"),
dateOfBirth: z.string().refine(isValidDate, "Ungültiges Datum"),
membershipDate: z.string().optional(),
})
API endpoints consumed:
GET /api/v1/members?page={p}&size={s}&sort={field},{dir}— paginated listGET /api/v1/members/{id}— single member detailPOST /api/v1/members— create memberPUT /api/v1/members/{id}— update member
Acceptance criteria:
- Member list renders with pagination (20 per page)
- Sorting works on all sortable columns
- Search filters members by name/email
- Status filter dropdown works
- Clicking a row navigates to detail page
- Edit form pre-fills current values
- Add member form validates and submits
- Success/error toasts after create/update
- Under-21 badge renders in warning color
- Empty state shown when no members match filter
Phase 5: Distribution Recording
Goal: Distribution form with real-time quota check, member/batch selection, history table.
Dependencies: Phase 4 complete (member lookup exists).
Files to create/modify:
| File | Action | Description |
|---|---|---|
app/(dashboard)/distributions/page.tsx |
Create | Distribution history list |
app/(dashboard)/distributions/new/page.tsx |
Create | New distribution form |
components/cannamanage/distributions/distribution-form.tsx |
Create | Multi-step distribution form |
components/cannamanage/distributions/quota-indicator.tsx |
Create | Visual quota remaining widget |
components/cannamanage/distributions/member-search.tsx |
Create | Combobox member search |
components/cannamanage/distributions/batch-select.tsx |
Create | Batch selector (only AVAILABLE) |
components/cannamanage/distributions/distribution-table.tsx |
Create | History table |
hooks/use-distributions.ts |
Create | Distribution data hook |
hooks/use-quota.ts |
Create | Real-time quota fetch |
lib/validations/distribution.ts |
Create | Zod schema |
Distribution form flow:
Step 1: Select Member
→ Combobox with debounced search (GET /members?search=...)
→ On select: fetch quota (GET /compliance/quota/{memberId})
→ Show QuotaIndicator (remaining today + this month)
Step 2: Select Batch + Amount
→ Dropdown: available batches (GET /stock/batches?status=AVAILABLE)
→ Number input: quantityGrams
→ Real-time validation: amount ≤ remaining quota
→ Visual: green/yellow/red indicator based on remaining
Step 3: Confirm
→ Summary card showing: member name, batch code, amount, quota impact
→ Submit: POST /distributions
→ On success: toast + redirect to history
→ On quota violation (409): show specific error from backend
Quota indicator component:
// Visual representation of quota usage
// Bar: [████████░░] 72g / 100g remaining (green)
// Bar: [██████████] 5g / 100g remaining (red/warning)
interface QuotaIndicatorProps {
totalAllowed: number
totalUsed: number
remaining: number
under21: boolean
}
API endpoints consumed:
GET /api/v1/members?search={term}— member searchGET /api/v1/compliance/quota/{memberId}— current quotaGET /api/v1/stock/batches?status=AVAILABLE— available batchesPOST /api/v1/distributions— record distributionGET /api/v1/distributions?page={p}&size={s}— history
Acceptance criteria:
- Member search combobox shows filtered results
- Selecting a member triggers quota fetch
- Quota indicator shows remaining grams (color-coded)
- Under-21 members show reduced quota (25g/month) with warning
- Batch dropdown only shows AVAILABLE batches
- Amount input validates against remaining quota
- Form submission succeeds → toast + redirect
- Quota violation (409) shows meaningful error
- Distribution history table with pagination
- Distribution history shows member name, batch code, amount, date, staff
Phase 6: Stock/Batch Management
Goal: Batch list with status badges, add batch form, stock level chart.
Dependencies: Phase 3 complete (can run independently of Phase 4-5).
Files to create/modify:
| File | Action | Description |
|---|---|---|
app/(dashboard)/stock/page.tsx |
Create | Stock overview (chart + batch list) |
app/(dashboard)/stock/new/page.tsx |
Create | Add batch form |
components/cannamanage/stock/batch-table.tsx |
Create | Batch table |
components/cannamanage/stock/batch-status-badge.tsx |
Create | Status badge (AVAILABLE/RECALLED/DEPLETED) |
components/cannamanage/stock/stock-chart.tsx |
Create | Recharts bar chart |
components/cannamanage/stock/add-batch-form.tsx |
Create | New batch form |
hooks/use-batches.ts |
Create | Batch data hook |
lib/validations/batch.ts |
Create | Zod schema |
Stock overview page layout:
┌─────────────────────────────────────┐
│ Stock Level Chart (Recharts BarChart)│
│ X: batch code, Y: quantity (grams) │
│ Color: green=AVAILABLE, red=RECALLED│
└─────────────────────────────────────┘
┌─────────────────────────────────────┐
│ Batch Table (TanStack Table) │
│ Columns: Code | Strain | Qty | │
│ Harvest | Status | Flag │
└─────────────────────────────────────┘
Recharts bar chart config:
<BarChart data={batches.filter(b => b.status !== "DEPLETED")}>
<Bar dataKey="quantityGrams" fill="#2ECC71" />
// RECALLED batches in red
</BarChart>
Batch form (Zod schema):
const batchSchema = z.object({
strainId: z.string().uuid("Sorte auswählen"),
quantityGrams: z.number().positive("Menge muss positiv sein"),
harvestDate: z.string().refine(isValidDate, "Ungültiges Datum"),
batchCode: z.string().min(3, "Mindestens 3 Zeichen"),
})
API endpoints consumed:
GET /api/v1/stock/batches— all batchesPOST /api/v1/stock/batches— create batchGET /api/v1/stock/batches?status=AVAILABLE— chart data
Acceptance criteria:
- Stock chart renders with batch quantities
- RECALLED batches shown in red on chart
- Batch table with status badges (green/red/gray)
- Contamination flag shown as warning icon
- Add batch form validates and submits
- Success toast after batch creation
- New batch appears in table without page refresh
- Responsive chart (readable on mobile)
Phase 7: Reports
Goal: Report overview page with download buttons, PDF/CSV generation triggers.
Dependencies: Phase 3 complete (sidebar nav).
Files to create/modify:
| File | Action | Description |
|---|---|---|
app/(dashboard)/reports/page.tsx |
Create | Report overview page |
components/cannamanage/reports/report-card.tsx |
Create | Report type card (trigger + download) |
components/cannamanage/reports/month-picker.tsx |
Create | Year/month selector |
components/cannamanage/reports/download-button.tsx |
Create | Download button with format selector |
hooks/use-reports.ts |
Create | Report generation/download hook |
Report overview layout:
┌─────────────────────────────────────┐
│ Monatsbericht │
│ [Monat wählen ▼] [PDF] [CSV] [JSON]│
│ Monthly distribution summary, │
│ compliance status, quota usage │
├─────────────────────────────────────┤
│ Mitgliederliste │
│ [PDF] [CSV] [JSON] │
│ Complete member roster with status │
├─────────────────────────────────────┤
│ Rückruf-Bericht │
│ [PDF] [CSV] [JSON] │
│ Recalled batches + affected members │
└─────────────────────────────────────┘
Download implementation:
async function downloadReport(type: string, format: string, params?: Record<string, string>) {
const session = await auth()
const query = new URLSearchParams({ format, ...params }).toString()
const res = await fetch(`/api/backend/reports/${type}?${query}`, {
headers: { Authorization: `Bearer ${session.accessToken}` },
})
if (format === "json") {
// Display inline in a modal/sheet
const data = await res.json()
return data
}
// PDF/CSV: trigger browser download
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
a.href = url
a.download = `${type}-${new Date().toISOString().slice(0,10)}.${format}`
a.click()
URL.revokeObjectURL(url)
}
API endpoints consumed:
GET /api/v1/reports/monthly?year={y}&month={m}&format=pdf|csv|jsonGET /api/v1/reports/members?format=pdf|csv|jsonGET /api/v1/reports/recall?format=pdf|csv|json
Acceptance criteria:
- Report overview page shows 3 report types
- Month picker works for monthly report
- PDF download triggers browser save dialog
- CSV download triggers browser save dialog
- JSON format shows inline preview in sheet/modal
- Loading indicator during report generation
- Error toast if report generation fails
- Reports page only visible to users with
VIEW_COMPLIANCE_REPORTpermission or ADMIN role
Phase 8: Member Portal Layout & Auth (Sprint 4.b)
Goal: Set up the member portal route group with session-based authentication, portal layout shell, and locale support.
Dependencies: Phase 1 complete (i18n setup), Phase 2 complete (NextAuth exists). Backend portal endpoints already exist from Sprint 3.
Files to create/modify:
| File | Action | Description |
|---|---|---|
app/[locale]/(portal)/layout.tsx |
Create | Portal layout shell (simpler than admin — no sidebar, top nav only) |
app/[locale]/(portal)/page.tsx |
Create | Portal dashboard redirect (→ quota view) |
components/cannamanage/portal/portal-header.tsx |
Create | Portal header with member name, locale switcher, logout |
components/cannamanage/portal/portal-nav.tsx |
Create | Top navigation: Übersicht, Historie, Einstellungen |
lib/portal-auth.ts |
Create | Portal-specific auth helpers (session check, member role guard) |
middleware.ts |
Modify | Add portal route protection (member role required) |
messages/de.json |
Modify | Add portal.* translation keys |
messages/en.json |
Modify | Add portal.* translation keys |
Implementation details:
-
Portal uses the same NextAuth session but with role guard:
// lib/portal-auth.ts import { auth } from "@/lib/auth" import { redirect } from "next/navigation" export async function requireMemberRole() { const session = await auth() if (!session) redirect("/login") if (session.user.role !== "MEMBER") redirect("/login?error=unauthorized") return session } -
Portal layout — minimal, member-facing:
// app/[locale]/(portal)/layout.tsx import { requireMemberRole } from "@/lib/portal-auth" import { PortalHeader } from "@/components/cannamanage/portal/portal-header" import { PortalNav } from "@/components/cannamanage/portal/portal-nav" export default async function PortalLayout({ children }) { const session = await requireMemberRole() return ( <div className="min-h-screen bg-background"> <PortalHeader memberName={session.user.name} /> <PortalNav /> <main className="container mx-auto px-4 py-6"> {children} </main> </div> ) } -
Portal navigation (top bar, not sidebar):
const portalNavItems = [ { title: t("portal.nav.quota"), href: "/portal", icon: Gauge }, { title: t("portal.nav.history"), href: "/portal/history", icon: History }, { title: t("portal.nav.settings"), href: "/portal/settings", icon: Settings }, ] -
Backend endpoints consumed (already exist from Sprint 3):
GET /api/v1/portal/dashboard— quota summaryGET /api/v1/portal/distributions— distribution historyGET /api/v1/portal/profile— member profilePUT /api/v1/portal/profile— update profile (email, language preference)
Acceptance criteria:
- Portal layout renders at
/de/portal(German) and/en/portal(English) - Members can access portal, admin/staff cannot (role guard)
- Unauthenticated users redirected to
/login - Portal header shows member name and locale switcher
- Top navigation links work correctly
- All portal UI strings use translation keys
- Dark theme consistent with admin dashboard
Phase 9: Member Portal Pages (Sprint 4.b)
Goal: Implement the member-facing pages: quota/dashboard view, distribution history, and profile/settings.
Dependencies: Phase 8 complete (portal layout + auth).
Files to create/modify:
| File | Action | Description |
|---|---|---|
app/[locale]/(portal)/page.tsx |
Modify | Quota dashboard — current quota usage, monthly/daily remaining |
app/[locale]/(portal)/history/page.tsx |
Create | Distribution history table (read-only) |
app/[locale]/(portal)/settings/page.tsx |
Create | Profile view + settings (email, password change, language) |
components/cannamanage/portal/quota-dashboard.tsx |
Create | Visual quota display (progress ring/bar) |
components/cannamanage/portal/distribution-history-table.tsx |
Create | Read-only history table |
components/cannamanage/portal/profile-form.tsx |
Create | Profile edit form |
hooks/use-portal-data.ts |
Create | Portal-specific data fetching hooks |
lib/validations/portal.ts |
Create | Zod schemas for portal forms |
messages/de.json |
Modify | Add portal page translation keys |
messages/en.json |
Modify | Add portal page translation keys |
Implementation details:
-
Quota Dashboard (
/portal):// Displays: // - Monthly quota: progress ring (e.g., 35g / 50g used) // - Daily limit status: X g remaining today // - Under-21 indicator (if applicable): reduced quota warning // - Last distribution: date, amount, batch infoVisual quota widget with progress bars and color coding:
interface PortalQuotaProps { monthlyAllowed: number // 50g or 25g (under-21) monthlyUsed: number dailyAllowed: number // ~25g per day dailyUsed: number under21: boolean nextRefillDate: string // ISO date of next month start (quota reset) } // Renders: // 1. Circular progress ring for monthly quota (large, centered) // 2. Linear progress bar for daily quota (below ring) // 3. Color coding: // - Green (<50% used): safe zone // - Amber (50-80% used): approaching limit // - Red (>80% used): near limit, caution // 4. "Next refill" indicator: "Kontingent wird am 01.07. zurückgesetzt" // 5. Simple language, no technical jargon: // - "Du hast noch 28g diesen Monat" (not "remaining quota: 28g") // - "Heute verfügbar: 25g" (not "daily limit remaining: 25g")Color thresholds (CSS classes):
function getQuotaColor(used: number, allowed: number): string { const pct = (used / allowed) * 100 if (pct < 50) return "text-green-500" // safe if (pct < 80) return "text-amber-500" // approaching return "text-red-500" // near limit } -
Distribution History (
/portal/history):// Read-only table showing member's own distributions // Columns: Date | Amount (g) | Batch Code | Staff Member // Sorted by date descending // Paginated (server-side, 20 per page)API:
GET /api/v1/portal/distributions?page={p}&size=20 -
Profile/Settings (
/portal/settings):// Sections: // - Personal info (read-only: name, membership number, member since) // - Contact (editable: email) // - Password change (current + new + confirm) // - Language preference (de/en dropdown — updates locale)Forms:
const profileSchema = z.object({ email: z.string().email(), }) const passwordSchema = z.object({ currentPassword: z.string().min(8), newPassword: z.string().min(8), confirmPassword: z.string().min(8), }).refine(data => data.newPassword === data.confirmPassword, { message: "Passwörter stimmen nicht überein", path: ["confirmPassword"], })
API endpoints consumed:
GET /api/v1/portal/dashboard— quota status (monthly + daily)GET /api/v1/portal/distributions?page={p}&size={s}— distribution historyGET /api/v1/portal/profile— current profilePUT /api/v1/portal/profile— update emailPOST /api/v1/auth/change-password— password change (new endpoint needed)
Backend change required (minor):
- Add
POST /api/v1/auth/change-passwordendpoint (acceptscurrentPassword+newPassword). This is a small addition to the existingAuthController.
Acceptance criteria:
- Quota dashboard shows monthly + daily usage with visual progress
- Under-21 members see reduced quota (25g) with appropriate warning
- Distribution history table renders with pagination
- History shows date, amount, batch code, staff name
- Profile page shows personal info (read-only) + editable email
- Password change form validates and submits
- Language preference switch changes locale immediately
- All text uses translation keys (no hardcoded strings)
- Empty states shown when no distributions exist
- Loading skeletons during data fetch
6. Cross-Cutting Concerns
6.1 Error Handling & Empty States
Global error boundary + per-page error states:
// app/(dashboard)/error.tsx — catches any unhandled error
"use client"
export default function DashboardError({ error, reset }) {
return (
<div className="flex flex-col items-center justify-center gap-4">
<h2>Ein Fehler ist aufgetreten</h2>
<p className="text-muted-foreground">{error.message}</p>
<Button onClick={reset}>Erneut versuchen</Button>
</div>
)
}
Empty states — every list page must have a friendly empty state:
| Page | Empty State Message | CTA |
|---|---|---|
| Members list | "Noch keine Mitglieder vorhanden" + illustration | "Erstes Mitglied anlegen" button |
| Distribution history | "Heute noch keine Ausgaben" + illustration | "Neue Ausgabe erfassen" button |
| Batch list | "Kein Bestand vorhanden" + illustration | "Erste Charge anlegen" button |
| Portal history | "Du hast noch keine Ausgaben erhalten" | — (no CTA, member can't create) |
| Reports | "Noch keine Berichte verfügbar" | — |
Error boundaries with retry:
- Each route group (
(dashboard),(portal)) has its ownerror.tsx - Network errors show "Verbindung zum Server fehlgeschlagen" + retry button
- 403 errors show "Keine Berechtigung" + link to dashboard
Offline indicator:
- Detect
navigator.onLinestatus - Show subtle banner at top: "Keine Internetverbindung — Daten möglicherweise veraltet"
6.2 Loading States
Shadboard provides skeleton components. Use for:
- Dashboard stat cards → 4 skeleton cards (pulse animation)
- Tables → skeleton rows (5 rows with shimmer)
- Forms → spinner on submit button (disabled state)
- Quota ring → circular skeleton placeholder
- Charts → rectangular skeleton with shimmer
6.3 Toast Notifications
Use Shadboard's built-in toast system (based on sonner):
- Success: green accent for CRUD operations
- Error: red for API failures
- Warning: amber for quota warnings
- Screen reader announcements: toasts use
role="alert"witharia-live="polite"for non-critical,aria-live="assertive"for errors
6.4 Responsive Design
Shadboard's layout handles:
- Desktop (≥1024px): fixed sidebar + content
- Tablet (768-1023px): collapsible sidebar
- Mobile (<768px): bottom nav or hamburger menu
6.5 Accessibility (WCAG 2.1 AA)
Target: WCAG 2.1 Level AA compliance across all pages.
| Requirement | Implementation |
|---|---|
| Focus management | Visible focus ring: ring-2 ring-ring ring-offset-2 ring-offset-background on all interactive elements |
| Skip navigation | <a href="#main" className="sr-only focus:not-sr-only focus:absolute ...">Zum Hauptinhalt</a> in root layout |
| Color contrast | Minimum 4.5:1 for normal text, 3:1 for large text. Verified: #E6EDF3 on #0D1117 = 13.5:1 ✅, #2ECC71 on #0D1117 = 7.5:1 ✅ |
| Keyboard navigation | All flows completable via keyboard. Tab order logical. Escape closes modals/dropdowns. |
| aria-labels | All icon-only buttons have aria-label. All form inputs have associated <label>. |
| Screen reader announcements | Toast notifications, quota updates, form submission results announced via aria-live regions |
| Reduced motion | Respect prefers-reduced-motion — disable animations/transitions when set |
| Images/icons | All decorative icons have aria-hidden="true". Informational icons have aria-label. |
6.6 Security Hardening
CSP headers: Configured in next.config.ts (section 3.5) — Content-Security-Policy, X-Frame-Options, HSTS, X-Content-Type-Options.
Auth security (already in backend, frontend must respect):
- Rate limiting on auth endpoints: backend enforces 5 attempts / 15 minutes per IP
- Frontend shows "Zu viele Anmeldeversuche" message on 429 response
- CSRF protection: NextAuth.js v5 includes built-in CSRF token verification (via
csrfTokenin forms)
Cookie security (NextAuth.js defaults, verified):
HttpOnly: true— session cookie not accessible via JavaScriptSecure: true— only sent over HTTPS (in production)SameSite: Lax— prevents CSRF from third-party sites- JWT never exposed to client-side JavaScript — stored server-side in encrypted session
Secret management:
.env.localcontainsNEXTAUTH_SECRET— must be a random 32+ character string in production- Docker Compose uses
${NEXTAUTH_SECRET}variable reference (not hardcoded indocker-compose.yml) - Production deployment: inject via CI/CD secrets or cloud secret manager (AWS Secrets Manager / Vault)
Input sanitization:
- All form inputs validated with Zod schemas before submission
- Server-side validation remains the authoritative check (backend already enforces)
- No
dangerouslySetInnerHTMLusage anywhere — all content rendered via React's default XSS protection
6.7 Testing Strategy
| Layer | Tool | Target Coverage | Convention |
|---|---|---|---|
| Unit tests | Vitest | 80% component logic | *.test.ts / *.test.tsx |
| Component tests | React Testing Library | All interactive components | *.test.tsx alongside component |
| E2E tests | Playwright | Critical user flows | e2e/*.spec.ts |
| API mocking | MSW (Mock Service Worker) | All API calls in tests | mocks/handlers.ts |
Critical E2E flows (Playwright):
- Login → Dashboard → verify stats load
- Record distribution → quota enforcement → success toast
- Member portal login → view quota → check history
- Report download (PDF)
Test file conventions:
cannamanage-frontend/
├── __tests__/ # Unit/component tests (mirrors src structure)
├── e2e/ # Playwright E2E tests
│ ├── auth.spec.ts
│ ├── distribution.spec.ts
│ ├── portal.spec.ts
│ └── reports.spec.ts
├── mocks/ # MSW handlers
│ ├── handlers.ts
│ └── server.ts
├── vitest.config.ts
└── playwright.config.ts
Vitest config:
// vitest.config.ts
import { defineConfig } from "vitest/config"
import react from "@vitejs/plugin-react"
import path from "path"
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
setupFiles: ["./vitest.setup.ts"],
globals: true,
},
resolve: {
alias: { "@": path.resolve(__dirname, ".") },
},
})
Package additions for testing:
{
"devDependencies": {
"vitest": "^3.x",
"@testing-library/react": "^16.x",
"@testing-library/jest-dom": "^6.x",
"msw": "^2.x",
"@playwright/test": "^1.x",
"jsdom": "^25.x"
}
}
6.8 Immutable Audit Trail
Backend enforcement (already exists):
- Distribution records cannot be edited or deleted after creation (
DISTRIBUTION_IMMUTABLEerror code) - All distributions are append-only — no UPDATE/DELETE on
distributionstable - Server-generated timestamps (
distributedAtset by backend, not client-submitted)
Frontend enforcement:
- Distribution history shows no edit/delete buttons on past records
- "Locked" indicator (🔒 icon) on each distribution row in admin view
- Timestamp displayed is always the server timestamp in
Europe/Berlintimezone with TZ indicator
Audit Log page (admin dashboard):
| File | Action | Description |
|---|---|---|
app/(dashboard)/audit/page.tsx |
Create | Read-only audit log viewer |
components/cannamanage/audit/audit-table.tsx |
Create | Paginated event log table |
// Audit log table columns:
// Zeitpunkt | Aktion | Mitglied | Menge | Charge | Mitarbeiter
// All read-only, no editing capabilities
// Filters: date range, staff member, action type
// API: GET /api/v1/distributions?page={p}&size={s}&sort=distributedAt,desc
// (Reuses existing distribution endpoint — distributions ARE the audit trail)
Compliance export (PDF audit report for Behörde inspections):
- New button on reports page: "Audit-Bericht exportieren"
- Generates PDF with all distributions for a given date range
- Includes: timestamp, member (anonymized ID), quantity, batch code, staff name
- All timestamps in
Europe/Berlintimezone with explicit(MEZ/MESZ)indicator - Footer: "Generiert am {date} — Alle Zeitstempel in Europe/Berlin"
6.9 Compliance Timestamp Verification
Server-authoritative timestamps:
- Distribution
distributedAtis always set by the backend (LocalDateTime.now(ZoneId.of("Europe/Berlin"))) - Frontend displays server timestamp, never
new Date()from client - All API responses include timestamps in ISO 8601 with timezone offset
Timezone display rules:
- All dates/times shown to users include timezone:
"12.06.2026, 14:32 MEZ" - Monthly report date ranges are timezone-aware:
01.06.2026 00:00 MEZ—30.06.2026 23:59 MEZ - Use
Intl.DateTimeFormatwithtimeZone: "Europe/Berlin"for all date formatting
// lib/date-utils.ts
export function formatDateTime(isoString: string): string {
return new Intl.DateTimeFormat("de-DE", {
dateStyle: "medium",
timeStyle: "short",
timeZone: "Europe/Berlin",
}).format(new Date(isoString))
}
7. Open Questions (All RESOLVED)
- Q1: Should we add a client-side data cache (SWR/React Query) or keep it simple with plain fetch + server components?
- RESOLVED: Start simple with server components + plain fetch. Add SWR/React Query only if UX demands real-time updates (defer to Sprint 5).
- Q2: Shadboard starter-kit may bundle its own auth example — do we strip it and replace with our NextAuth config, or adapt what's there?
- RESOLVED: Strip Shadboard's auth example and replace with our NextAuth CredentialsProvider config. Shadboard's auth is demo-only.
- Q3: Do we need an API health check endpoint on the backend for the frontend to verify connectivity on startup?
- RESOLVED: Not needed for MVP. Next.js will show error states naturally when API is unreachable. Add health endpoint in Sprint 5 if needed for Docker healthchecks.
- Q4: German language — hardcode all UI strings in Phase 1 or set up
next-intlfrom the start for future i18n?- RESOLVED: i18n from Day 1. Set up
next-intlin Phase 1 with German (default) + English. All UI strings useuseTranslations()hook. No hardcoded strings.
- RESOLVED: i18n from Day 1. Set up
8. Risk Assessment
| Risk | Probability | Impact | Mitigation |
|---|---|---|---|
| Shadboard starter-kit version mismatch (Next.js/React) | Low | High | Pin versions in package.json, test build immediately |
| NextAuth.js v5 breaking changes (still in beta) | Medium | Medium | Use stable patterns from docs, avoid experimental features |
| Backend CORS issues in dev | Low | Low | Fallback: use Next.js rewrites (no CORS needed) |
| TanStack Table learning curve | Low | Low | Shadboard may already include table examples |
| Token refresh race condition | Medium | Medium | NextAuth handles this internally; add retry logic |
| Dark theme contrast issues | Low | Low | Test with Lighthouse accessibility audit |
9. Definition of Done (Sprint 4)
Sprint 4.a (Admin Dashboard)
- Phases 1–7 implemented and functional
- Login → Dashboard → CRUD flows work end-to-end
- i18n working: German default, English available, no hardcoded strings
pnpm buildsucceeds without errors (Node 22 LTS)docker compose upstarts all services (node:22-alpine)- No TypeScript errors (
pnpm type-check) - Responsive on desktop + mobile viewport
- Dark theme with green accents consistent across all pages
- README.md in
cannamanage-frontend/with setup instructions
Sprint 4.b (Member Portal)
- Phases 8–9 implemented and functional
- Member login → Portal quota view → History → Settings flows work
- Portal uses same i18n setup (de/en), all strings translated
- Role guard: only MEMBER role can access portal routes
- Portal layout distinct from admin (top nav, no sidebar)