Portalen är stängd på riktigt — m-9 stängd

Anmärkningen var att en inloggning som SER ut att autentisera ger
portalen bakom den en auktoritet den inte har. Rättelsen är inte att ta
bort skylten utan att göra spärren verklig där det finns något att
spärra mot.

Med plattformen konfigurerad autentiserar inloggningen mot
/api/auth/logga-in, varje portalväg går genom Portalvakt, och
fakturavyn läser organisationens egna fakturor ur GET /api/fakturor.
Misslyckas hämtningen visas felet — inte exemplet: data som ser äkta ut
men inte är det är värre än ett synligt fel. Utloggningen finns i
portalhuvudet och rensar sessionen på riktigt.

Utan konfigurerad plattform finns ingen session att kräva, och
demonstrationsmärkningen visas exakt i det läget.

---- Varför garantin inte är ett källkodstest ------------------------

Sviten kan bara läsa koden och se att vakten är inkopplad. Släpper den
igenom ändå — fel villkor, fel ordning, ett <Navigate> som aldrig hinner
rendera — ser koden likadan ut. Och en spärr som inte spärrar är precis
det m-9 handlade om.

e2e/portalsparr.mjs bygger därför med plattformen konfigurerad och
tittar var besökaren faktiskt hamnar: fem vägar spärrade utan session,
fem öppna med, utloggning som rensar token och stänger portalen igen.
Mutationstestad — att plocka bort vakten från en enda route fäller den.

