From 00dc0d6c69a34c0038464f012b74060b885b4462 Mon Sep 17 00:00:00 2001 From: Nils-Johan Gynther Date: Sat, 18 Apr 2026 14:41:04 +0200 Subject: [PATCH] fix(docs): update NEXT_STEPS, README, and TEKNISK_BESKRIVNING with user role management details and new category structure --- NEXT_STEPS.md | 29 ++++++++++----- README.md | 14 +++++++ TEKNISK_BESKRIVNING.md | 59 ++++++++++++++++++++---------- db/seeds/categories_supplement.sql | 26 +++++++++++++ 4 files changed, 98 insertions(+), 30 deletions(-) diff --git a/NEXT_STEPS.md b/NEXT_STEPS.md index 77030d69..ebba3050 100644 --- a/NEXT_STEPS.md +++ b/NEXT_STEPS.md @@ -31,8 +31,8 @@ | Näringsvärden på produkter | ✅ Klart (schema + API) | | Seed produktdata med kategoritilldelning | ⚠️ Script klart, ej aktiverat i init | | Användarspecifika produkter (UserProduct) | ⚠️ Schema klart, UI basic | -| Användarroller (user / admin) | ❌ Saknas | -| Användarhantering i admin-UI | ❌ Saknas | +| Användarroller (user / admin) | ✅ Klart | +| Användarhantering i admin-UI | ✅ Klart | | Teknisk skuld — oanvända InventoryItem-fält | ✅ Klart (migration 20260418) | | Teknisk skuld — redirect-routes städade | ✅ Klart | | Avancerad AI-integration (veckoplanering, kampanjdata) | ❌ Planerad | @@ -51,15 +51,24 @@ - Generera en ny `002-seed-products.sql` med korrekt `categoryId` per rad (via `SELECT` mot live-db) - Ta bort `.disabled`-suffixet och testa fresh install -### 2. Användarroller -**Mål:** Säkerställa att endast behöriga användare har admin-rättigheter. +### 2. Användarroller ✅ +**Klart.** -Idag har alla inloggade användare samma behörighetsnivå — ett säkerhetsproblem inför lansering. -- **Databas:** Lägg till `role` (enum `user` | `admin`) på `User`-modellen i Prisma + migration -- **Backend:** Rollbaserad guard (`@Roles('admin')`) — skyddar admin-endpoints; vanliga användare nekas med 403 -- **Auth:** Rollen inkluderas i JWT-tokenen och i Auth.js session-objektet -- **Frontend — admin-UI (`/admin/users/`):** Lista användare, skapa nya konton (namn, e-post, lösenord, roll), ändra roll, avaktivera konto -- **Frontend — skyddade routes:** `/admin/*` kräver admin-roll; omdirigerar annars till startsidan +Systemet har nu fullständig rollbaserad åtkomstkontroll med två roller: `user` (standard) och `admin`. + +**Vad som implementerades:** + +- **Prisma-migration** (`20260418100000_add_user_role`) — fältet `role String @default("user")` lades till på `User`-modellen +- **`@Roles('admin')`-dekoratorn** (`auth/decorators/roles.decorator.ts`) — använder `SetMetadata` för att markera endpoints +- **`RolesGuard`** (`auth/roles.guard.ts`) — registrerad globalt som `APP_GUARD`; läser rollmetadata, kastar 403 om rätt roll saknas +- **JWT inkluderar nu `role`** — `jwt.strategy.ts` returnerar `{userId, username, role}`, `auth.service.ts` signerar med `role` i payload +- **Bootstrap-användare** (`users/admin-bootstrap.service.ts`) — `OnApplicationBootstrap` skapar/uppdaterar Nadmin, Padmin, user1 och user2 vid varje uppstart via miljövariabler +- **Skyddade produkt-endpoints** — `@Roles('admin')` på `merge`, `delete`, `restore`, `reset-all`, `bulk-update`, `backfill-canonical` i `products.controller.ts` +- **Användarhantering-API** — `GET /api/users` och `PATCH /api/users/:id/role` (båda kräver admin-roll) +- **Frontend-session** — `auth.ts` sparar `role` i JWT och session; `types/next-auth.d.ts` utgör typdefinition +- **Proxy-skydd** — `proxy.ts` blockerar `/admin/*` för användare utan admin-roll +- **Admin-UI** — `/admin/users` med tabell och rollbyteskäppar; länken "👥 Användare" i navigeringen visas enbart för admins +- **API-proxies** — `app/api/admin-users/route.ts` och `app/api/admin-users/[id]/route.ts` vidarebefordrar anrop med auth-header ### 3. Matplan-vy (frontend-polish) ✅ **Klart.** diff --git a/README.md b/README.md index 85572751..ce7d4ae7 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,20 @@ En fullstack-applikation för hantering av hemmavaror och recept. Håll koll på - **Ta bort och återställ** — soft-delete enskilda produkter, återställ med ett klick - **Återställ all produktdata** — rensningsknapp som raderar alla produkter, inventarie, taggar och kvitto-alias (behåller användare och kategorier) +> Obs: Destruktiva åtgärder (merge, ta bort, återställ, bulk-uppdatera, återställ all data) kräver admin-roll. + +### Admin: Användare +- **Lista alla användare** — se användarnamn, e-postadress, namn, roll och registreringsdatum +- **Ändra roll** — växla en användares roll mellan `user` och `admin` med ett klick +- **Skyddad sida** — `/admin/users` är enbart åtkomlig för inloggade användare med admin-roll; övriga omdirigeras till startsidan +- **Navigering** — länken "👥 Användare" visas bara i huvudmenyn om inloggad användare har admin-roll + +### Autentisering och roller +- **Rollbaserad åtkomstkontroll** — systemet har två roller: `user` (standard) och `admin` +- **Automatisk bootstrap** — fyra användare skapas eller uppdateras automatiskt när backend startar, baserat på miljövariabler: + - `Nadmin` (admin), `Padmin` (admin), `user1` (user), `user2` (user) +- **Skyddade admin-endpoints** — destruktiva produkt-endpoints och användarhantering kräver `admin`-roll; försök utan rätt roll ger 403 Förbjuden + ### Användarprofil - **Redigera profilinformation** — uppdatera förnamn, efternamn och e-postadress under "Min profil" diff --git a/TEKNISK_BESKRIVNING.md b/TEKNISK_BESKRIVNING.md index 6203db22..94fadbd0 100644 --- a/TEKNISK_BESKRIVNING.md +++ b/TEKNISK_BESKRIVNING.md @@ -89,6 +89,8 @@ docker exec recipe-db mariadb -uroot -p"LÖSENORD" recipe_app -e "SHOW TABLES;" | | `app/matplan/MealPlanClient.tsx` | Veckovy, receptval per dag, portionsjustering, inköpslista, inventariejämförelse | | **Kvittoimport** | `app/import/page.tsx` | Server component med Navigation + flikvy | | | `app/import/ImportTabsClient.tsx` | Klientkomponent: kvitto/recept-flikar | +| **Admin: Användare** | `app/admin/users/page.tsx` | Server component: hämtar alla användare, kräver admin-roll | +| | `app/admin/users/UserAdminClient.tsx` | Klientkomponent: tabell med rollbyte-knappar, anropar `/api/admin-users/:id` | | **Admin: Produkter** | `app/admin/products/page.tsx` | Produktadmin-panel | | | `AdminProductList.tsx` | Lista produkter, sök, sortera, filter okategoriserade, bulk-select + bulk-kategorisering | | | `EditProductForm.tsx` | Inline redigering: name, canonicalName, kategori (hierarkisk dropdown), brand, taggar | @@ -106,6 +108,8 @@ Alla proxy-routes läser auth-token via `auth()` (Auth.js v5) och vidarebefordra | Route | Metod | Syfte | |-------|-------|-------| +| `/api/admin-users` | GET | Hämtar alla användare (kräver admin-roll i session) | +| `/api/admin-users/[id]` | PATCH | Ändrar roll för användare med givet id (kräver admin-roll i session) | | `/api/auth/[...nextauth]` | GET, POST | Auth.js handlers (login, logout, session) | | `/api/products` | GET | Produktlista (auth-wrappat med `auth(req)`) | | `/api/categories` | GET | Kategorihierarki (publik, proxies `/api/categories/tree`) | @@ -124,8 +128,8 @@ Alla proxy-routes läser auth-token via `auth()` (Auth.js v5) och vidarebefordra ### Autentisering (Auth.js v5) -- `auth.ts` — NextAuth-konfiguration med Credentials provider -- `middleware.ts` — Skyddar alla routes utom `/login`, `/register` och `/api/auth` +- `auth.ts` — NextAuth-konfiguration med Credentials provider; sparar `accessToken`, `userId`, `username` och `role` i JWT-token och session +- `proxy.ts` — Skyddar alla routes utom `/login`, `/register` och `/api/auth`; blockerar `/admin/*` om sessionens `role` inte är `admin` - `lib/auth-headers.ts` — `getAuthHeaders()` hämtar Bearer-token från session (server-side) - `lib/api.ts` — `fetchJson()` lägger automatiskt till auth-headers server-side, redirectar till `/login` vid 401 @@ -143,7 +147,8 @@ Alla proxy-routes läser auth-token via `auth()` (Auth.js v5) och vidarebefordra - **Språk:** TypeScript 5.4.5 - **Databas:** MariaDB 11 (via Prisma 6.12.0 ORM) - **API:** REST, validering med class-validator -- **Autentisering:** JWT (7 dagars token), JwtAuthGuard skyddar alla routes, `@Public()` dekorator för öppna endpoints +- **Autentisering:** JWT (7 dagars token), `JwtAuthGuard` skyddar alla routes globalt, `RolesGuard` kontrollerar rollkrav, `@Public()` dekorator för öppna endpoints +- **Rollbaserad behörighet:** `@Roles('admin')` dekoratorn via `SetMetadata`; `RolesGuard` kastar 403 om rätt roll saknas - **Felhantering:** GlobalExceptionFilter (svenska felmeddelanden) - **Hälsokontroll:** /health endpoints - **Bygg:** `nest build`, körs i Docker-container @@ -155,16 +160,22 @@ backend/src/ ├── app.module.ts # Root module ├── main.ts # Startpunkt (port 8080, global prefix "api") ├── auth/ -│ ├── auth.controller.ts # POST /api/auth/login -│ ├── auth.service.ts # validateUser, login (JWT-signering) +│ ├── auth.controller.ts # POST /api/auth/login, POST /api/auth/register +│ ├── auth.service.ts # validateUser, login (JWT-signering inkl. role) │ ├── auth.module.ts -│ ├── jwt.strategy.ts # Passport JWT-strategi +│ ├── jwt.strategy.ts # Passport JWT-strategi (returnerar userId, username, role) │ ├── jwt-auth.guard.ts # Global guard (skyddar allt utom @Public) +│ ├── roles.guard.ts # Guard som kontrollerar @Roles() metadata; kastar 403 │ └── decorators/ -│ └── public.decorator.ts # @Public() – markerar öppen endpoint +│ ├── public.decorator.ts # @Public() – markerar öppen endpoint +│ ├── current-user.decorator.ts # @CurrentUser() – extraherar {userId, username, role} +│ └── roles.decorator.ts # @Roles('admin') – sätter rollkrav via SetMetadata ├── users/ -│ ├── users.controller.ts # GET/PATCH /api/users/me -│ ├── users.service.ts # findByUsername, create, updateProfile +│ ├── users.controller.ts # GET /api/users/me, PATCH /api/users/me +│ │ # GET /api/users (admin), PATCH /api/users/:id/role (admin) +│ ├── users.service.ts # findByUsername, findById, create, updateProfile +│ │ # findAll, setRole +│ ├── admin-bootstrap.service.ts # OnApplicationBootstrap: skapar/uppdaterar 4 seed-användare │ └── users.module.ts ├── categories/ │ ├── categories.controller.ts # GET /api/categories, GET /api/categories/tree (@Public) @@ -379,9 +390,11 @@ GET /api/categories/tree Hierarkiskt träd (@Public) ### Användar-endpoints ``` -POST /api/auth/login Logga in, returnerar JWT (@Public) -GET /api/users/me Hämta inloggad användares profil +POST /api/auth/login Logga in, returnerar JWT inkl. role (@Public) +GET /api/users/me Hämta inloggad användares profil (inkl. role) PATCH /api/users/me Uppdatera firstName, lastName, email +GET /api/users Lista alla användare (kräver admin-roll) +PATCH /api/users/:id/role Ändra roll för användare (kräver admin-roll) ``` ### Baslager-endpoints @@ -422,17 +435,29 @@ DELETE /api/receipt-alias/:id Ta bort alias ### User ```prisma model User { - id Int @id @default(autoincrement()) - username String @unique - email String @unique + id Int @id @default(autoincrement()) + username String @unique + email String @unique firstName String? lastName String? passwordHash String + role String @default("user") # "user" eller "admin" createdAt DateTime @default(now()) updatedAt DateTime @updatedAt } ``` +**Bootstrap-användare:** När backend startar kör `AdminBootstrapService.onApplicationBootstrap()` och skapar eller uppdaterar fyra användare baserade på miljövariabler: + +| Användarnamn | Roll | E-post | Miljövariabel | +|---|---|---|---| +| Nadmin | admin | nadmin@localhost | `ADMIN_NADMIN_PASSWORD` | +| Padmin | admin | padmin@localhost | `ADMIN_PADMIN_PASSWORD` | +| user1 | user | user1@localhost | `SEED_USER1_PASSWORD` | +| user2 | user | user2@localhost | `SEED_USER2_PASSWORD` | + +Om en användare redan finns men har fel roll rättas rollen automatiskt. Om miljövariabeln saknas hoppas den användaren över med en varning i loggen. + ### Category ```prisma model Category { @@ -495,16 +520,10 @@ model InventoryItem { unit String # Enhet (g, kg, ml, dl, st, tsk, msk, etc) brand String? # Varumärke location String? # Lagerplats (Kyl, Frys, Skafferi, etc) - priority Int? # Prioritetsordning purchaseDate DateTime? # Köpdatum opened Boolean? # Markering för öppnad produkt - shelfNote String? # Lagringsnot suitableFor String? # Lämplighetsmärkning (t.ex. "vegetarian") - isOnSale Boolean? # Är på rea - priceLevel Int? # Priskategori (1–5) bestBeforeDate DateTime? # Bäst före-datum - proteinType String? # Proteintyp (t.ex. "beef", "chicken") - isLeftover Boolean? # Är från tidigare lagnning comment String? # Fri kommentar createdAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/db/seeds/categories_supplement.sql b/db/seeds/categories_supplement.sql index 6d23ef65..55cf8aee 100644 --- a/db/seeds/categories_supplement.sql +++ b/db/seeds/categories_supplement.sql @@ -283,3 +283,29 @@ INSERT IGNORE INTO `Category` (`name`, `parentId`) FROM `Category` c1 JOIN `Category` c2 ON c2.parentId = c1.id AND c2.name = 'Asien' WHERE c1.name = 'Skafferi' AND c1.parentId IS NULL; + +-- ============================================================ +-- LEVEL 1 — Övrigt (ny toppkategori) +-- ============================================================ +INSERT IGNORE INTO `Category` (`name`, `parentId`) VALUES + ('Övrigt', NULL); + +-- ============================================================ +-- LEVEL 2 — under Övrigt +-- ============================================================ +INSERT IGNORE INTO `Category` (`name`, `parentId`) + SELECT 'Frukt och grönt', id FROM `Category` WHERE name = 'Övrigt' AND parentId IS NULL; +INSERT IGNORE INTO `Category` (`name`, `parentId`) + SELECT 'Kött', id FROM `Category` WHERE name = 'Övrigt' AND parentId IS NULL; +INSERT IGNORE INTO `Category` (`name`, `parentId`) + SELECT 'Fisk', id FROM `Category` WHERE name = 'Övrigt' AND parentId IS NULL; +INSERT IGNORE INTO `Category` (`name`, `parentId`) + SELECT 'Mejeri', id FROM `Category` WHERE name = 'Övrigt' AND parentId IS NULL; +INSERT IGNORE INTO `Category` (`name`, `parentId`) + SELECT 'Fryst', id FROM `Category` WHERE name = 'Övrigt' AND parentId IS NULL; +INSERT IGNORE INTO `Category` (`name`, `parentId`) + SELECT 'Skafferi', id FROM `Category` WHERE name = 'Övrigt' AND parentId IS NULL; +INSERT IGNORE INTO `Category` (`name`, `parentId`) + SELECT 'Barn och Godis', id FROM `Category` WHERE name = 'Övrigt' AND parentId IS NULL; +INSERT IGNORE INTO `Category` (`name`, `parentId`) + SELECT 'Dryck', id FROM `Category` WHERE name = 'Övrigt' AND parentId IS NULL;