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:
@@ -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", () => {
|
||||
|
||||
+63
-11
@@ -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<string> {
|
||||
async function authAnrop(vag: string, kropp: object): Promise<PlattformKonto> {
|
||||
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<string> {
|
||||
});
|
||||
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<string> {
|
||||
export function loggaInPlattform(epost: string, losenord: string): Promise<PlattformKonto> {
|
||||
return authAnrop("/api/auth/logga-in", { epost, losenord });
|
||||
}
|
||||
|
||||
export function registreraPlattform(epost: string, losenord: string, namn: string): Promise<string> {
|
||||
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<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å
|
||||
|
||||
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user