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:
Claude
2026-08-03 07:04:32 +00:00
parent 7c24186cc5
commit b069c9a12f
11 changed files with 553 additions and 164 deletions
+144 -28
View File
@@ -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<PlattformRoll, string> = {
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 | null>(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 (
<Panel rubrik="Plattformskonto">
<div className="flex items-center justify-between gap-3">
<p className="text-lg">
Inloggad som <span className="font-extrabold">{namn}</span> synk och AI aktiva.
</p>
<button
className="whitespace-nowrap rounded-lg border-2 border-zinc-600 px-4 py-2 font-bold text-zinc-300 hover:border-zinc-400"
onClick={() => {
loggaUtPlattform();
setNamn(null);
}}
>
Logga ut
</button>
</div>
</Panel>
<>
<Panel rubrik="Plattformskonto">
<div className="flex items-center justify-between gap-3">
<p className="text-lg">
<span className="font-extrabold">{konto.namn}</span> · {ROLL_LABEL[konto.roll]} ·{" "}
{konto.organisation} synk och AI aktiva.
</p>
<button
className="whitespace-nowrap rounded-lg border-2 border-zinc-600 px-4 py-2 font-bold text-zinc-300 hover:border-zinc-400"
onClick={() => {
loggaUtPlattform();
setKonto(null);
}}
>
Logga ut
</button>
</div>
</Panel>
{konto.roll === "admin" && <AnvandarAdmin />}
</>
);
}
@@ -52,11 +69,11 @@ function PlattformInloggning() {
<>
<p className="mb-2 text-zinc-300">
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>
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => setLage("registrera")}>
Skapa konto
Skapa organisation
</StorKnapp>
<StorKnapp onClick={() => setLage("loggaIn")}>Logga in</StorKnapp>
</div>
@@ -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" && <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="Lösenord (minst 8 tecken)" varde={losenord} satt={setLosenord} losenord />
{fel && <p className="mb-3 font-bold text-red-400">{fel}</p>}
@@ -85,8 +107,15 @@ function PlattformInloggning() {
<StorKnapp variant="sekundar" onClick={() => setLage("stangd")}>
Avbryt
</StorKnapp>
<StorKnapp type="submit" disabled={!epost.trim() || losenord.length < 8 || (lage === "registrera" && !nyttNamn.trim())}>
{lage === "loggaIn" ? "Logga in" : "Skapa konto"}
<StorKnapp
type="submit"
disabled={
!epost.trim() ||
losenord.length < 8 ||
(lage === "registrera" && (!nyttNamn.trim() || !organisation.trim()))
}
>
{lage === "loggaIn" ? "Logga in" : "Skapa organisation"}
</StorKnapp>
</div>
</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,
// pågående, klara och starta nytt ärende.
export default function Arendelista() {