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:
@@ -67,6 +67,9 @@ jobs:
|
|||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npx playwright install --with-deps chromium
|
- run: npx playwright install --with-deps chromium
|
||||||
- run: npm run genomgang
|
- run: npm run genomgang
|
||||||
|
# Portalspärren kräver ett eget bygge (plattformen konfigurerad),
|
||||||
|
# så den kan inte ligga i genomgången.
|
||||||
|
- run: npm run portalsparr
|
||||||
|
|
||||||
# Plattformstjänsten mot en riktig Postgres.
|
# Plattformstjänsten mot en riktig Postgres.
|
||||||
#
|
#
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
// Portalspärren (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, annars är portalen uttryckligen
|
||||||
|
// märkt som demonstration.
|
||||||
|
//
|
||||||
|
// ---- Varför en egen körning och inte ett enhetstest ------------------
|
||||||
|
//
|
||||||
|
// Sviten kan bara läsa källkod och se att vakten är inkopplad. Släpper
|
||||||
|
// den ändå igenom — fel villkor, fel ordning, ett <Navigate> som aldrig
|
||||||
|
// hinner rendera — ser koden likadan ut. Och en spärr som inte spärrar
|
||||||
|
// är exakt det m-9 handlade om.
|
||||||
|
//
|
||||||
|
// Bygget görs därför här, med plattformen konfigurerad, och kontrollen
|
||||||
|
// tittar var man faktiskt hamnar. Adressen pekar på ett värdnamn som inte
|
||||||
|
// finns: spärren ska hålla utan att någon server svarar.
|
||||||
|
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
|
import { dirname, extname, join } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { chromium } from "playwright";
|
||||||
|
|
||||||
|
const ROT = dirname(dirname(fileURLToPath(import.meta.url)));
|
||||||
|
const DIST = join(ROT, "dist");
|
||||||
|
const PORT = 4271;
|
||||||
|
const BAS = `http://127.0.0.1:${PORT}`;
|
||||||
|
|
||||||
|
const VYER = [
|
||||||
|
"/alva/portal",
|
||||||
|
"/alva/portal/analys",
|
||||||
|
"/alva/portal/kunskapskallor",
|
||||||
|
"/alva/portal/integration",
|
||||||
|
"/alva/portal/fakturor",
|
||||||
|
];
|
||||||
|
|
||||||
|
// Nycklarna är plattformsklientens egna (src/felsokning/plattform.ts).
|
||||||
|
// Skulle de byta namn ska den här kontrollen falla, inte tyst sluta mäta:
|
||||||
|
// en session som läggs under fel nyckel ger samma utfall som ingen
|
||||||
|
// session, och då hade "spärrad" sett ut som ett godkänt resultat.
|
||||||
|
const TOKEN_NYCKEL = "gf-plattform-token";
|
||||||
|
const KONTO_NYCKEL = "gf-plattform-konto";
|
||||||
|
|
||||||
|
const TYPER = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".js": "text/javascript",
|
||||||
|
".css": "text/css",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!process.env.GENOMGANG_HOPPA_BYGG) {
|
||||||
|
execFileSync("npx", ["vite", "build"], {
|
||||||
|
cwd: ROT,
|
||||||
|
stdio: ["ignore", "ignore", "inherit"],
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
VITE_HASH_ROUTER: "1",
|
||||||
|
VITE_PLATTFORM_URL: "https://plattform.invalid",
|
||||||
|
VITE_SUPABASE_URL: "https://portalsparr.invalid",
|
||||||
|
VITE_SUPABASE_PUBLISHABLE_KEY: "portalsparr",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(join(DIST, "index.html"))) {
|
||||||
|
console.error(`Inget bygge i ${DIST}.`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = createServer((req, res) => {
|
||||||
|
const vag = decodeURIComponent(req.url.split("?")[0]);
|
||||||
|
const fil = join(DIST, vag === "/" ? "index.html" : vag);
|
||||||
|
const mal = existsSync(fil) && extname(fil) ? fil : join(DIST, "index.html");
|
||||||
|
res.writeHead(200, { "Content-Type": TYPER[extname(mal)] ?? "application/octet-stream" });
|
||||||
|
res.end(readFileSync(mal));
|
||||||
|
}).listen(PORT);
|
||||||
|
|
||||||
|
const webblasare = await chromium.launch({
|
||||||
|
executablePath: process.env.PLAYWRIGHT_CHROMIUM ?? undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
let fel = 0;
|
||||||
|
const kontroll = (namn, faktiskt, vantat) => {
|
||||||
|
if (faktiskt === vantat) console.log(`✓ ${namn}`);
|
||||||
|
else {
|
||||||
|
fel++;
|
||||||
|
console.log(`✗ ${namn}: fick '${faktiskt}', väntade '${vantat}'`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const hash = (sida) => new URL(sida.url()).hash;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// ---- Utan session -----------------------------------------------------
|
||||||
|
{
|
||||||
|
const ctx = await webblasare.newContext();
|
||||||
|
const s = await ctx.newPage();
|
||||||
|
for (const vy of VYER) {
|
||||||
|
await s.goto(`${BAS}/#${vy}`, { waitUntil: "networkidle" });
|
||||||
|
await s.waitForTimeout(300);
|
||||||
|
kontroll(`${vy} spärras`, hash(s), "#/alva/logga-in");
|
||||||
|
}
|
||||||
|
await ctx.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Med session ------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Sessionen läggs in som inloggningen skulle ha lagt in den. Spärren
|
||||||
|
// ska öppna — annars är den inte en spärr utan ett hinder.
|
||||||
|
{
|
||||||
|
const ctx = await webblasare.newContext();
|
||||||
|
const s = await ctx.newPage();
|
||||||
|
await s.goto(`${BAS}/#/alva`, { waitUntil: "networkidle" });
|
||||||
|
await s.evaluate(
|
||||||
|
([tn, kn]) => {
|
||||||
|
localStorage.setItem(tn, "provtoken");
|
||||||
|
localStorage.setItem(kn, JSON.stringify({ namn: "Anna", roll: "admin", organisation: "Verkstad A" }));
|
||||||
|
},
|
||||||
|
[TOKEN_NYCKEL, KONTO_NYCKEL],
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const vy of VYER) {
|
||||||
|
await s.goto(`${BAS}/#${vy}`, { waitUntil: "networkidle" });
|
||||||
|
await s.waitForTimeout(300);
|
||||||
|
kontroll(`${vy} öppnas med session`, hash(s), `#${vy}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utloggningen ska både finnas och verkligen avsluta sessionen. En
|
||||||
|
// knapp som navigerar utan att rensa token vore samma sken igen.
|
||||||
|
await s.goto(`${BAS}/#/alva/portal`, { waitUntil: "networkidle" });
|
||||||
|
await s.waitForTimeout(300);
|
||||||
|
const knapp = s.getByRole("button", { name: /sign out/i });
|
||||||
|
kontroll("utloggning finns i portalen", await knapp.count(), 1);
|
||||||
|
await knapp.click();
|
||||||
|
await s.waitForTimeout(500);
|
||||||
|
kontroll("utloggning leder till inloggningen", hash(s), "#/alva/logga-in");
|
||||||
|
kontroll("token är borta", await s.evaluate((tn) => localStorage.getItem(tn), TOKEN_NYCKEL), null);
|
||||||
|
|
||||||
|
await s.goto(`${BAS}/#/alva/portal`, { waitUntil: "networkidle" });
|
||||||
|
await s.waitForTimeout(300);
|
||||||
|
kontroll("portalen är stängd igen efter utloggning", hash(s), "#/alva/logga-in");
|
||||||
|
await ctx.close();
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await webblasare.close();
|
||||||
|
server.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(fel === 0 ? "\nportalspärren håller" : `\n${fel} fel`);
|
||||||
|
process.exit(fel === 0 ? 0 : 1);
|
||||||
@@ -12,7 +12,8 @@
|
|||||||
"typkontroll": "tsc --noEmit -p tsconfig.app.json",
|
"typkontroll": "tsc --noEmit -p tsconfig.app.json",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"genomgang": "node e2e/genomgang.mjs"
|
"genomgang": "node e2e/genomgang.mjs",
|
||||||
|
"portalsparr": "node e2e/portalsparr.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource/ibm-plex-mono": "^5.3.0",
|
"@fontsource/ibm-plex-mono": "^5.3.0",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import AlvaKunskapskallor from "./pages/alva/Kunskapskallor";
|
|||||||
import AlvaAnalys from "./pages/alva/Analys";
|
import AlvaAnalys from "./pages/alva/Analys";
|
||||||
import AlvaFakturor from "./pages/alva/Fakturor";
|
import AlvaFakturor from "./pages/alva/Fakturor";
|
||||||
import AlvaIntegration from "./pages/alva/Integration";
|
import AlvaIntegration from "./pages/alva/Integration";
|
||||||
|
import { Portalvakt } from "./pages/alva/Portalvakt";
|
||||||
import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner";
|
import { PaymentTestModeBanner } from "@/components/PaymentTestModeBanner";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
@@ -65,11 +66,14 @@ function AppContent() {
|
|||||||
<Route path="/alva" element={<AlvaStart />} />
|
<Route path="/alva" element={<AlvaStart />} />
|
||||||
<Route path="/alva/ansokan" element={<AlvaAnsokan />} />
|
<Route path="/alva/ansokan" element={<AlvaAnsokan />} />
|
||||||
<Route path="/alva/logga-in" element={<AlvaLoggaIn />} />
|
<Route path="/alva/logga-in" element={<AlvaLoggaIn />} />
|
||||||
<Route path="/alva/portal" element={<AlvaPortal />} />
|
{/* Portalen är stängd utan giltig plattformssession — se
|
||||||
<Route path="/alva/portal/kunskapskallor" element={<AlvaKunskapskallor />} />
|
Portalvakt. Utan konfigurerad plattform finns ingen session
|
||||||
<Route path="/alva/portal/analys" element={<AlvaAnalys />} />
|
att kräva, och vyerna är då märkta som demonstration. */}
|
||||||
<Route path="/alva/portal/integration" element={<AlvaIntegration />} />
|
<Route path="/alva/portal" element={<Portalvakt><AlvaPortal /></Portalvakt>} />
|
||||||
<Route path="/alva/portal/fakturor" element={<AlvaFakturor />} />
|
<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" element={<Arendelista />} />
|
||||||
<Route path="/felsokning/nytt" element={<NyttArende />} />
|
<Route path="/felsokning/nytt" element={<NyttArende />} />
|
||||||
<Route path="/felsokning/arende/:id" element={<ArendeSida />} />
|
<Route path="/felsokning/arende/:id" element={<ArendeSida />} />
|
||||||
|
|||||||
@@ -350,3 +350,52 @@ describe("ingen betalleverantör laddas", () => {
|
|||||||
expect(Object.keys(m)).not.toContain("betala");
|
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;
|
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
|
// Märkesspecifika kopplingar. Uppgifterna lagras krypterat på servern
|
||||||
// och returneras alltid maskerade — klienten ser aldrig hemligheterna.
|
// och returneras alltid maskerade — klienten ser aldrig hemligheterna.
|
||||||
export interface LeverantorsFalt {
|
export interface LeverantorsFalt {
|
||||||
|
|||||||
@@ -10,12 +10,13 @@
|
|||||||
// underlag — antalet aktiva användare är inte en uppskattning utan de
|
// underlag — antalet aktiva användare är inte en uppskattning utan de
|
||||||
// konton som faktiskt kan logga in.
|
// konton som faktiskt kan logga in.
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
PRISLISTA,
|
PRISLISTA,
|
||||||
fakturera,
|
fakturera,
|
||||||
formateraBelopp,
|
formateraBelopp,
|
||||||
} from "../../../../services/gemensam/fakturering.mjs";
|
} from "../../../../services/gemensam/fakturering.mjs";
|
||||||
|
import { hamtaFakturor, plattformAktiv } from "@/felsokning/plattform";
|
||||||
import { Block, Demonstration, Etikett, FARG, Rubrik, Statusmärke, Tabell } from "@/alva/komponenter";
|
import { Block, Demonstration, Etikett, FARG, Rubrik, Statusmärke, Tabell } from "@/alva/komponenter";
|
||||||
import { Ram } from "./Ram";
|
import { Ram } from "./Ram";
|
||||||
|
|
||||||
@@ -51,7 +52,9 @@ const STATUSAV: Record<string, "passed" | "pending" | "not_applicable"> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function Fakturor() {
|
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({
|
fakturera({
|
||||||
nummer: 1,
|
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 (
|
return (
|
||||||
<Ram portal>
|
<Ram portal>
|
||||||
<div className="mx-auto max-w-[1040px] px-6 py-12">
|
<div className="mx-auto max-w-[1040px] px-6 py-12">
|
||||||
@@ -77,17 +130,19 @@ export default function Fakturor() {
|
|||||||
actually holds.
|
actually holds.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Demonstration>
|
{!skarpt && (
|
||||||
The organization below is an example, and the price list is a placeholder to be set per market.
|
<Demonstration>
|
||||||
The invoice itself is not: it is computed by the same module the server uses, from the inputs
|
The organization below is an example, and the price list is a placeholder to be set per market.
|
||||||
shown.
|
The invoice itself is not: it is computed by the same module the server uses, from the inputs
|
||||||
</Demonstration>
|
shown. Connected to a platform instance this page shows the organization’s own invoices.
|
||||||
|
</Demonstration>
|
||||||
|
)}
|
||||||
|
|
||||||
<Block rubrik="Invoice" beteckning={faktura.beteckning}>
|
<Block rubrik="Invoice" beteckning={faktura.beteckning}>
|
||||||
<div className="grid gap-6 sm:grid-cols-2">
|
<div className="grid gap-6 sm:grid-cols-2">
|
||||||
<dl className="text-[13px] leading-[22px]">
|
<dl className="text-[13px] leading-[22px]">
|
||||||
{[
|
{[
|
||||||
["Organization", faktura.organisation],
|
["Organization", faktura.organisation ?? "This organization"],
|
||||||
["Period", `${faktura.period.fran} – ${faktura.period.till}`],
|
["Period", `${faktura.period.fran} – ${faktura.period.till}`],
|
||||||
["Issued", faktura.utfardad],
|
["Issued", faktura.utfardad],
|
||||||
["Due", faktura.forfaller],
|
["Due", faktura.forfaller],
|
||||||
|
|||||||
@@ -3,31 +3,67 @@
|
|||||||
// Användarnamn, lösenord, organisation. Ingen social inloggning, ingen
|
// Användarnamn, lösenord, organisation. Ingen social inloggning, ingen
|
||||||
// "kom ihåg mig", ingen illustration. Ett industrisystem frågar vem du
|
// "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.
|
// ä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 { type FormEvent, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { loggaInPlattform, plattformAktiv } from "@/felsokning/plattform";
|
||||||
import { Demonstration, Block, Etikett, FARG, Knapp, Rubrik } from "@/alva/komponenter";
|
import { Demonstration, Block, Etikett, FARG, Knapp, Rubrik } from "@/alva/komponenter";
|
||||||
import { Ram } from "./Ram";
|
import { Ram } from "./Ram";
|
||||||
|
|
||||||
const FALT = [
|
const FALT = [
|
||||||
{ namn: "organisation", etikett: "Organization", typ: "text" },
|
{ namn: "epost", etikett: "Email", typ: "email", komplettering: "username" },
|
||||||
{ namn: "anvandarnamn", etikett: "Username", typ: "text" },
|
{ namn: "losenord", etikett: "Password", typ: "password", komplettering: "current-password" },
|
||||||
{ namn: "losenord", etikett: "Password", typ: "password" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function LoggaIn() {
|
export default function LoggaIn() {
|
||||||
const navigera = useNavigate();
|
const navigera = useNavigate();
|
||||||
const [fel, setFel] = useState("");
|
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();
|
e.preventDefault();
|
||||||
const data = new FormData(e.currentTarget);
|
const data = new FormData(e.currentTarget);
|
||||||
if (!data.get("organisation") || !data.get("anvandarnamn")) {
|
const epost = String(data.get("epost") ?? "").trim();
|
||||||
// Statusspråket gäller även fel: ett konstaterande, inte en ursäkt.
|
const losenord = String(data.get("losenord") ?? "");
|
||||||
setFel("Authentication incomplete. Organization and username required.");
|
|
||||||
|
if (!skarpt) {
|
||||||
|
// Demonstrationen kräver ingenting, och påstår heller ingenting.
|
||||||
|
navigera("/alva/portal");
|
||||||
return;
|
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 (
|
return (
|
||||||
@@ -37,11 +73,14 @@ export default function LoggaIn() {
|
|||||||
<div className="mt-2 mb-8">
|
<div className="mt-2 mb-8">
|
||||||
<Rubrik niva={1}>Login</Rubrik>
|
<Rubrik niva={1}>Login</Rubrik>
|
||||||
</div>
|
</div>
|
||||||
<Demonstration>
|
{!skarpt && (
|
||||||
This login authenticates nothing. Type anything into Organization and Username and press
|
<Demonstration>
|
||||||
Sign in — the password is not checked, and no account is needed. The platform's real
|
This login authenticates nothing. Type anything into Email and Password and press Sign in
|
||||||
authentication is not reachable from this demonstration.
|
— no account is needed, and the portal behind it presents fixed example data. Connected to
|
||||||
</Demonstration>
|
a platform instance this page authenticates against it, and the portal is closed without a
|
||||||
|
valid session.
|
||||||
|
</Demonstration>
|
||||||
|
)}
|
||||||
|
|
||||||
<form onSubmit={skicka}>
|
<form onSubmit={skicka}>
|
||||||
<Block>
|
<Block>
|
||||||
@@ -58,7 +97,7 @@ export default function LoggaIn() {
|
|||||||
id={f.namn}
|
id={f.namn}
|
||||||
name={f.namn}
|
name={f.namn}
|
||||||
type={f.typ}
|
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]"
|
className="mt-2 w-full border px-4 py-2 text-[14px]"
|
||||||
style={{ borderColor: FARG.lightSteel, background: FARG.white, color: FARG.graphite }}
|
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">
|
<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 }}>
|
<a href="/alva/ansokan" className="text-[12px] uppercase tracking-[0.08em]" style={{ color: FARG.steel }}>
|
||||||
Forgot password
|
Forgot password
|
||||||
</a>
|
</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}</>;
|
||||||
|
}
|
||||||
@@ -5,7 +5,8 @@
|
|||||||
// något i ett industrisystem vet vad det heter.
|
// något i ett industrisystem vet vad det heter.
|
||||||
|
|
||||||
import type { ReactNode } from "react";
|
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 { ALVA, PLATTFORMSVERSION } from "@/alva/system";
|
||||||
import { FARG } from "@/alva/komponenter";
|
import { FARG } from "@/alva/komponenter";
|
||||||
|
|
||||||
@@ -26,7 +27,12 @@ const PORTAL = [
|
|||||||
|
|
||||||
export function Ram({ children, portal = false }: { children: ReactNode; portal?: boolean }) {
|
export function Ram({ children, portal = false }: { children: ReactNode; portal?: boolean }) {
|
||||||
const plats = useLocation();
|
const plats = useLocation();
|
||||||
|
const navigera = useNavigate();
|
||||||
const lankar = portal ? PORTAL : PUBLIKT;
|
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 (
|
return (
|
||||||
// `alva-yta` bär typografin ur ALVA-SPEC-001 och håller den skild
|
// `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>
|
</div>
|
||||||
</header>
|
</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>
|
<main>{children}</main>
|
||||||
|
|
||||||
<footer className="border-t" style={{ borderColor: FARG.lightSteel, background: FARG.white }}>
|
<footer className="border-t" style={{ borderColor: FARG.lightSteel, background: FARG.white }}>
|
||||||
|
|||||||
@@ -419,7 +419,7 @@ changed, so a re-audit can verify rather than take it on trust.
|
|||||||
| **M-8** | ✅ Closed | Per-event outcomes replace the count: rejected events are returned with index, type and reason, and a partial import answers `207`, not `200`. Ids derive from a SHA-256 of case, source, index and content, which removes the millisecond collision and makes re-uploading the same protocol idempotent rather than duplicating. The whole import runs in one transaction. |
|
| **M-8** | ✅ Closed | Per-event outcomes replace the count: rejected events are returned with index, type and reason, and a partial import answers `207`, not `200`. Ids derive from a SHA-256 of case, source, index and content, which removes the millisecond collision and makes re-uploading the same protocol idempotent rather than duplicating. The whole import runs in one transaction. |
|
||||||
| **M-9** | ✅ Closed | A generated secret is returned once at creation with an explicit note that it will not be shown again. A caller-supplied secret is still never echoed. |
|
| **M-9** | ✅ Closed | A generated secret is returned once at creation with an explicit note that it will not be shown again. A caller-supplied secret is still never echoed. |
|
||||||
| **m-8** | ✅ Closed | Profile path lookups are restricted to own enumerable properties, so `__proto__` and `constructor.prototype` are unreachable from an external profile. |
|
| **m-8** | ✅ Closed | Profile path lookups are restricted to own enumerable properties, so `__proto__` and `constructor.prototype` are unreachable from an external profile. |
|
||||||
| **m-9** | ◐ Open | The portal remains a presentation shell with an authenticating-looking login. Deliberately deferred: the honest fix is to gate the route behind the real platform session, which is a product decision about what the portal is for. |
|
| **m-9** | ✅ Closed | Every portal route now passes through `Portalvakt`. With a platform configured (`VITE_PLATTFORM_URL`), the login authenticates against `/api/auth/logga-in` and the portal is unreachable without a valid session; the invoice view reads the organization's own invoices from `GET /api/fakturor`, and a failed fetch shows the error rather than falling back to the example — data that looks real but is not is worse than a visible fault. Without a platform there is no session to demand, and the demonstration notice appears exactly in that case. The guarantee is not a source-code assertion: `e2e/portalsparr.mjs` builds with the platform configured and checks where a visitor actually lands, including that signing out clears the token and re-closes the portal. Mutation-proved — unwrapping one route makes it fail. |
|
||||||
| **m-10** | ✅ Closed | `observationer()` suppresses a `matvarde` whose description and value already appear as a check result, so a measurement taken through the methodology is reported once. |
|
| **m-10** | ✅ Closed | `observationer()` suppresses a `matvarde` whose description and value already appear as a check result, so a measurement taken through the methodology is reported once. |
|
||||||
|
|
||||||
### The defect M-7 uncovered
|
### The defect M-7 uncovered
|
||||||
@@ -504,7 +504,6 @@ walkthrough would quietly start testing a different application.
|
|||||||
| Rev 1 · C-4 | Processor agreement and transfer impact assessment | Documents to be written and signed, not code. |
|
| Rev 1 · C-4 | Processor agreement and transfer impact assessment | Documents to be written and signed, not code. |
|
||||||
| Rev 1 · m-4 | Retiring the Supabase orchestrator | A deployment decision belonging to the product owner. |
|
| Rev 1 · m-4 | Retiring the Supabase orchestrator | A deployment decision belonging to the product owner. |
|
||||||
| Rev 1 · m-6 | Manual accessibility review | Automated tooling finds malformation, not usability. |
|
| Rev 1 · m-6 | Manual accessibility review | Automated tooling finds malformation, not usability. |
|
||||||
| Rev 2 · m-9 | The portal mock | A product decision about what the portal is for. |
|
|
||||||
|
|
||||||
### Two backend defects found by running the suite that was never run
|
### Two backend defects found by running the suite that was never run
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user