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.
2. The JWT lives in a HttpOnly cookie¶
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-Cookiereachable 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:
- Walks each section.
- Filters items by
visible(me, item). - 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.tsfiles (marked"use server") for: simple form submissions, page revalidation, one-shot operations. - API routes in
app/api/admin/<area>/route.tsfor: 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:
- Cookie present → otherwise →
/login?next=. - JWT not expired → otherwise →
/login?next=. mustChangePassword=false→ otherwise →/change-password.- The accounting role lands on
/accountingby 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:
- Add the entry to
components/Sidebar.tsx's NAV array with the rightpermtag. - Create
app/(auth)/<area>/page.tsxfor the list view. - Create
app/(auth)/<area>/[id]/page.tsxfor the detail/edit view. - Create
app/api/admin/<area>/route.tsfor 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).