diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f1f8cc4..c4a4064 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,21 @@ jobs: - run: npx vitest run - run: npm run build + integrationstest: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - run: sudo apt-get update && sudo apt-get install -y postgresql + - run: npm install --no-audit --no-fund + working-directory: services/plattform + # Kör hela flödet mot riktig Postgres: organisationer, roller, + # append-only-triggern, isolering och publik delning. + - run: sudo bash integrationstest.sh + working-directory: services/plattform + container-byggen: runs-on: ubuntu-latest steps: diff --git a/docs/DRIFT.md b/docs/DRIFT.md index 9102c76..294552f 100644 --- a/docs/DRIFT.md +++ b/docs/DRIFT.md @@ -19,7 +19,7 @@ flowchart LR | Komponent | Vad | Var | | --- | --- | --- | | `web` | SPA:n bakom oprivilegierad nginx (`Dockerfile`, `docker/nginx.conf`) | Deployment + Service + HPA + PDB | -| `plattform` | Självhostad backend (`services/plattform`): inloggning (bcrypt via pgcrypto, HS256-JWT), append-only händelse-API för synken, publik delningsendpoint för Live Share | Deployment + Service + HPA + PDB | +| `plattform` | Självhostad backend (`services/plattform`): **multi-tenant** — registrering skapar organisation + systemadministratör, admin hanterar användare (tekniker/arbetsledare/admin), all ärendedata organisationsisolerad. Inloggning (bcrypt via pgcrypto, HS256-JWT med roll + org i anspråken), append-only händelse-API, publik delningsendpoint | Deployment + Service + HPA + PDB | | `ai-orkester` | AI-orkestern (`services/ai-orkester`): fyra uppgifter routade till Sonnet 5 / Opus 5 / Haiku 4.5 — verifierar plattformens JWT (delad hemlighet) | Deployment + Service + HPA + PDB | | `postgres` | Händelselogg + användare; **append-only garanterat med databastriggers** — historik kan inte ändras eller raderas oavsett roll | StatefulSet + PVC (10 Gi). Produktion: CloudNativePG-operatorn för backup/failover/PITR | | Hemligheter | `anthropic-api-key`, `jwt-secret` (delas av plattform + orkester), `postgres-losenord` | Secret `felsokning-hemligheter` — aldrig i bilder eller manifest | @@ -53,7 +53,18 @@ kubectl -n guidad-felsokning get pods curl https://app.exempel.se/halsa # → {"status":"ok"} (plattformen) ``` -Byt domän och cert-issuer i `infra/k8s/ingress.yaml`. Självregistrering är öppen i beta — stäng med `REGISTRERING_OPPEN=false` på plattformens Deployment när organisationsstyrd användarhantering införs. +Byt domän och cert-issuer i `infra/k8s/ingress.yaml`. Att skapa nya organisationer är öppet i beta — stäng med `REGISTRERING_OPPEN=false` på plattformens Deployment; användare inom en organisation skapas alltid av dess systemadministratör. + +## Multi-tenant och roller + +Enligt Master Prompt: varje kund är en egen tenant, ingen data blandas mellan kunder. + +- **Registrering skapar organisationen** och gör användaren till systemadministratör. +- **Admin skapar användare** (tekniker/arbetsledare/admin) i sin organisation — via UI:t eller `POST /api/anvandare`. +- **All ärendedata är organisationsknuten**: ärenden skapas i användarens organisation och händelse-API:t verifierar organisationstillhörighet på varje anrop — en annan organisations ärenden ger 404. +- **Rollen ligger i JWT:n** och verifieras på servern; klienten anpassar bara UI:t. + +Integrationstestet (`services/plattform/integrationstest.sh`, körs även i CI mot riktig Postgres) verifierar hela kedjan: registrering, synk, idempotens, append-only-triggern, organisationsisolering, delningsfiltrering och rollstyrning. ## Säkerhet och robusthet diff --git a/docs/MVP.md b/docs/MVP.md index af97655..d7dc4c0 100644 --- a/docs/MVP.md +++ b/docs/MVP.md @@ -27,6 +27,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st | Röstinmatning (tal in, text ut) | ✅ Push-to-Talk via webbläsarens taligenkänning (sv-SE): lyssnar bara efter aktivt tryck, röd indikator med realtidstranskript, texten hamnar i ett redigerbart fält och skickas aldrig automatiskt. Knappen visas bara i webbläsare med talstöd. Produktionsversionen byter motor till leverantörens Voice-to-Text bakom samma gränssnitt. | | Verifierade checklistor | ✅ Varje kontroll i metodiken har ett minimikrav (foto, mätvärde eller kort observation). Foto-kontroller verifieras med bild; mätningar kan inte markeras verifierade utan värde. | | Export | ✅ Versionsmärkt JSON-export (version = antal händelser vid exporttillfället, med användare och tidpunkt); exporten loggas själv som händelse. PDF via utskrift. CSV och API i backend-fasen. | +| Multi-tenant & roller | ✅ I självhostat läge: registrering skapar organisation + systemadministratör; admin hanterar användare (tekniker/arbetsledare/admin) via UI; all ärendedata organisationsisolerad i API:t; roll + organisation i JWT:n. Integrationstestat mot riktig Postgres (isolering, rollstyrning, append-only). | | Backend & synk | ✅ Databas-migration (`supabase/migrations/20260802230000_guidad_felsokning.sql`): ärenden + händelser med RLS, append-only även i databasen (inga update/delete-rättigheter). Synklager i klienten: konfliktfri ihopflätning av händelser per id (testad), push av lokala + pull av kollegors händelser var 15:e sekund. Utan inloggning arbetar appen i lokalt läge; status visas i ärendehuvudet. | | Metodiker | ✅ Tre: vibration, elsystem/strömförsörjning (relä-exemplet ur visionen) och generisk — vald automatiskt utifrån felbeskrivningen. | | Live Share | ✅ Skrivskyddad livevy per ärende (`/felsokning/dela/:id`): status ✔/🔄/⏳, bilder, mätvärdestabell, tidslinje, rekommenderat nästa steg. Uppdateras automatiskt, interna poster filtreras bort. Publik delningssida (`/felsokning/delad/:kod`) läser via `hamta_delat_arende` utan inloggning och pollar för liveuppdatering; "Kopiera delningslänk" finns i rapportfliken. Behörighetsnivåer (kund/intern/partner) i nästa fas. | diff --git a/infra/k8s/kustomization.yaml b/infra/k8s/kustomization.yaml index e13e93f..9f4373b 100644 --- a/infra/k8s/kustomization.yaml +++ b/infra/k8s/kustomization.yaml @@ -9,3 +9,9 @@ resources: - ingress.yaml # hemligheter.exempel.yaml appliceras medvetet inte — skapa secret:en # separat (se kommentaren i den filen). + +configMapGenerator: + - name: postgres-init + namespace: guidad-felsokning + files: + - init.sql=postgres-init.sql diff --git a/infra/k8s/postgres-init.sql b/infra/k8s/postgres-init.sql new file mode 100644 index 0000000..00f8c29 --- /dev/null +++ b/infra/k8s/postgres-init.sql @@ -0,0 +1,64 @@ +-- Guidad Felsökning: schema för den självhostade plattformen. +-- Multi-tenant: varje organisation är en egen tenant — all ärendedata är +-- organisationsknuten och API:t släpper aldrig data över organisationsgränsen. +-- Append-only garanteras med triggers: historik kan inte ändras eller +-- raderas oavsett vilken roll som ansluter. + +create extension if not exists pgcrypto; + +create table if not exists organisationer ( + id uuid primary key default gen_random_uuid(), + namn text not null, + skapad timestamptz not null default now() +); + +create table if not exists anvandare ( + id uuid primary key default gen_random_uuid(), + organisation_id uuid not null references organisationer(id), + epost text unique not null, + losen_hash text not null, + namn text not null, + roll text not null default 'tekniker' + check (roll in ('tekniker', 'arbetsledare', 'admin')), + skapad timestamptz not null default now() +); + +create table if not exists felsokning_arenden ( + id text primary key, + organisation_id uuid not null references organisationer(id), + nummer integer not null, + skapad timestamptz not null, + delningskod text unique, + metodik_id text, + skapad_av uuid references anvandare(id), + insatt timestamptz not null default now() +); +create index if not exists felsokning_arenden_org_idx + on felsokning_arenden (organisation_id, skapad desc); + +create table if not exists felsokning_handelser ( + id text primary key, + arende_id text not null references felsokning_arenden(id), + tidpunkt timestamptz not null, + anvandare text not null, + handelse jsonb not null, + insatt timestamptz not null default now() +); +create index if not exists felsokning_handelser_arende_idx + on felsokning_handelser (arende_id, tidpunkt); + +create or replace function forbjud_andring() returns trigger +language plpgsql as $$ +begin + raise exception 'Loggen är append-only — historik kan inte ändras eller raderas'; +end $$; + +drop trigger if exists handelser_append_only on felsokning_handelser; +create trigger handelser_append_only + before update or delete on felsokning_handelser + for each row execute function forbjud_andring(); + +drop trigger if exists arenden_append_only on felsokning_arenden; +create trigger arenden_append_only + before update or delete on felsokning_arenden + for each row execute function forbjud_andring(); diff --git a/infra/k8s/postgres.yaml b/infra/k8s/postgres.yaml index 220891a..2003b98 100644 --- a/infra/k8s/postgres.yaml +++ b/infra/k8s/postgres.yaml @@ -1,64 +1,8 @@ # Självhostad Postgres för Guidad Felsökning. -# För produktion rekommenderas CloudNativePG-operatorn (automatiska -# säkerhetskopior, failover, PITR) — den här StatefulSet:en är den enkla -# grundplattan med samma schema. -apiVersion: v1 -kind: ConfigMap -metadata: - name: postgres-init - namespace: guidad-felsokning -data: - # Körs vid första starten. Append-only garanteras med triggers — - # historiken kan inte ändras eller raderas oavsett vilken roll som - # ansluter. - init.sql: | - create extension if not exists pgcrypto; - - create table if not exists anvandare ( - id uuid primary key default gen_random_uuid(), - epost text unique not null, - losen_hash text not null, - namn text not null, - skapad timestamptz not null default now() - ); - - create table if not exists felsokning_arenden ( - id text primary key, - nummer integer not null, - skapad timestamptz not null, - delningskod text unique, - metodik_id text, - skapad_av uuid references anvandare(id), - insatt timestamptz not null default now() - ); - - create table if not exists felsokning_handelser ( - id text primary key, - arende_id text not null references felsokning_arenden(id), - tidpunkt timestamptz not null, - anvandare text not null, - handelse jsonb not null, - insatt timestamptz not null default now() - ); - create index if not exists felsokning_handelser_arende_idx - on felsokning_handelser (arende_id, tidpunkt); - - create or replace function forbjud_andring() returns trigger - language plpgsql as $$ - begin - raise exception 'Loggen är append-only — historik kan inte ändras eller raderas'; - end $$; - - drop trigger if exists handelser_append_only on felsokning_handelser; - create trigger handelser_append_only - before update or delete on felsokning_handelser - for each row execute function forbjud_andring(); - - drop trigger if exists arenden_append_only on felsokning_arenden; - create trigger arenden_append_only - before update or delete on felsokning_arenden - for each row execute function forbjud_andring(); ---- +# Schemat (infra/k8s/postgres-init.sql) genereras in som ConfigMap av +# kustomize och körs vid första starten — samma fil används av +# integrationstestet. För produktion rekommenderas CloudNativePG-operatorn +# (automatiska säkerhetskopior, failover, PITR). apiVersion: apps/v1 kind: StatefulSet metadata: diff --git a/services/plattform/integrationstest.sh b/services/plattform/integrationstest.sh new file mode 100755 index 0000000..7b1b4af --- /dev/null +++ b/services/plattform/integrationstest.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env bash +# Integrationstest för plattformstjänsten mot en riktig Postgres. +# Kör hela flödet: registrering (org + admin), synk av händelser, +# idempotens, append-only-triggern, organisationsisolering, publik +# delning med filtrering samt rollstyrd användarhantering. +# +# Krav: postgres (initdb/pg_ctl), node, curl. Kör: bash integrationstest.sh +set -euo pipefail +cd "$(dirname "$0")" + +PGPORT=5499 +APPPORT=8399 +BAS="http://127.0.0.1:$APPPORT" +DATADIR=$(mktemp -d) +PGBIN=$(ls -d /usr/lib/postgresql/*/bin | head -1) + +stada() { + kill "$SERVER_PID" 2>/dev/null || true + su postgres -c "$PGBIN/pg_ctl -D '$DATADIR' stop -m immediate" >/dev/null 2>&1 || true + rm -rf "$DATADIR" +} +trap stada EXIT + +# ---- Postgres upp ---- +chown postgres "$DATADIR" +su postgres -c "$PGBIN/initdb -D '$DATADIR' -A trust" >/dev/null +su postgres -c "$PGBIN/pg_ctl -D '$DATADIR' -o '-p $PGPORT -k /tmp -c listen_addresses=127.0.0.1' -l '$DATADIR/logg' start" >/dev/null +su postgres -c "$PGBIN/psql -h /tmp -p $PGPORT -d postgres -qc \"create role plattform login password 'test'\"" +su postgres -c "$PGBIN/psql -h /tmp -p $PGPORT -d postgres -qc 'create database felsokning owner plattform'" +PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning -q -f ../../infra/k8s/postgres-init.sql + +# ---- Tjänsten upp ---- +DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \ + JWT_SECRET=integrationshemlighet PORT=$APPPORT node server.mjs & +SERVER_PID=$! +sleep 1 + +falt() { node -pe "JSON.parse(require('fs').readFileSync(0,'utf8'))$1"; } +kontroll() { # kontroll + if [ "$2" = "$3" ]; then echo "✓ $1"; else echo "✗ $1: fick '$2', väntade '$3'"; exit 1; fi +} + +# 1. Registrera organisation A (admin) +SVAR=$(curl -s -X POST "$BAS/api/auth/registrera" -H 'Content-Type: application/json' \ + -d '{"epost":"anna@a.se","losenord":"hemligt123","namn":"Anna","organisation":"Verkstad A"}') +TOKEN_A=$(echo "$SVAR" | falt .token); kontroll "registrering ger admin" "$(echo "$SVAR" | falt .roll)" "admin" +kontroll "organisationsnamn" "$(echo "$SVAR" | falt .organisation)" "Verkstad A" + +# 2. Skapa ärende + händelser (inkl. internt kategoribyte) +curl -s -X POST "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d '{"id":"arende-test1","nummer":1,"skapad":"2026-08-03T08:00:00Z","delningskod":"delkod123"}' >/dev/null +curl -s -X POST "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d '{"handelser":[ + {"id":"h1","tidpunkt":"2026-08-03T08:01:00Z","anvandare":"Anna","handelse":{"typ":"felbeskrivning","text":"Startar inte"}}, + {"id":"h2","tidpunkt":"2026-08-03T08:02:00Z","anvandare":"Anna","handelse":{"typ":"kategori_byte","kategori":"provkorning"}} + ]}' >/dev/null +ANTAL=$(curl -s "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" | falt .handelser.length) +kontroll "händelser sparade" "$ANTAL" "2" + +# 3. Idempotens: samma push igen ändrar ingenting +curl -s -X POST "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d '{"handelser":[{"id":"h1","tidpunkt":"2026-08-03T08:01:00Z","anvandare":"Anna","handelse":{"typ":"felbeskrivning","text":"ÄNDRAD"}}]}' >/dev/null +TEXT=$(curl -s "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_A" | falt '.handelser[0].handelse.text') +kontroll "händelser skrivs aldrig över" "$TEXT" "Startar inte" + +# 4. Append-only-triggern stoppar direkta ändringar i databasen +if PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \ + -qc "update felsokning_handelser set anvandare='hackad' where id='h1'" 2>/dev/null; then + echo "✗ append-only-trigger saknas"; exit 1 +else + echo "✓ databastriggern avvisar update" +fi + +# 5. Organisationsisolering: org B ser inte org A:s ärenden +TOKEN_B=$(curl -s -X POST "$BAS/api/auth/registrera" -H 'Content-Type: application/json' \ + -d '{"epost":"bo@b.se","losenord":"hemligt123","namn":"Bo","organisation":"Verkstad B"}' | falt .token) +KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_B") +kontroll "org B nekas org A:s händelser" "$KOD" "404" +ANTAL_B=$(curl -s "$BAS/api/arenden" -H "Authorization: Bearer $TOKEN_B" | falt .arenden.length) +kontroll "org B:s ärendelista är tom" "$ANTAL_B" "0" + +# 6. Publik delning: utan inloggning, interna poster filtrerade +DELAT=$(curl -s "$BAS/api/delad/delkod123") +kontroll "delning nås utan inloggning" "$(echo "$DELAT" | falt .handelser.length)" "1" +kontroll "kategoribyte filtreras ur delning" "$(echo "$DELAT" | falt '.handelser[0].handelse.typ')" "felbeskrivning" + +# 7. Rollstyrning: admin skapar tekniker; tekniker får inte hantera användare +curl -s -X POST "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' \ + -d '{"epost":"johan@a.se","losenord":"hemligt123","namn":"Johan","roll":"tekniker"}' >/dev/null +TOKEN_J=$(curl -s -X POST "$BAS/api/auth/logga-in" -H 'Content-Type: application/json' \ + -d '{"epost":"johan@a.se","losenord":"hemligt123"}' | falt .token) +KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_J" \ + -H 'Content-Type: application/json' -d '{"epost":"x@a.se","losenord":"hemligt123","namn":"X","roll":"admin"}') +kontroll "tekniker nekas användarhantering" "$KOD" "403" +ANTAL_ANV=$(curl -s "$BAS/api/anvandare" -H "Authorization: Bearer $TOKEN_A" | falt .anvandare.length) +kontroll "org A har två användare" "$ANTAL_ANV" "2" + +# 8. Tekniker i org A når ärendet (delad arbetsyta inom organisationen) +ANTAL=$(curl -s "$BAS/api/arenden/arende-test1/handelser" -H "Authorization: Bearer $TOKEN_J" | falt .handelser.length) +kontroll "tekniker i samma org når ärendet" "$ANTAL" "2" + +echo "Integrationstest: allt grönt" diff --git a/services/plattform/server.mjs b/services/plattform/server.mjs index a47c538..c49b753 100644 --- a/services/plattform/server.mjs +++ b/services/plattform/server.mjs @@ -1,15 +1,17 @@ // Plattformstjänsten — självhostad backend för Guidad Felsökning. // -// Ersätter Supabase i klustret: egen inloggning (HS256-JWT, lösenord -// hashade med pgcrypto/bcrypt i Postgres), händelse-API för synken och -// publik delningsendpoint för Live Share. Händelseloggen är append-only -// även i databasen (triggers) — den här tjänsten exponerar medvetet inga -// update/delete-operationer. +// Multi-tenant: varje organisation är en egen tenant. Registrering skapar +// en organisation med en systemadministratör; admin skapar övriga +// användare (tekniker/arbetsledare) i sin organisation. All ärendedata +// är organisationsknuten — API:t släpper aldrig data över gränsen. +// +// Händelseloggen är append-only även i databasen (triggers) — den här +// tjänsten exponerar medvetet inga update/delete-operationer. // // Miljövariabler: // DATABASE_URL Postgres-anslutning (krävs) // JWT_SECRET HS256-hemlighet, delas med ai-orkestern (krävs) -// REGISTRERING_OPPEN "false" stänger självregistrering (default öppen, beta) +// REGISTRERING_OPPEN "false" stänger nya organisationer (default öppen, beta) // PORT default 8080 import { createServer } from "node:http"; @@ -19,6 +21,7 @@ import pg from "pg"; const PORT = Number(process.env.PORT ?? 8080); const MAX_KROPP = 4 * 1024 * 1024; const TOKEN_LIVSTID_S = 12 * 60 * 60; +const ROLLER = ["tekniker", "arbetsledare", "admin"]; const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 }); @@ -77,9 +80,27 @@ function kravAuth(req, hemlighet) { return token ? verifieraJwt(token, hemlighet) : null; } +// Verifierar att ärendet tillhör användarens organisation. +async function arendeIOrg(arendeId, organisationId) { + const rader = await pool.query( + `select 1 from felsokning_arenden where id = $1 and organisation_id = $2`, + [arendeId, organisationId], + ); + return rader.rowCount > 0; +} + // ---- Server ----------------------------------------------------------- export function skapaServer() { + function loggaIn(res, rad, hemlighet) { + const nu = Math.floor(Date.now() / 1000); + const token = skapaJwt( + { sub: rad.id, namn: rad.namn, org: rad.organisation_id, roll: rad.roll, iat: nu, exp: nu + TOKEN_LIVSTID_S }, + hemlighet, + ); + return svara(res, 200, { token, namn: rad.namn, roll: rad.roll, organisation: rad.org_namn }); + } + return createServer(async (req, res) => { if (req.method === "OPTIONS") { res.writeHead(204, { @@ -100,31 +121,51 @@ export function skapaServer() { const hemlighet = process.env.JWT_SECRET; if (!hemlighet) return svara(res, 503, { error: "Tjänsten är inte konfigurerad." }); - // -- Auth (öppna endpoints) -- + // -- Registrering: skapar organisation + systemadministratör -- if (req.method === "POST" && vag === "/api/auth/registrera") { if (process.env.REGISTRERING_OPPEN === "false") { - return svara(res, 403, { error: "Registrering är stängd." }); + return svara(res, 403, { error: "Registrering är stängd — kontakta er administratör." }); } - const { epost, losenord, namn } = await lasKropp(req); - if (!epost?.includes("@") || !losenord || losenord.length < 8 || !namn?.trim()) { - return svara(res, 400, { error: "Ogiltig e-post, namn eller för kort lösenord (minst 8 tecken)." }); + const { epost, losenord, namn, organisation } = await lasKropp(req); + if (!epost?.includes("@") || !losenord || losenord.length < 8 || !namn?.trim() || !organisation?.trim()) { + return svara(res, 400, { + error: "Ange organisation, namn, e-post och lösenord (minst 8 tecken).", + }); + } + const klientDb = await pool.connect(); + try { + await klientDb.query("begin"); + const org = await klientDb.query( + `insert into organisationer (namn) values ($1) returning id, namn`, + [organisation.trim()], + ); + const rad = await klientDb.query( + `insert into anvandare (organisation_id, epost, losen_hash, namn, roll) + values ($1, lower($2), crypt($3, gen_salt('bf')), $4, 'admin') + on conflict (epost) do nothing + returning id, namn, organisation_id, roll`, + [org.rows[0].id, epost.trim(), losenord, namn.trim()], + ); + if (rad.rowCount === 0) { + await klientDb.query("rollback"); + return svara(res, 409, { error: "E-postadressen är redan registrerad." }); + } + await klientDb.query("commit"); + return loggaIn(res, { ...rad.rows[0], org_namn: org.rows[0].namn }, hemlighet); + } catch (fel) { + await klientDb.query("rollback"); + throw fel; + } finally { + klientDb.release(); } - const rader = await pool.query( - `insert into anvandare (epost, losen_hash, namn) - values (lower($1), crypt($2, gen_salt('bf')), $3) - on conflict (epost) do nothing - returning id, namn`, - [epost.trim(), losenord, namn.trim()], - ); - if (rader.rowCount === 0) return svara(res, 409, { error: "E-postadressen är redan registrerad." }); - return loggaIn(res, rader.rows[0], hemlighet); } if (req.method === "POST" && vag === "/api/auth/logga-in") { const { epost, losenord } = await lasKropp(req); const rader = await pool.query( - `select id, namn from anvandare - where epost = lower($1) and losen_hash = crypt($2, losen_hash)`, + `select a.id, a.namn, a.organisation_id, a.roll, o.namn as org_namn + from anvandare a join organisationer o on o.id = a.organisation_id + where a.epost = lower($1) and a.losen_hash = crypt($2, a.losen_hash)`, [epost ?? "", losenord ?? ""], ); if (rader.rowCount === 0) return svara(res, 401, { error: "Fel e-post eller lösenord." }); @@ -148,9 +189,45 @@ export function skapaServer() { return svara(res, 200, { arende: arende.rows[0], handelser: handelser.rows }); } - // -- Skyddade endpoints -- + // -- Skyddade endpoints (organisationsknutna) -- const anspr = kravAuth(req, hemlighet); - if (!anspr) return svara(res, 401, { error: "Inloggning krävs." }); + if (!anspr?.org) return svara(res, 401, { error: "Inloggning krävs." }); + + // Användarhantering: endast systemadministratör, endast egen org. + if (vag === "/api/anvandare") { + if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." }); + if (req.method === "GET") { + const rader = await pool.query( + `select id, epost, namn, roll from anvandare where organisation_id = $1 order by namn`, + [anspr.org], + ); + return svara(res, 200, { anvandare: rader.rows }); + } + if (req.method === "POST") { + const { epost, losenord, namn, roll } = await lasKropp(req); + if (!epost?.includes("@") || !losenord || losenord.length < 8 || !namn?.trim() || !ROLLER.includes(roll)) { + return svara(res, 400, { error: "Ange namn, e-post, roll och lösenord (minst 8 tecken)." }); + } + const rad = await pool.query( + `insert into anvandare (organisation_id, epost, losen_hash, namn, roll) + values ($1, lower($2), crypt($3, gen_salt('bf')), $4, $5) + on conflict (epost) do nothing + returning id, epost, namn, roll`, + [anspr.org, epost.trim(), losenord, namn.trim(), roll], + ); + if (rad.rowCount === 0) return svara(res, 409, { error: "E-postadressen är redan registrerad." }); + return svara(res, 200, rad.rows[0]); + } + } + + if (req.method === "GET" && vag === "/api/arenden") { + const rader = await pool.query( + `select id, nummer, skapad, delningskod, metodik_id from felsokning_arenden + where organisation_id = $1 order by skapad desc limit 200`, + [anspr.org], + ); + return svara(res, 200, { arenden: rader.rows }); + } if (req.method === "POST" && vag === "/api/arenden") { const { id, nummer, skapad, delningskod, metodikId } = await lasKropp(req); @@ -158,40 +235,46 @@ export function skapaServer() { return svara(res, 400, { error: "Ogiltigt ärende." }); } await pool.query( - `insert into felsokning_arenden (id, nummer, skapad, delningskod, metodik_id, skapad_av) - values ($1, $2, $3, $4, $5, $6) on conflict (id) do nothing`, - [id, nummer, skapad, delningskod ?? null, metodikId ?? null, anspr.sub], + `insert into felsokning_arenden (id, organisation_id, nummer, skapad, delningskod, metodik_id, skapad_av) + values ($1, $2, $3, $4, $5, $6, $7) on conflict (id) do nothing`, + [id, anspr.org, nummer, skapad, delningskod ?? null, metodikId ?? null, anspr.sub], ); return svara(res, 200, { ok: true }); } const handelserVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/handelser$/); - if (handelserVag && req.method === "GET") { - const rader = await pool.query( - `select id, tidpunkt, anvandare, handelse from felsokning_handelser - where arende_id = $1 order by tidpunkt, id`, - [handelserVag[1]], - ); - return svara(res, 200, { handelser: rader.rows }); - } - if (handelserVag && req.method === "POST") { - const { handelser } = await lasKropp(req); - if (!Array.isArray(handelser) || handelser.length > 500) { - return svara(res, 400, { error: "Ogiltig händelselista." }); + if (handelserVag) { + // Organisationsgränsen: ärendet måste tillhöra användarens org. + if (!(await arendeIOrg(handelserVag[1], anspr.org))) { + return svara(res, 404, { error: "Ärendet är inte tillgängligt." }); } - for (const post of handelser) { - if (typeof post?.id !== "string" || !post.tidpunkt || typeof post.anvandare !== "string" || !post.handelse) { - return svara(res, 400, { error: "Ogiltig händelse." }); - } - // Append-only: on conflict do nothing — en befintlig händelse - // skrivs aldrig över, och databastriggern stoppar allt annat. - await pool.query( - `insert into felsokning_handelser (id, arende_id, tidpunkt, anvandare, handelse) - values ($1, $2, $3, $4, $5) on conflict (id) do nothing`, - [post.id, handelserVag[1], post.tidpunkt, post.anvandare, post.handelse], + if (req.method === "GET") { + const rader = await pool.query( + `select id, tidpunkt, anvandare, handelse from felsokning_handelser + where arende_id = $1 order by tidpunkt, id`, + [handelserVag[1]], ); + return svara(res, 200, { handelser: rader.rows }); + } + if (req.method === "POST") { + const { handelser } = await lasKropp(req); + if (!Array.isArray(handelser) || handelser.length > 500) { + return svara(res, 400, { error: "Ogiltig händelselista." }); + } + for (const post of handelser) { + if (typeof post?.id !== "string" || !post.tidpunkt || typeof post.anvandare !== "string" || !post.handelse) { + return svara(res, 400, { error: "Ogiltig händelse." }); + } + // Append-only: on conflict do nothing — en befintlig händelse + // skrivs aldrig över, och databastriggern stoppar allt annat. + await pool.query( + `insert into felsokning_handelser (id, arende_id, tidpunkt, anvandare, handelse) + values ($1, $2, $3, $4, $5) on conflict (id) do nothing`, + [post.id, handelserVag[1], post.tidpunkt, post.anvandare, post.handelse], + ); + } + return svara(res, 200, { ok: true }); } - return svara(res, 200, { ok: true }); } return svara(res, 404, { error: "Okänd resurs." }); @@ -200,15 +283,6 @@ export function skapaServer() { return svara(res, 500, { error: "Förfrågan misslyckades." }); } }); - - function loggaIn(res, anvandare, hemlighet) { - const nu = Math.floor(Date.now() / 1000); - const token = skapaJwt( - { sub: anvandare.id, namn: anvandare.namn, iat: nu, exp: nu + TOKEN_LIVSTID_S }, - hemlighet, - ); - return svara(res, 200, { token, namn: anvandare.namn }); - } } if (process.env.NODE_ENV !== "test") { diff --git a/src/felsokning/__tests__/ai.test.ts b/src/felsokning/__tests__/ai.test.ts index 1c63345..c5d439c 100644 --- a/src/felsokning/__tests__/ai.test.ts +++ b/src/felsokning/__tests__/ai.test.ts @@ -50,11 +50,15 @@ describe("AI-orkestern", () => { } // API:t exponerar medvetet inga update/delete-operationer. expect(plattform).not.toMatch(/\b(update|delete)\s+felsokning_handelser/i); + // Multi-tenant: all ärendedata är organisationsknuten. + expect(plattform).toContain("organisation_id"); + expect(plattform).toContain("arendeIOrg"); - const postgres = readFileSync("infra/k8s/postgres.yaml", "utf8"); - expect(postgres).toContain("before update or delete on felsokning_handelser"); - expect(postgres).toContain("before update or delete on felsokning_arenden"); - expect(postgres).toContain("append-only"); + const schema = readFileSync("infra/k8s/postgres-init.sql", "utf8"); + expect(schema).toContain("before update or delete on felsokning_handelser"); + expect(schema).toContain("before update or delete on felsokning_arenden"); + expect(schema).toContain("create table if not exists organisationer"); + expect(schema).toContain("check (roll in ('tekniker', 'arbetsledare', 'admin'))"); }); it("endpointen kodar AI-reglerna i grundprompten", () => { diff --git a/src/felsokning/plattform.ts b/src/felsokning/plattform.ts index 5067b5b..85a0d29 100644 --- a/src/felsokning/plattform.ts +++ b/src/felsokning/plattform.ts @@ -5,7 +5,15 @@ export const PLATTFORM_URL = (import.meta.env.VITE_PLATTFORM_URL as string | undefined)?.replace(/\/$/, ""); const TOKEN_NYCKEL = "gf-plattform-token"; -const NAMN_NYCKEL = "gf-plattform-namn"; +const KONTO_NYCKEL = "gf-plattform-konto"; + +export type PlattformRoll = "tekniker" | "arbetsledare" | "admin"; + +export interface PlattformKonto { + namn: string; + roll: PlattformRoll; + organisation: string; +} export function plattformAktiv(): boolean { return !!PLATTFORM_URL; @@ -15,16 +23,22 @@ export function plattformToken(): string | null { return localStorage.getItem(TOKEN_NYCKEL); } -export function plattformNamn(): string | null { - return localStorage.getItem(NAMN_NYCKEL); +export function plattformKonto(): PlattformKonto | null { + const rad = localStorage.getItem(KONTO_NYCKEL); + if (!rad || !plattformToken()) return null; + try { + return JSON.parse(rad) as PlattformKonto; + } catch { + return null; + } } export function loggaUtPlattform(): void { localStorage.removeItem(TOKEN_NYCKEL); - localStorage.removeItem(NAMN_NYCKEL); + localStorage.removeItem(KONTO_NYCKEL); } -async function authAnrop(vag: string, kropp: object): Promise { +async function authAnrop(vag: string, kropp: object): Promise { const res = await fetch(`${PLATTFORM_URL}${vag}`, { method: "POST", headers: { "Content-Type": "application/json" }, @@ -32,18 +46,56 @@ async function authAnrop(vag: string, kropp: object): Promise { }); const data = await res.json().catch(() => ({})); if (!res.ok) throw new Error((data as { error?: string }).error ?? `Fel ${res.status}`); - const { token, namn } = data as { token: string; namn: string }; + const { token, namn, roll, organisation } = data as PlattformKonto & { token: string }; localStorage.setItem(TOKEN_NYCKEL, token); - localStorage.setItem(NAMN_NYCKEL, namn); - return namn; + const konto: PlattformKonto = { namn, roll, organisation }; + localStorage.setItem(KONTO_NYCKEL, JSON.stringify(konto)); + return konto; } -export function loggaInPlattform(epost: string, losenord: string): Promise { +export function loggaInPlattform(epost: string, losenord: string): Promise { return authAnrop("/api/auth/logga-in", { epost, losenord }); } -export function registreraPlattform(epost: string, losenord: string, namn: string): Promise { - return authAnrop("/api/auth/registrera", { epost, losenord, namn }); +// Registrering skapar en ny organisation med användaren som +// systemadministratör; övriga användare skapas av admin. +export function registreraPlattform( + epost: string, + losenord: string, + namn: string, + organisation: string, +): Promise { + return authAnrop("/api/auth/registrera", { epost, losenord, namn, organisation }); +} + +// Användarhantering (kräver admin-roll; servern verifierar). +export interface PlattformAnvandare { + id: string; + epost: string; + namn: string; + roll: PlattformRoll; +} + +export async function hamtaAnvandare(): Promise { + const res = await plattformFetch("/api/anvandare"); + if (!res.ok) throw new Error(`Fel ${res.status}`); + return ((await res.json()) as { anvandare: PlattformAnvandare[] }).anvandare; +} + +export async function skapaAnvandare( + epost: string, + losenord: string, + namn: string, + roll: PlattformRoll, +): Promise { + const res = await plattformFetch("/api/anvandare", { + method: "POST", + body: JSON.stringify({ epost, losenord, namn, roll }), + }); + if (!res.ok) { + const data = (await res.json().catch(() => ({}))) as { error?: string }; + throw new Error(data.error ?? `Fel ${res.status}`); + } } // Autentiserat anrop mot plattformen. En utgången token rensas (401) så diff --git a/src/pages/felsokning/Arendelista.tsx b/src/pages/felsokning/Arendelista.tsx index 528f7e1..a51c6d0 100644 --- a/src/pages/felsokning/Arendelista.tsx +++ b/src/pages/felsokning/Arendelista.tsx @@ -4,45 +4,62 @@ import { useFelsokning, metodikForArende } from "@/felsokning/store"; import { arAvslutat, felbeskrivning, objekt, sistaAktivitet, brief } from "@/felsokning/projektioner"; import { byggDemoArende } from "@/felsokning/demo"; import { + hamtaAnvandare, loggaInPlattform, loggaUtPlattform, plattformAktiv, - plattformNamn, + plattformKonto, registreraPlattform, + skapaAnvandare, + type PlattformAnvandare, + type PlattformKonto, + type PlattformRoll, } from "@/felsokning/plattform"; import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui"; import { tidDatum, tidKlockslag } from "@/felsokning/format"; type Filter = "alla" | "pagaende" | "klara"; +const ROLL_LABEL: Record = { + tekniker: "Tekniker", + arbetsledare: "Arbetsledare", + admin: "Systemadministratör", +}; + // Självhostat läge: inloggning mot plattformstjänsten i klustret. -// Aktiverar synk, samarbete och AI-orkestern. +// Registrering skapar en ny organisation (tenant) med användaren som +// systemadministratör; övriga användare skapas av admin. function PlattformInloggning() { - const [namn, setNamn] = useState(plattformNamn()); + const [konto, setKonto] = useState(plattformKonto()); const [lage, setLage] = useState<"stangd" | "loggaIn" | "registrera">("stangd"); const [epost, setEpost] = useState(""); const [losenord, setLosenord] = useState(""); const [nyttNamn, setNyttNamn] = useState(""); + const [organisation, setOrganisation] = useState(""); const [fel, setFel] = useState(""); - if (namn) { + if (konto) { return ( - -
-