Bevisad: 374 enhetstester, typkontroll, genomgången 4/4 ärenden,
portalspärren 14/14.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012EQg3rJsrQ1ZNTvkzmQAtt
This commit is contained in:
Claude
2026-08-06 12:08:18 +00:00
parent d1b361573e
commit 3b83fc94e9
11 changed files with 434 additions and 32 deletions
+9 -5
View File
@@ -29,6 +29,7 @@ import AlvaKunskapskallor from "./pages/alva/Kunskapskallor";
import AlvaAnalys from "./pages/alva/Analys";
import AlvaFakturor from "./pages/alva/Fakturor";
import AlvaIntegration from "./pages/alva/Integration";
import { Portalvakt } from "./pages/alva/Portalvakt";
import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner";
const queryClient = new QueryClient();
@@ -65,11 +66,14 @@ function AppContent() {
<Route path="/alva" element={<AlvaStart />} />
<Route path="/alva/ansokan" element={<AlvaAnsokan />} />
<Route path="/alva/logga-in" element={<AlvaLoggaIn />} />
<Route path="/alva/portal" element={<AlvaPortal />} />
<Route path="/alva/portal/kunskapskallor" element={<AlvaKunskapskallor />} />
<Route path="/alva/portal/analys" element={<AlvaAnalys />} />
<Route path="/alva/portal/integration" element={<AlvaIntegration />} />
<Route path="/alva/portal/fakturor" element={<AlvaFakturor />} />
{/* Portalen är stängd utan giltig plattformssession — se
Portalvakt. Utan konfigurerad plattform finns ingen session
att kräva, och vyerna är då märkta som demonstration. */}
<Route path="/alva/portal" element={<Portalvakt><AlvaPortal /></Portalvakt>} />
<Route path="/alva/portal/kunskapskallor" element={<Portalvakt><AlvaKunskapskallor /></Portalvakt>} />
<Route path="/alva/portal/analys" element={<Portalvakt><AlvaAnalys /></Portalvakt>} />
<Route path="/alva/portal/integration" element={<Portalvakt><AlvaIntegration /></Portalvakt>} />
<Route path="/alva/portal/fakturor" element={<Portalvakt><AlvaFakturor /></Portalvakt>} />
<Route path="/felsokning" element={<Arendelista />} />
<Route path="/felsokning/nytt" element={<NyttArende />} />
<Route path="/felsokning/arende/:id" element={<ArendeSida />} />
@@ -350,3 +350,52 @@ describe("ingen betalleverantör laddas", () => {
expect(Object.keys(m)).not.toContain("betala");
});
});
// ---- Portalens spärr (revision 2, m-9) ---------------------------------
//
// Anmärkningen var att en inloggning som SER ut att autentisera ger
// portalen bakom den en auktoritet den inte har. Rättelsen kan gå sönder
// på två motsatta sätt, och bägge prövas här: spärren tas bort igen, eller
// demonstrationsskylten tas bort medan spärren inte spärrar något.
describe("portalen är stängd, eller uttryckligen en demonstration", () => {
const app = readFileSync("src/App.tsx", "utf8");
const vakt = readFileSync("src/pages/alva/Portalvakt.tsx", "utf8");
const loggaIn = readFileSync("src/pages/alva/LoggaIn.tsx", "utf8");
it("varje portalväg går genom vakten", () => {
const vagar = [...app.matchAll(/<Route path="(\/alva\/portal[^"]*)" element=\{([^}]*)\}/g)];
expect(vagar.length).toBeGreaterThanOrEqual(5);
for (const [, vag, element] of vagar) {
expect(element, vag).toContain("Portalvakt");
}
});
it("vakten kräver en verklig session när plattformen är konfigurerad", () => {
expect(vakt).toContain("plattformAktiv()");
expect(vakt).toContain("plattformKonto()");
expect(vakt).toContain("Navigate");
});
it("inloggningen autentiserar på riktigt när det finns något att autentisera mot", () => {
expect(loggaIn).toContain("loggaInPlattform");
// Och skylten står kvar exakt när den ska: bara i det läge där
// inloggningen inte prövar något.
expect(loggaIn).toMatch(/!skarpt && \(\s*<Demonstration>/);
});
it("fakturavyn visar organisationens egna fakturor mot en plattform", () => {
const fakturor = readFileSync("src/pages/alva/Fakturor.tsx", "utf8");
expect(fakturor).toContain("hamtaFakturor");
// Ett misslyckat anrop får inte tyst falla tillbaka på exemplet:
// data som ser äkta ut men inte är det är värre än ett synligt fel.
expect(fakturor).toContain("hamtningsfel");
expect(fakturor).toMatch(/skarpt \? hamtade\?\.\[0\] : exempel/);
});
it("utloggning visas bara när det finns en session att avsluta", () => {
const ram = readFileSync("src/pages/alva/Ram.tsx", "utf8");
expect(ram).toContain("loggaUtPlattform");
expect(ram).toMatch(/konto = portal \? plattformKonto\(\) : null/);
expect(ram).toMatch(/\{konto && \(/);
});
});
@@ -195,6 +195,44 @@ export async function hamtaFelorsaksstatistik(): Promise<{ orsak: string; antal:
return ((await res.json()) as { orsaker: { orsak: string; antal: number }[] }).orsaker;
}
// Organisationens fakturor (ALVA-PROC-0001). Läsning, aldrig mer:
// utfärdande och betalning hör till utfärdaren och har en egen nyckel som
// aldrig finns i en webbläsare.
//
// `status` är härledd på servern ur fakturahändelserna, inte lagrad —
// en utfärdad faktura ändras aldrig.
export interface Fakturarad {
benamning: string;
underlag: string;
antal: number;
enhet: string;
apris: number;
belopp: number;
}
export interface Faktura {
id: string;
beteckning: string;
utfardad: string;
forfaller: string;
valuta: string;
totalt: number;
netto: number;
moms: number;
momssats: number;
period: { fran: string; till: string };
krediterar: string | null;
status: "utfardad" | "betald" | "krediterad";
betalningssatt: string;
rader: Fakturarad[];
}
export async function hamtaFakturor(): Promise<Faktura[]> {
const res = await plattformFetch("/api/fakturor");
if (!res.ok) throw new Error(`Fel ${res.status}`);
return ((await res.json()) as { fakturor: Faktura[] }).fakturor;
}
// Märkesspecifika kopplingar. Uppgifterna lagras krypterat på servern
// och returneras alltid maskerade — klienten ser aldrig hemligheterna.
export interface LeverantorsFalt {
+63 -8
View File
@@ -10,12 +10,13 @@
// underlag — antalet aktiva användare är inte en uppskattning utan de
// konton som faktiskt kan logga in.
import { useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import {
PRISLISTA,
fakturera,
formateraBelopp,
} from "../../../../services/gemensam/fakturering.mjs";
import { hamtaFakturor, plattformAktiv } from "@/felsokning/plattform";
import { Block, Demonstration, Etikett, FARG, Rubrik, Statusmärke, Tabell } from "@/alva/komponenter";
import { Ram } from "./Ram";
@@ -51,7 +52,9 @@ const STATUSAV: Record<string, "passed" | "pending" | "not_applicable"> = {
};
export default function Fakturor() {
const faktura = useMemo(
// Exemplet räknas alltid fram, av samma modul som servern använder. Det
// är vad som visas när ingen plattform är konfigurerad.
const exempel = useMemo(
() =>
fakturera({
nummer: 1,
@@ -63,6 +66,56 @@ export default function Fakturor() {
[],
);
// Mot en konfigurerad plattform visas organisationens verkliga
// fakturor. Misslyckas hämtningen visas felet — inte exemplet: en
// tystnad som ser ut som data är värre än ett fel som syns.
const [hamtade, setHamtade] = useState<Faktura[] | null>(null);
const [hamtningsfel, setHamtningsfel] = useState("");
const skarpt = plattformAktiv();
useEffect(() => {
if (!skarpt) return;
let avbruten = false;
hamtaFakturor()
.then((f) => {
if (!avbruten) setHamtade(f as unknown as Faktura[]);
})
.catch((orsak) => {
if (!avbruten) setHamtningsfel(orsak instanceof Error ? orsak.message : String(orsak));
});
return () => {
avbruten = true;
};
}, [skarpt]);
// Senast utfärdad först — servern sorterar redan så.
const faktura = skarpt ? hamtade?.[0] : exempel;
// `!faktura` och inte `skarpt && !faktura`: exemplet är alltid
// definierat, så det här smalnar av typen i stället för att bara
// beskriva samma villkor en gång till.
if (!faktura) {
return (
<Ram portal>
<div className="mx-auto max-w-[1040px] px-6 py-12">
<Etikett>Commercial</Etikett>
<div className="mt-2 mb-8">
<Rubrik niva={1}>Invoices</Rubrik>
</div>
<Block rubrik="Status" beteckning="ALVA-PROC-0001">
<p className="text-[13px] leading-[20px]" style={{ color: FARG.steel }}>
{hamtningsfel
? `Invoices could not be retrieved: ${hamtningsfel}`
: hamtade
? "No invoice has been issued for this organization."
: "Retrieving invoices."}
</p>
</Block>
</div>
</Ram>
);
}
return (
<Ram portal>
<div className="mx-auto max-w-[1040px] px-6 py-12">
@@ -77,17 +130,19 @@ export default function Fakturor() {
actually holds.
</p>
<Demonstration>
The organization below is an example, and the price list is a placeholder to be set per market.
The invoice itself is not: it is computed by the same module the server uses, from the inputs
shown.
</Demonstration>
{!skarpt && (
<Demonstration>
The organization below is an example, and the price list is a placeholder to be set per market.
The invoice itself is not: it is computed by the same module the server uses, from the inputs
shown. Connected to a platform instance this page shows the organization&rsquo;s own invoices.
</Demonstration>
)}
<Block rubrik="Invoice" beteckning={faktura.beteckning}>
<div className="grid gap-6 sm:grid-cols-2">
<dl className="text-[13px] leading-[22px]">
{[
["Organization", faktura.organisation],
["Organization", faktura.organisation ?? "This organization"],
["Period", `${faktura.period.fran} ${faktura.period.till}`],
["Issued", faktura.utfardad],
["Due", faktura.forfaller],
+56 -15
View File
@@ -3,31 +3,67 @@
// Användarnamn, lösenord, organisation. Ingen social inloggning, ingen
// "kom ihåg mig", ingen illustration. Ett industrisystem frågar vem du
// är och i vilken organisation — inte om du vill fortsätta med Google.
//
// ---- Två lägen, och skillnaden döljs aldrig --------------------------
//
// Är plattformen konfigurerad (VITE_PLATTFORM_URL) autentiserar den här
// sidan på riktigt mot /api/auth/logga-in, och portalen är stängd utan
// giltig session. Är den inte det är sidan en demonstration, och det står
// på den.
//
// Revisionens m-9 gällde just det: en inloggning som SÅG ut att
// autentisera gav portalen en auktoritet den inte hade. Rättelsen är inte
// att ta bort skylten utan att göra spärren verklig där det finns något
// att spärra mot.
import { type FormEvent, useState } from "react";
import { useNavigate } from "react-router-dom";
import { loggaInPlattform, plattformAktiv } from "@/felsokning/plattform";
import { Demonstration, Block, Etikett, FARG, Knapp, Rubrik } from "@/alva/komponenter";
import { Ram } from "./Ram";
const FALT = [
{ namn: "organisation", etikett: "Organization", typ: "text" },
{ namn: "anvandarnamn", etikett: "Username", typ: "text" },
{ namn: "losenord", etikett: "Password", typ: "password" },
{ namn: "epost", etikett: "Email", typ: "email", komplettering: "username" },
{ namn: "losenord", etikett: "Password", typ: "password", komplettering: "current-password" },
];
export default function LoggaIn() {
const navigera = useNavigate();
const [fel, setFel] = useState("");
const [arbetar, setArbetar] = useState(false);
const skarpt = plattformAktiv();
const skicka = (e: FormEvent<HTMLFormElement>) => {
const skicka = async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
if (!data.get("organisation") || !data.get("anvandarnamn")) {
// Statusspråket gäller även fel: ett konstaterande, inte en ursäkt.
setFel("Authentication incomplete. Organization and username required.");
const epost = String(data.get("epost") ?? "").trim();
const losenord = String(data.get("losenord") ?? "");
if (!skarpt) {
// Demonstrationen kräver ingenting, och påstår heller ingenting.
navigera("/alva/portal");
return;
}
navigera("/alva/portal");
if (!epost || !losenord) {
// Statusspråket gäller även fel: ett konstaterande, inte en ursäkt.
setFel("Authentication incomplete. Email and password required.");
return;
}
setArbetar(true);
setFel("");
try {
await loggaInPlattform(epost, losenord);
navigera("/alva/portal");
} catch (orsak) {
// Serverns egen text visas oförändrad. Den vet vad som hände —
// avstängt konto, spärrat efter för många försök, fel uppgifter —
// och en omskrivning här skulle bara göra beskedet vagare.
setFel(orsak instanceof Error ? orsak.message : "Authentication failed.");
} finally {
setArbetar(false);
}
};
return (
@@ -37,11 +73,14 @@ export default function LoggaIn() {
<div className="mt-2 mb-8">
<Rubrik niva={1}>Login</Rubrik>
</div>
<Demonstration>
This login authenticates nothing. Type anything into Organization and Username and press
Sign in the password is not checked, and no account is needed. The platform's real
authentication is not reachable from this demonstration.
</Demonstration>
{!skarpt && (
<Demonstration>
This login authenticates nothing. Type anything into Email and Password and press Sign in
no account is needed, and the portal behind it presents fixed example data. Connected to
a platform instance this page authenticates against it, and the portal is closed without a
valid session.
</Demonstration>
)}
<form onSubmit={skicka}>
<Block>
@@ -58,7 +97,7 @@ export default function LoggaIn() {
id={f.namn}
name={f.namn}
type={f.typ}
autoComplete={f.typ === "password" ? "current-password" : "username"}
autoComplete={f.komplettering}
className="mt-2 w-full border px-4 py-2 text-[14px]"
style={{ borderColor: FARG.lightSteel, background: FARG.white, color: FARG.graphite }}
/>
@@ -73,7 +112,9 @@ export default function LoggaIn() {
)}
<div className="flex items-center justify-between">
<Knapp type="submit">Sign in</Knapp>
<Knapp type="submit" disabled={arbetar}>
{arbetar ? "Signing in" : "Sign in"}
</Knapp>
<a href="/alva/ansokan" className="text-[12px] uppercase tracking-[0.08em]" style={{ color: FARG.steel }}>
Forgot password
</a>
@@ -0,0 +1,29 @@
// Portalens spärr (revision 2, m-9).
//
// Anmärkningen var att en inloggning som ser ut att autentisera ger
// portalen bakom den en auktoritet den inte har. Rättelsen är att spärren
// blir verklig där det finns något att spärra mot.
//
// Är plattformen konfigurerad krävs en giltig session; utan den skickas
// besökaren till inloggningen. Är den inte konfigurerad finns ingen
// session att kräva, och portalen är då uttryckligen en demonstration —
// märkt som en sådan på varje vy, inte förklädd till en spärr som ändå
// släpper igenom alla.
//
// Sessionen läses ur localStorage vid varje navigering i stället för att
// hållas i en context: en token som återkallats i en annan flik ska sluta
// gälla här också, och plattformFetch rensar den vid 401.
import type { ReactNode } from "react";
import { Navigate, useLocation } from "react-router-dom";
import { plattformAktiv, plattformKonto } from "@/felsokning/plattform";
export function Portalvakt({ children }: { children: ReactNode }) {
const plats = useLocation();
if (plattformAktiv() && !plattformKonto()) {
// `replace`: den spärrade vyn ska inte ligga kvar i historiken, så
// bakåtknappen efter inloggning inte leder tillbaka till spärren.
return <Navigate to="/alva/logga-in" replace state={{ fran: plats.pathname }} />;
}
return <>{children}</>;
}
+31 -1
View File
@@ -5,7 +5,8 @@
// något i ett industrisystem vet vad det heter.
import type { ReactNode } from "react";
import { Link, useLocation } from "react-router-dom";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { loggaUtPlattform, plattformKonto } from "@/felsokning/plattform";
import { ALVA, PLATTFORMSVERSION } from "@/alva/system";
import { FARG } from "@/alva/komponenter";
@@ -26,7 +27,12 @@ const PORTAL = [
export function Ram({ children, portal = false }: { children: ReactNode; portal?: boolean }) {
const plats = useLocation();
const navigera = useNavigate();
const lankar = portal ? PORTAL : PUBLIKT;
// Finns en riktig session ska den gå att avsluta. Utan konfigurerad
// plattform finns ingen, och då visas ingen utloggning heller — en
// knapp som inte loggar ut något är samma sorts sken som m-9 gällde.
const konto = portal ? plattformKonto() : null;
return (
// `alva-yta` bär typografin ur ALVA-SPEC-001 och håller den skild
@@ -68,6 +74,30 @@ export function Ram({ children, portal = false }: { children: ReactNode; portal?
</div>
</header>
{konto && (
<div className="border-b" style={{ borderColor: FARG.lightSteel, background: FARG.white }}>
<div
className="mx-auto flex max-w-[1040px] flex-wrap items-center justify-between gap-2 px-6 py-2 text-[11px] uppercase tracking-[0.08em]"
style={{ color: FARG.steel }}
>
<span>
{konto.organisation} · {konto.namn} · {konto.roll}
</span>
<button
type="button"
onClick={() => {
loggaUtPlattform();
navigera("/alva/logga-in", { replace: true });
}}
className="uppercase tracking-[0.08em] underline"
style={{ color: FARG.steel }}
>
Sign out
</button>
</div>
</div>
)}
<main>{children}</main>
<footer className="border-t" style={{ borderColor: FARG.lightSteel, background: FARG.white }}>