Skip to content

Architecture - KCMS admin

How apps/cms-admin is structured, where rendering happens, how it talks to the backend, and how permissions filter the UI.

Diagrams are Mermaid; they render inline on GitHub.


1. Where things run

flowchart LR
    Browser["Admin browser"]
    subgraph "Hetzner VPS (prod or dev)"
        Caddy["Caddy / TLS"]
        KCMS["rmsnordic-cms-admin\nNext.js 15\n:3002"]
        BE["rmsnordic-backend-dev\nSpring Boot\n:3001"]
    end

    Browser -->|"https://cms.rmsnordic.se"| Caddy
    Caddy --> KCMS
    KCMS -->|"server-side\nAuthorization: Bearer ..."| BE

Browser ↔ KCMS is the only external link. KCMS proxies every backend call server-side so the admin JWT never touches the browser bundle.


sequenceDiagram
    participant U as Admin Browser
    participant K as KCMS Next_js server
    participant BE as Spring backend

    U->>K: POST /api/auth/login { email, password }
    K->>BE: POST /api/v1/admin/auth/login
    BE-->>K: { token, mustChangePassword, role, permissions[] }
    K->>U: Set-Cookie admin-token=<jwt>; HttpOnly; Secure; SameSite=Lax\n(303 → /change-password if mustChangePassword)

    Note over U: Subsequent requests
    U->>K: GET /products
    K->>K: Read admin-token cookie
    K->>BE: GET /api/v1/admin/products\nAuthorization: Bearer <jwt>
    BE-->>K: products JSON
    K-->>U: SSR'd HTML

The token never appears in:

  • The HTML page source.
  • Any Set-Cookie reachable by client JS (HttpOnly).
  • Any browser-issued request to the backend (browser never calls the backend directly).

The only way to lift it is to compromise the KCMS server itself.


3. Sidebar filtering by permission

flowchart TB
    A["Admin signs in"] --> B[Backend returns role + permissions array]
    B --> C[KCMS stores in AdminMe context]
    C --> D{For each sidebar item}
    D --> E{"item.perm undefined OR\npermissions includes item.perm?"}
    E -->|yes| F[Render item]
    E -->|no| G[Hide item]
    F --> H{Section has any visible items?}
    G --> H
    H -->|yes| I[Show section]
    H -->|no| J[Hide section]

Every entry in components/Sidebar.tsx's NAV array declares its required perm. The filterNav() helper at render time:

  1. Walks each section.
  2. Filters items by visible(me, item).
  3. Drops sections that end up empty.

This is UI nicety. The backend rejects the request anyway if the user POSTs to a forbidden endpoint - but we don't show buttons people can't use.


4. Data flow for a list page (e.g. /products)

sequenceDiagram
    participant U as Browser
    participant Page as page_tsx server
    participant API as /api/admin/products/route.ts
    participant BE as Backend

    U->>Page: GET /products
    Note over Page: RSC fetch
    Page->>API: GET /api/admin/products?page=0 size=20
    API->>API: read admin-token cookie
    API->>BE: GET /api/v1/admin/products?page=0 size=20\n+ Authorization
    BE-->>API: { items, total, page, size }
    API-->>Page: same JSON
    Page-->>U: SSR'd table

    Note over U: User clicks row
    U->>Page: GET /products/{id}
    Page->>API: GET /api/admin/products/{id}
    API->>BE: ...

Two-layer indirection (page → API route → backend) keeps the server component code free of credential handling.


5. Forms: server actions vs API routes

We mix:

  • Server actions in app/(auth)/<area>/actions.ts files (marked "use server") for: simple form submissions, page revalidation, one-shot operations.
  • API routes in app/api/admin/<area>/route.ts for: anything driven from a client component (file upload, typeahead search, partial updates).

Both call the same underlying fetcher in lib/api.ts.

flowchart LR
    Form["Form component"] -->|action= servAction| ServerAction["actions.ts"]
    Form -->|fetch('/api/admin/...')| APIRoute["route.ts"]
    ServerAction --> Fetcher["lib/api.ts adminFetch"]
    APIRoute --> Fetcher
    Fetcher --> BE["Spring backend"]

6. Authentication flow

sequenceDiagram
    participant U as Browser
    participant M as middleware.ts
    participant Login as /login page
    participant API as /api/auth/login
    participant CP as /change-password page

    U->>M: GET /products
    M->>M: no admin-token cookie?
    M->>U: 307 → /login?next=/products
    U->>Login: render form
    U->>API: POST { email, password }
    API->>U: Set-Cookie + 303 → /products
    U->>M: GET /products (with cookie)
    M->>M: mustChangePassword in token?
    M->>U: 307 → /change-password (if so)
    U->>CP: set new pw
    CP->>API: POST /api/auth/change-password
    API->>U: Set-Cookie (refreshed) + 303 → /products

The middleware (apps/cms-admin/middleware.ts) is the single gate for "is this user authorized to be here?". It checks:

  1. Cookie present → otherwise → /login?next=.
  2. JWT not expired → otherwise → /login?next=.
  3. mustChangePassword=false → otherwise → /change-password.
  4. The accounting role lands on /accounting by default (it has no access to /products, so we redirect helpfully).

7. State management

There isn't any. KCMS is server-rendered, every change refreshes data from the backend, and we have zero client-side cache. The few client components (forms, search dropdowns, modals) keep their own useState.

This is by design. KCMS is a small admin app driven by humans, not a high-traffic public storefront. Optimistic UI, cache invalidation, local state synchronization - all unnecessary complexity here.


8. File uploads (images, CSVs)

sequenceDiagram
    participant U as Browser
    participant Page as Image upload form client
    participant Route as /api/admin/media/upload
    participant BE as Backend
    participant M as MinIO

    U->>Page: <input type=file>
    Page->>Route: POST multipart
    Route->>BE: forward multipart with Bearer
    BE->>M: PUT s3 object
    M-->>BE: 200
    BE-->>Route: { url, key }
    Route-->>Page: { url, key }
    Page-->>U: thumbnail rendered

The backend owns the MinIO connection; KCMS never sees the MinIO credentials. The public URL returned points at api.rmsnordic.se/media/... via Caddy.


9. Rich-text editing (TipTap)

CMS page bodies, blog posts, campaign descriptions use TipTap with:

  • StarterKit (paragraphs, headings, bold, italic, lists, blockquote, hr).
  • Underline.
  • Link (with auto-protocol).
  • Placeholder.

We serialize to HTML on save (not JSON). The storefront renders the HTML directly via dangerouslySetInnerHTML - sanitized by DOMPurify on the read path.

If we ever need block-based editing (image blocks, product-grid blocks), we have a Blocks system in packages/storefront/components/blocks/ and a corresponding KCMS editor at app/(auth)/pages/[id]/blocks/.


10. The Sidebar pattern

The sidebar is the spine. Adding a new admin area means:

  1. Add the entry to components/Sidebar.tsx's NAV array with the right perm tag.
  2. Create app/(auth)/<area>/page.tsx for the list view.
  3. Create app/(auth)/<area>/[id]/page.tsx for the detail/edit view.
  4. Create app/api/admin/<area>/route.ts for the backend proxy.

Naming convention is consistent across the codebase - duplicate an existing area as a template (e.g. copy bundles/ to start a new area).