- Inloggad som {namn} — synk och AI aktiva. -

- -
-
+ <> + +
+

+ {konto.namn} · {ROLL_LABEL[konto.roll]} ·{" "} + {konto.organisation} — synk och AI aktiva. +

+ +
+
+ {konto.roll === "admin" && } + ); } @@ -52,11 +69,11 @@ function PlattformInloggning() { <>

Logga in för synk mellan enheter, samarbete i ärenden och AI-orkestern. Utan inloggning - arbetar appen i lokalt läge. + arbetar appen i lokalt läge. Ny organisation? Skapa konto — du blir systemadministratör.

setLage("registrera")}> - Skapa konto + Skapa organisation setLage("loggaIn")}>Logga in
@@ -67,17 +84,22 @@ function PlattformInloggning() { e.preventDefault(); setFel(""); try { - const inloggadSom = + setKonto( lage === "loggaIn" ? await loggaInPlattform(epost, losenord) - : await registreraPlattform(epost, losenord, nyttNamn); - setNamn(inloggadSom); + : await registreraPlattform(epost, losenord, nyttNamn, organisation), + ); } catch (misslyckande) { setFel(misslyckande instanceof Error ? misslyckande.message : "Något gick fel."); } }} > - {lage === "registrera" && } + {lage === "registrera" && ( + <> + + + + )} {fel &&

{fel}

} @@ -85,8 +107,15 @@ function PlattformInloggning() { setLage("stangd")}> Avbryt - - {lage === "loggaIn" ? "Logga in" : "Skapa konto"} + + {lage === "loggaIn" ? "Logga in" : "Skapa organisation"} @@ -95,6 +124,93 @@ function PlattformInloggning() { ); } +// Användarhantering för systemadministratören: lista och skapa användare +// i den egna organisationen (servern verifierar behörigheten). +function AnvandarAdmin() { + const [oppen, setOppen] = useState(false); + const [lista, setLista] = useState([]); + const [epost, setEpost] = useState(""); + const [namn, setNamn] = useState(""); + const [losenord, setLosenord] = useState(""); + const [roll, setRoll] = useState("tekniker"); + const [fel, setFel] = useState(""); + + const uppdatera = async () => { + try { + setLista(await hamtaAnvandare()); + } catch { + setFel("Kunde inte hämta användare."); + } + }; + + if (!oppen) { + return ( + + { + setOppen(true); + uppdatera(); + }} + > + Hantera användare i organisationen + + + ); + } + + return ( + + {lista.map((anv) => ( +

+ {anv.namn} · {ROLL_LABEL[anv.roll]}{" "} + {anv.epost} +

+ ))} +
{ + e.preventDefault(); + setFel(""); + try { + await skapaAnvandare(epost, losenord, namn, roll); + setEpost(""); + setNamn(""); + setLosenord(""); + await uppdatera(); + } catch (misslyckande) { + setFel(misslyckande instanceof Error ? misslyckande.message : "Något gick fel."); + } + }} + > + + + +
+ {(Object.keys(ROLL_LABEL) as PlattformRoll[]).map((valbar) => ( + + ))} +
+ {fel &&

{fel}

} + + Skapa användare + + +
+ ); +} + // Dashboard enligt direktivet: endast det viktigaste — mina ärenden, // pågående, klara och starta nytt ärende. export default function Arendelista() {