Organisationslagret: multi-tenant med roller, integrationstestat
Master Prompts tenant- och rollmodell implementerad i den självhostade plattformen: - Registrering skapar organisation + systemadministratör (transaktion); admin skapar övriga användare (tekniker/arbetsledare/admin) i sin organisation via nytt UI och POST /api/anvandare. Roll + organisation ligger i JWT-anspråken och verifieras alltid på servern. - All ärendedata organisationsknuten: ärenden skapas i användarens organisation, händelse-API:t verifierar tillhörighet per anrop och GET /api/arenden listar bara egna organisationens ärenden. - Schemat flyttat till infra/k8s/postgres-init.sql och genereras in som ConfigMap via kustomize — samma fil används av integrationstestet. Nya tabeller: organisationer; anvandare med organisation_id + roll (check-constraint); arenden med organisation_id + index. - Integrationstest (services/plattform/integrationstest.sh) mot riktig Postgres, kört grönt lokalt och tillagt i CI: registrering, synk, idempotens (händelser skrivs aldrig över), append-only-triggern, organisationsisolering (404 över gränsen), publik delning med filtrering, rollstyrning (tekniker nekas användarhantering) och delad arbetsyta inom organisationen. - Klienten: registreringsformulär med organisationsfält, kontopanel med roll och organisation, admin-panel för användarhantering. Verifierat: integrationstestets 12 kontroller gröna mot Postgres 16, 28 vitest-tester gröna, produktionsbygge ok, YAML validerad, Playwright-röktest grönt. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
@@ -18,6 +18,21 @@ jobs:
|
|||||||
- run: npx vitest run
|
- run: npx vitest run
|
||||||
- run: npm run build
|
- 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:
|
container-byggen:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
|||||||
+13
-2
@@ -19,7 +19,7 @@ flowchart LR
|
|||||||
| Komponent | Vad | Var |
|
| Komponent | Vad | Var |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `web` | SPA:n bakom oprivilegierad nginx (`Dockerfile`, `docker/nginx.conf`) | Deployment + Service + HPA + PDB |
|
| `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 |
|
| `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 |
|
| `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 |
|
| 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)
|
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
|
## Säkerhet och robusthet
|
||||||
|
|
||||||
|
|||||||
@@ -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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
||||||
|
|||||||
@@ -9,3 +9,9 @@ resources:
|
|||||||
- ingress.yaml
|
- ingress.yaml
|
||||||
# hemligheter.exempel.yaml appliceras medvetet inte — skapa secret:en
|
# hemligheter.exempel.yaml appliceras medvetet inte — skapa secret:en
|
||||||
# separat (se kommentaren i den filen).
|
# separat (se kommentaren i den filen).
|
||||||
|
|
||||||
|
configMapGenerator:
|
||||||
|
- name: postgres-init
|
||||||
|
namespace: guidad-felsokning
|
||||||
|
files:
|
||||||
|
- init.sql=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();
|
||||||
+4
-60
@@ -1,64 +1,8 @@
|
|||||||
# Självhostad Postgres för Guidad Felsökning.
|
# Självhostad Postgres för Guidad Felsökning.
|
||||||
# För produktion rekommenderas CloudNativePG-operatorn (automatiska
|
# Schemat (infra/k8s/postgres-init.sql) genereras in som ConfigMap av
|
||||||
# säkerhetskopior, failover, PITR) — den här StatefulSet:en är den enkla
|
# kustomize och körs vid första starten — samma fil används av
|
||||||
# grundplattan med samma schema.
|
# integrationstestet. För produktion rekommenderas CloudNativePG-operatorn
|
||||||
apiVersion: v1
|
# (automatiska säkerhetskopior, failover, PITR).
|
||||||
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();
|
|
||||||
---
|
|
||||||
apiVersion: apps/v1
|
apiVersion: apps/v1
|
||||||
kind: StatefulSet
|
kind: StatefulSet
|
||||||
metadata:
|
metadata:
|
||||||
|
|||||||
Executable
+102
@@ -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 <namn> <faktiskt> <forvantat>
|
||||||
|
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"
|
||||||
+133
-59
@@ -1,15 +1,17 @@
|
|||||||
// Plattformstjänsten — självhostad backend för Guidad Felsökning.
|
// Plattformstjänsten — självhostad backend för Guidad Felsökning.
|
||||||
//
|
//
|
||||||
// Ersätter Supabase i klustret: egen inloggning (HS256-JWT, lösenord
|
// Multi-tenant: varje organisation är en egen tenant. Registrering skapar
|
||||||
// hashade med pgcrypto/bcrypt i Postgres), händelse-API för synken och
|
// en organisation med en systemadministratör; admin skapar övriga
|
||||||
// publik delningsendpoint för Live Share. Händelseloggen är append-only
|
// användare (tekniker/arbetsledare) i sin organisation. All ärendedata
|
||||||
// även i databasen (triggers) — den här tjänsten exponerar medvetet inga
|
// är organisationsknuten — API:t släpper aldrig data över gränsen.
|
||||||
// update/delete-operationer.
|
//
|
||||||
|
// Händelseloggen är append-only även i databasen (triggers) — den här
|
||||||
|
// tjänsten exponerar medvetet inga update/delete-operationer.
|
||||||
//
|
//
|
||||||
// Miljövariabler:
|
// Miljövariabler:
|
||||||
// DATABASE_URL Postgres-anslutning (krävs)
|
// DATABASE_URL Postgres-anslutning (krävs)
|
||||||
// JWT_SECRET HS256-hemlighet, delas med ai-orkestern (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
|
// PORT default 8080
|
||||||
|
|
||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
@@ -19,6 +21,7 @@ import pg from "pg";
|
|||||||
const PORT = Number(process.env.PORT ?? 8080);
|
const PORT = Number(process.env.PORT ?? 8080);
|
||||||
const MAX_KROPP = 4 * 1024 * 1024;
|
const MAX_KROPP = 4 * 1024 * 1024;
|
||||||
const TOKEN_LIVSTID_S = 12 * 60 * 60;
|
const TOKEN_LIVSTID_S = 12 * 60 * 60;
|
||||||
|
const ROLLER = ["tekniker", "arbetsledare", "admin"];
|
||||||
|
|
||||||
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 10 });
|
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;
|
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 -----------------------------------------------------------
|
// ---- Server -----------------------------------------------------------
|
||||||
|
|
||||||
export function skapaServer() {
|
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) => {
|
return createServer(async (req, res) => {
|
||||||
if (req.method === "OPTIONS") {
|
if (req.method === "OPTIONS") {
|
||||||
res.writeHead(204, {
|
res.writeHead(204, {
|
||||||
@@ -100,31 +121,51 @@ export function skapaServer() {
|
|||||||
const hemlighet = process.env.JWT_SECRET;
|
const hemlighet = process.env.JWT_SECRET;
|
||||||
if (!hemlighet) return svara(res, 503, { error: "Tjänsten är inte konfigurerad." });
|
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 (req.method === "POST" && vag === "/api/auth/registrera") {
|
||||||
if (process.env.REGISTRERING_OPPEN === "false") {
|
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);
|
const { epost, losenord, namn, organisation } = await lasKropp(req);
|
||||||
if (!epost?.includes("@") || !losenord || losenord.length < 8 || !namn?.trim()) {
|
if (!epost?.includes("@") || !losenord || losenord.length < 8 || !namn?.trim() || !organisation?.trim()) {
|
||||||
return svara(res, 400, { error: "Ogiltig e-post, namn eller för kort lösenord (minst 8 tecken)." });
|
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") {
|
if (req.method === "POST" && vag === "/api/auth/logga-in") {
|
||||||
const { epost, losenord } = await lasKropp(req);
|
const { epost, losenord } = await lasKropp(req);
|
||||||
const rader = await pool.query(
|
const rader = await pool.query(
|
||||||
`select id, namn from anvandare
|
`select a.id, a.namn, a.organisation_id, a.roll, o.namn as org_namn
|
||||||
where epost = lower($1) and losen_hash = crypt($2, losen_hash)`,
|
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 ?? ""],
|
[epost ?? "", losenord ?? ""],
|
||||||
);
|
);
|
||||||
if (rader.rowCount === 0) return svara(res, 401, { error: "Fel e-post eller lösenord." });
|
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 });
|
return svara(res, 200, { arende: arende.rows[0], handelser: handelser.rows });
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Skyddade endpoints --
|
// -- Skyddade endpoints (organisationsknutna) --
|
||||||
const anspr = kravAuth(req, hemlighet);
|
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") {
|
if (req.method === "POST" && vag === "/api/arenden") {
|
||||||
const { id, nummer, skapad, delningskod, metodikId } = await lasKropp(req);
|
const { id, nummer, skapad, delningskod, metodikId } = await lasKropp(req);
|
||||||
@@ -158,40 +235,46 @@ export function skapaServer() {
|
|||||||
return svara(res, 400, { error: "Ogiltigt ärende." });
|
return svara(res, 400, { error: "Ogiltigt ärende." });
|
||||||
}
|
}
|
||||||
await pool.query(
|
await pool.query(
|
||||||
`insert into felsokning_arenden (id, nummer, skapad, delningskod, metodik_id, skapad_av)
|
`insert into felsokning_arenden (id, organisation_id, nummer, skapad, delningskod, metodik_id, skapad_av)
|
||||||
values ($1, $2, $3, $4, $5, $6) on conflict (id) do nothing`,
|
values ($1, $2, $3, $4, $5, $6, $7) on conflict (id) do nothing`,
|
||||||
[id, nummer, skapad, delningskod ?? null, metodikId ?? null, anspr.sub],
|
[id, anspr.org, nummer, skapad, delningskod ?? null, metodikId ?? null, anspr.sub],
|
||||||
);
|
);
|
||||||
return svara(res, 200, { ok: true });
|
return svara(res, 200, { ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const handelserVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/handelser$/);
|
const handelserVag = vag.match(/^\/api\/arenden\/([A-Za-z0-9_-]+)\/handelser$/);
|
||||||
if (handelserVag && req.method === "GET") {
|
if (handelserVag) {
|
||||||
const rader = await pool.query(
|
// Organisationsgränsen: ärendet måste tillhöra användarens org.
|
||||||
`select id, tidpunkt, anvandare, handelse from felsokning_handelser
|
if (!(await arendeIOrg(handelserVag[1], anspr.org))) {
|
||||||
where arende_id = $1 order by tidpunkt, id`,
|
return svara(res, 404, { error: "Ärendet är inte tillgängligt." });
|
||||||
[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." });
|
|
||||||
}
|
}
|
||||||
for (const post of handelser) {
|
if (req.method === "GET") {
|
||||||
if (typeof post?.id !== "string" || !post.tidpunkt || typeof post.anvandare !== "string" || !post.handelse) {
|
const rader = await pool.query(
|
||||||
return svara(res, 400, { error: "Ogiltig händelse." });
|
`select id, tidpunkt, anvandare, handelse from felsokning_handelser
|
||||||
}
|
where arende_id = $1 order by tidpunkt, id`,
|
||||||
// Append-only: on conflict do nothing — en befintlig händelse
|
[handelserVag[1]],
|
||||||
// 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, { 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." });
|
return svara(res, 404, { error: "Okänd resurs." });
|
||||||
@@ -200,15 +283,6 @@ export function skapaServer() {
|
|||||||
return svara(res, 500, { error: "Förfrågan misslyckades." });
|
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") {
|
if (process.env.NODE_ENV !== "test") {
|
||||||
|
|||||||
@@ -50,11 +50,15 @@ describe("AI-orkestern", () => {
|
|||||||
}
|
}
|
||||||
// API:t exponerar medvetet inga update/delete-operationer.
|
// API:t exponerar medvetet inga update/delete-operationer.
|
||||||
expect(plattform).not.toMatch(/\b(update|delete)\s+felsokning_handelser/i);
|
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");
|
const schema = readFileSync("infra/k8s/postgres-init.sql", "utf8");
|
||||||
expect(postgres).toContain("before update or delete on felsokning_handelser");
|
expect(schema).toContain("before update or delete on felsokning_handelser");
|
||||||
expect(postgres).toContain("before update or delete on felsokning_arenden");
|
expect(schema).toContain("before update or delete on felsokning_arenden");
|
||||||
expect(postgres).toContain("append-only");
|
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", () => {
|
it("endpointen kodar AI-reglerna i grundprompten", () => {
|
||||||
|
|||||||
+63
-11
@@ -5,7 +5,15 @@
|
|||||||
export const PLATTFORM_URL = (import.meta.env.VITE_PLATTFORM_URL as string | undefined)?.replace(/\/$/, "");
|
export const PLATTFORM_URL = (import.meta.env.VITE_PLATTFORM_URL as string | undefined)?.replace(/\/$/, "");
|
||||||
|
|
||||||
const TOKEN_NYCKEL = "gf-plattform-token";
|
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 {
|
export function plattformAktiv(): boolean {
|
||||||
return !!PLATTFORM_URL;
|
return !!PLATTFORM_URL;
|
||||||
@@ -15,16 +23,22 @@ export function plattformToken(): string | null {
|
|||||||
return localStorage.getItem(TOKEN_NYCKEL);
|
return localStorage.getItem(TOKEN_NYCKEL);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function plattformNamn(): string | null {
|
export function plattformKonto(): PlattformKonto | null {
|
||||||
return localStorage.getItem(NAMN_NYCKEL);
|
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 {
|
export function loggaUtPlattform(): void {
|
||||||
localStorage.removeItem(TOKEN_NYCKEL);
|
localStorage.removeItem(TOKEN_NYCKEL);
|
||||||
localStorage.removeItem(NAMN_NYCKEL);
|
localStorage.removeItem(KONTO_NYCKEL);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function authAnrop(vag: string, kropp: object): Promise<string> {
|
async function authAnrop(vag: string, kropp: object): Promise<PlattformKonto> {
|
||||||
const res = await fetch(`${PLATTFORM_URL}${vag}`, {
|
const res = await fetch(`${PLATTFORM_URL}${vag}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -32,18 +46,56 @@ async function authAnrop(vag: string, kropp: object): Promise<string> {
|
|||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) throw new Error((data as { error?: string }).error ?? `Fel ${res.status}`);
|
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(TOKEN_NYCKEL, token);
|
||||||
localStorage.setItem(NAMN_NYCKEL, namn);
|
const konto: PlattformKonto = { namn, roll, organisation };
|
||||||
return namn;
|
localStorage.setItem(KONTO_NYCKEL, JSON.stringify(konto));
|
||||||
|
return konto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function loggaInPlattform(epost: string, losenord: string): Promise<string> {
|
export function loggaInPlattform(epost: string, losenord: string): Promise<PlattformKonto> {
|
||||||
return authAnrop("/api/auth/logga-in", { epost, losenord });
|
return authAnrop("/api/auth/logga-in", { epost, losenord });
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registreraPlattform(epost: string, losenord: string, namn: string): Promise<string> {
|
// Registrering skapar en ny organisation med användaren som
|
||||||
return authAnrop("/api/auth/registrera", { epost, losenord, namn });
|
// systemadministratör; övriga användare skapas av admin.
|
||||||
|
export function registreraPlattform(
|
||||||
|
epost: string,
|
||||||
|
losenord: string,
|
||||||
|
namn: string,
|
||||||
|
organisation: string,
|
||||||
|
): Promise<PlattformKonto> {
|
||||||
|
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<PlattformAnvandare[]> {
|
||||||
|
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<void> {
|
||||||
|
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å
|
// Autentiserat anrop mot plattformen. En utgången token rensas (401) så
|
||||||
|
|||||||
@@ -4,45 +4,62 @@ import { useFelsokning, metodikForArende } from "@/felsokning/store";
|
|||||||
import { arAvslutat, felbeskrivning, objekt, sistaAktivitet, brief } from "@/felsokning/projektioner";
|
import { arAvslutat, felbeskrivning, objekt, sistaAktivitet, brief } from "@/felsokning/projektioner";
|
||||||
import { byggDemoArende } from "@/felsokning/demo";
|
import { byggDemoArende } from "@/felsokning/demo";
|
||||||
import {
|
import {
|
||||||
|
hamtaAnvandare,
|
||||||
loggaInPlattform,
|
loggaInPlattform,
|
||||||
loggaUtPlattform,
|
loggaUtPlattform,
|
||||||
plattformAktiv,
|
plattformAktiv,
|
||||||
plattformNamn,
|
plattformKonto,
|
||||||
registreraPlattform,
|
registreraPlattform,
|
||||||
|
skapaAnvandare,
|
||||||
|
type PlattformAnvandare,
|
||||||
|
type PlattformKonto,
|
||||||
|
type PlattformRoll,
|
||||||
} from "@/felsokning/plattform";
|
} from "@/felsokning/plattform";
|
||||||
import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
|
import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
|
||||||
import { tidDatum, tidKlockslag } from "@/felsokning/format";
|
import { tidDatum, tidKlockslag } from "@/felsokning/format";
|
||||||
|
|
||||||
type Filter = "alla" | "pagaende" | "klara";
|
type Filter = "alla" | "pagaende" | "klara";
|
||||||
|
|
||||||
|
const ROLL_LABEL: Record<PlattformRoll, string> = {
|
||||||
|
tekniker: "Tekniker",
|
||||||
|
arbetsledare: "Arbetsledare",
|
||||||
|
admin: "Systemadministratör",
|
||||||
|
};
|
||||||
|
|
||||||
// Självhostat läge: inloggning mot plattformstjänsten i klustret.
|
// 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() {
|
function PlattformInloggning() {
|
||||||
const [namn, setNamn] = useState(plattformNamn());
|
const [konto, setKonto] = useState<PlattformKonto | null>(plattformKonto());
|
||||||
const [lage, setLage] = useState<"stangd" | "loggaIn" | "registrera">("stangd");
|
const [lage, setLage] = useState<"stangd" | "loggaIn" | "registrera">("stangd");
|
||||||
const [epost, setEpost] = useState("");
|
const [epost, setEpost] = useState("");
|
||||||
const [losenord, setLosenord] = useState("");
|
const [losenord, setLosenord] = useState("");
|
||||||
const [nyttNamn, setNyttNamn] = useState("");
|
const [nyttNamn, setNyttNamn] = useState("");
|
||||||
|
const [organisation, setOrganisation] = useState("");
|
||||||
const [fel, setFel] = useState("");
|
const [fel, setFel] = useState("");
|
||||||
|
|
||||||
if (namn) {
|
if (konto) {
|
||||||
return (
|
return (
|
||||||
<Panel rubrik="Plattformskonto">
|
<>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<Panel rubrik="Plattformskonto">
|
||||||
<p className="text-lg">
|
<div className="flex items-center justify-between gap-3">
|
||||||
Inloggad som <span className="font-extrabold">{namn}</span> — synk och AI aktiva.
|
<p className="text-lg">
|
||||||
</p>
|
<span className="font-extrabold">{konto.namn}</span> · {ROLL_LABEL[konto.roll]} ·{" "}
|
||||||
<button
|
{konto.organisation} — synk och AI aktiva.
|
||||||
className="whitespace-nowrap rounded-lg border-2 border-zinc-600 px-4 py-2 font-bold text-zinc-300 hover:border-zinc-400"
|
</p>
|
||||||
onClick={() => {
|
<button
|
||||||
loggaUtPlattform();
|
className="whitespace-nowrap rounded-lg border-2 border-zinc-600 px-4 py-2 font-bold text-zinc-300 hover:border-zinc-400"
|
||||||
setNamn(null);
|
onClick={() => {
|
||||||
}}
|
loggaUtPlattform();
|
||||||
>
|
setKonto(null);
|
||||||
Logga ut
|
}}
|
||||||
</button>
|
>
|
||||||
</div>
|
Logga ut
|
||||||
</Panel>
|
</button>
|
||||||
|
</div>
|
||||||
|
</Panel>
|
||||||
|
{konto.roll === "admin" && <AnvandarAdmin />}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,11 +69,11 @@ function PlattformInloggning() {
|
|||||||
<>
|
<>
|
||||||
<p className="mb-2 text-zinc-300">
|
<p className="mb-2 text-zinc-300">
|
||||||
Logga in för synk mellan enheter, samarbete i ärenden och AI-orkestern. Utan inloggning
|
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.
|
||||||
</p>
|
</p>
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<StorKnapp variant="sekundar" onClick={() => setLage("registrera")}>
|
<StorKnapp variant="sekundar" onClick={() => setLage("registrera")}>
|
||||||
Skapa konto
|
Skapa organisation
|
||||||
</StorKnapp>
|
</StorKnapp>
|
||||||
<StorKnapp onClick={() => setLage("loggaIn")}>Logga in</StorKnapp>
|
<StorKnapp onClick={() => setLage("loggaIn")}>Logga in</StorKnapp>
|
||||||
</div>
|
</div>
|
||||||
@@ -67,17 +84,22 @@ function PlattformInloggning() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFel("");
|
setFel("");
|
||||||
try {
|
try {
|
||||||
const inloggadSom =
|
setKonto(
|
||||||
lage === "loggaIn"
|
lage === "loggaIn"
|
||||||
? await loggaInPlattform(epost, losenord)
|
? await loggaInPlattform(epost, losenord)
|
||||||
: await registreraPlattform(epost, losenord, nyttNamn);
|
: await registreraPlattform(epost, losenord, nyttNamn, organisation),
|
||||||
setNamn(inloggadSom);
|
);
|
||||||
} catch (misslyckande) {
|
} catch (misslyckande) {
|
||||||
setFel(misslyckande instanceof Error ? misslyckande.message : "Något gick fel.");
|
setFel(misslyckande instanceof Error ? misslyckande.message : "Något gick fel.");
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{lage === "registrera" && <TextFalt label="Namn" varde={nyttNamn} satt={setNyttNamn} platshallare="T.ex. Anna" />}
|
{lage === "registrera" && (
|
||||||
|
<>
|
||||||
|
<TextFalt label="Organisation" varde={organisation} satt={setOrganisation} platshallare="T.ex. Verkstad Syd AB" />
|
||||||
|
<TextFalt label="Ditt namn" varde={nyttNamn} satt={setNyttNamn} platshallare="T.ex. Anna" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<TextFalt label="E-post" varde={epost} satt={setEpost} platshallare="anna@verkstaden.se" />
|
<TextFalt label="E-post" varde={epost} satt={setEpost} platshallare="anna@verkstaden.se" />
|
||||||
<TextFalt label="Lösenord (minst 8 tecken)" varde={losenord} satt={setLosenord} losenord />
|
<TextFalt label="Lösenord (minst 8 tecken)" varde={losenord} satt={setLosenord} losenord />
|
||||||
{fel && <p className="mb-3 font-bold text-red-400">{fel}</p>}
|
{fel && <p className="mb-3 font-bold text-red-400">{fel}</p>}
|
||||||
@@ -85,8 +107,15 @@ function PlattformInloggning() {
|
|||||||
<StorKnapp variant="sekundar" onClick={() => setLage("stangd")}>
|
<StorKnapp variant="sekundar" onClick={() => setLage("stangd")}>
|
||||||
Avbryt
|
Avbryt
|
||||||
</StorKnapp>
|
</StorKnapp>
|
||||||
<StorKnapp type="submit" disabled={!epost.trim() || losenord.length < 8 || (lage === "registrera" && !nyttNamn.trim())}>
|
<StorKnapp
|
||||||
{lage === "loggaIn" ? "Logga in" : "Skapa konto"}
|
type="submit"
|
||||||
|
disabled={
|
||||||
|
!epost.trim() ||
|
||||||
|
losenord.length < 8 ||
|
||||||
|
(lage === "registrera" && (!nyttNamn.trim() || !organisation.trim()))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{lage === "loggaIn" ? "Logga in" : "Skapa organisation"}
|
||||||
</StorKnapp>
|
</StorKnapp>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@@ -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<PlattformAnvandare[]>([]);
|
||||||
|
const [epost, setEpost] = useState("");
|
||||||
|
const [namn, setNamn] = useState("");
|
||||||
|
const [losenord, setLosenord] = useState("");
|
||||||
|
const [roll, setRoll] = useState<PlattformRoll>("tekniker");
|
||||||
|
const [fel, setFel] = useState("");
|
||||||
|
|
||||||
|
const uppdatera = async () => {
|
||||||
|
try {
|
||||||
|
setLista(await hamtaAnvandare());
|
||||||
|
} catch {
|
||||||
|
setFel("Kunde inte hämta användare.");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!oppen) {
|
||||||
|
return (
|
||||||
|
<Panel rubrik="Användare">
|
||||||
|
<StorKnapp
|
||||||
|
variant="sekundar"
|
||||||
|
onClick={() => {
|
||||||
|
setOppen(true);
|
||||||
|
uppdatera();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Hantera användare i organisationen
|
||||||
|
</StorKnapp>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel rubrik="Användare">
|
||||||
|
{lista.map((anv) => (
|
||||||
|
<p key={anv.id} className="border-b border-zinc-800 py-1 text-lg last:border-0">
|
||||||
|
<span className="font-extrabold">{anv.namn}</span> · {ROLL_LABEL[anv.roll]}{" "}
|
||||||
|
<span className="text-zinc-500">{anv.epost}</span>
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
<form
|
||||||
|
className="mt-3"
|
||||||
|
onSubmit={async (e) => {
|
||||||
|
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.");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TextFalt label="Namn" varde={namn} satt={setNamn} />
|
||||||
|
<TextFalt label="E-post" varde={epost} satt={setEpost} />
|
||||||
|
<TextFalt label="Lösenord (minst 8 tecken)" varde={losenord} satt={setLosenord} losenord />
|
||||||
|
<div className="mb-3 grid grid-cols-3 gap-2">
|
||||||
|
{(Object.keys(ROLL_LABEL) as PlattformRoll[]).map((valbar) => (
|
||||||
|
<button
|
||||||
|
key={valbar}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRoll(valbar)}
|
||||||
|
className={`min-h-12 rounded-lg border-2 text-sm font-extrabold ${
|
||||||
|
roll === valbar
|
||||||
|
? "border-amber-400 bg-amber-400 text-zinc-950"
|
||||||
|
: "border-zinc-600 bg-zinc-900 text-zinc-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{ROLL_LABEL[valbar]}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{fel && <p className="mb-3 font-bold text-red-400">{fel}</p>}
|
||||||
|
<StorKnapp type="submit" disabled={!epost.trim() || !namn.trim() || losenord.length < 8}>
|
||||||
|
Skapa användare
|
||||||
|
</StorKnapp>
|
||||||
|
</form>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Dashboard enligt direktivet: endast det viktigaste — mina ärenden,
|
// Dashboard enligt direktivet: endast det viktigaste — mina ärenden,
|
||||||
// pågående, klara och starta nytt ärende.
|
// pågående, klara och starta nytt ärende.
|
||||||
export default function Arendelista() {
|
export default function Arendelista() {
|
||||||
|
|||||||
Reference in New Issue
Block a user