Files
cannamanage/docs/sprint-4/cannamanage-sprint4-plan.md
T
Patrick Plate fe6e96dd3f feat: Sprint 4 complete — frontend MVP (admin dashboard + member portal)
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
2026-06-12 17:18:38 +02:00

62 KiB
Raw Blame History

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 17: Admin Dashboard MVP (~7.5 days)
  • Sprint 4.b — Phases 89: 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.local file is gitignored. For production, inject NEXTAUTH_SECRET via 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:

  1. Clone Shadboard starter-kit:

    cd cannamanage-frontend
    npx degit Qualiora/shadboard/starter-kit .
    pnpm install
    
  2. Configure Tailwind with CannaManage brand palette (section 3.4).

  3. Set up next-intl for 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)
    
  4. 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"
      }
    }
    
  5. Update app/[locale]/layout.tsx to 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>
      )
    }
    
  6. 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>
      )
    }
    
  7. 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"]
    
  8. 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 ."
      }
    }
    
  9. Add MIT license attribution to LICENSES file at repo root.

Acceptance criteria:

  • pnpm dev starts 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 components
  • pnpm build produces standalone output
  • pnpm lint passes with no errors
  • pnpm format:check passes (Prettier)
  • Security headers (CSP, HSTS, X-Frame-Options) present in responses
  • Docker build succeeds (node:22-alpine)
  • docker compose up starts 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:

  1. 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"),
    })
    
  2. NextAuth middleware for route protection:

    // middleware.ts
    export { auth as middleware } from "@/lib/auth"
    export const config = {
      matcher: ["/(dashboard)(.*)"],
    }
    
  3. Role-based redirect after login:

    • ADMIN/ (dashboard overview)
    • STAFF/ (same dashboard, limited sidebar items)
    • MEMBER/portal (future — redirect to login with message for now)
  4. Token refresh: automatic via NextAuth jwt callback (section 3.2). If refresh fails → redirect to /login.

  5. 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 allow localhost:3000 during dev.

Acceptance criteria:

  • Login page renders at /login
  • Valid credentials → redirects to dashboard
  • Invalid credentials → shows error toast
  • Protected routes redirect to /login when 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/statsClubStats (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)
E-Mail 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 list
  • GET /api/v1/members/{id} — single member detail
  • POST /api/v1/members — create member
  • PUT /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 search
  • GET /api/v1/compliance/quota/{memberId} — current quota
  • GET /api/v1/stock/batches?status=AVAILABLE — available batches
  • POST /api/v1/distributions — record distribution
  • GET /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 batches
  • POST /api/v1/stock/batches — create batch
  • GET /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|json
  • GET /api/v1/reports/members?format=pdf|csv|json
  • GET /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_REPORT permission 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:

  1. 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
    }
    
  2. 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>
      )
    }
    
  3. 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 },
    ]
    
  4. Backend endpoints consumed (already exist from Sprint 3):

    • GET /api/v1/portal/dashboard — quota summary
    • GET /api/v1/portal/distributions — distribution history
    • GET /api/v1/portal/profile — member profile
    • PUT /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:

  1. 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 info
    

    Visual 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
    }
    
  2. 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

  3. 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 history
  • GET /api/v1/portal/profile — current profile
  • PUT /api/v1/portal/profile — update email
  • POST /api/v1/auth/change-password — password change (new endpoint needed)

Backend change required (minor):

  • Add POST /api/v1/auth/change-password endpoint (accepts currentPassword + newPassword). This is a small addition to the existing AuthController.

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 own error.tsx
  • Network errors show "Verbindung zum Server fehlgeschlagen" + retry button
  • 403 errors show "Keine Berechtigung" + link to dashboard

Offline indicator:

  • Detect navigator.onLine status
  • 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" with aria-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 csrfToken in forms)

Cookie security (NextAuth.js defaults, verified):

  • HttpOnly: true — session cookie not accessible via JavaScript
  • Secure: 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.local contains NEXTAUTH_SECRETmust be a random 32+ character string in production
  • Docker Compose uses ${NEXTAUTH_SECRET} variable reference (not hardcoded in docker-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 dangerouslySetInnerHTML usage 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):

  1. Login → Dashboard → verify stats load
  2. Record distribution → quota enforcement → success toast
  3. Member portal login → view quota → check history
  4. 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_IMMUTABLE error code)
  • All distributions are append-only — no UPDATE/DELETE on distributions table
  • Server-generated timestamps (distributedAt set 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/Berlin timezone 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/Berlin timezone with explicit (MEZ/MESZ) indicator
  • Footer: "Generiert am {date} — Alle Zeitstempel in Europe/Berlin"

6.9 Compliance Timestamp Verification

Server-authoritative timestamps:

  • Distribution distributedAt is 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 MEZ30.06.2026 23:59 MEZ
  • Use Intl.DateTimeFormat with timeZone: "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-intl from the start for future i18n?
    • RESOLVED: i18n from Day 1. Set up next-intl in Phase 1 with German (default) + English. All UI strings use useTranslations() hook. No hardcoded strings.

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 17 implemented and functional
  • Login → Dashboard → CRUD flows work end-to-end
  • i18n working: German default, English available, no hardcoded strings
  • pnpm build succeeds without errors (Node 22 LTS)
  • docker compose up starts 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 89 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)