diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index baa948c..1009d00 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -67,6 +67,9 @@ jobs: - run: npm ci - run: npx playwright install --with-deps chromium - 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. # diff --git a/felsokning/app/e2e/portalsparr.mjs b/felsokning/app/e2e/portalsparr.mjs new file mode 100644 index 0000000..32e0240 --- /dev/null +++ b/felsokning/app/e2e/portalsparr.mjs @@ -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 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); diff --git a/felsokning/app/package.json b/felsokning/app/package.json index 065ac08..e9fe706 100644 --- a/felsokning/app/package.json +++ b/felsokning/app/package.json @@ -12,7 +12,8 @@ "typkontroll": "tsc --noEmit -p tsconfig.app.json", "test": "vitest run", "test:watch": "vitest", - "genomgang": "node e2e/genomgang.mjs" + "genomgang": "node e2e/genomgang.mjs", + "portalsparr": "node e2e/portalsparr.mjs" }, "dependencies": { "@fontsource/ibm-plex-mono": "^5.3.0", diff --git a/felsokning/app/src/App.tsx b/felsokning/app/src/App.tsx index 2552f7f..e3d12c7 100644 --- a/felsokning/app/src/App.tsx +++ b/felsokning/app/src/App.tsx @@ -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() { } /> } /> } /> - } /> - } /> - } /> - } /> - } /> + {/* 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. */} + } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/felsokning/app/src/felsokning/__tests__/alva-yta.test.ts b/felsokning/app/src/felsokning/__tests__/alva-yta.test.ts index 85eabc0..51fd78b 100644 --- a/felsokning/app/src/felsokning/__tests__/alva-yta.test.ts +++ b/felsokning/app/src/felsokning/__tests__/alva-yta.test.ts @@ -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(/ { + 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*/); + }); + + 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 && \(/); + }); +}); diff --git a/felsokning/app/src/felsokning/plattform.ts b/felsokning/app/src/felsokning/plattform.ts index bb89326..73342fe 100644 --- a/felsokning/app/src/felsokning/plattform.ts +++ b/felsokning/app/src/felsokning/plattform.ts @@ -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 { + 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 { diff --git a/felsokning/app/src/pages/alva/Fakturor.tsx b/felsokning/app/src/pages/alva/Fakturor.tsx index 9964ded..b4e8f6c 100644 --- a/felsokning/app/src/pages/alva/Fakturor.tsx +++ b/felsokning/app/src/pages/alva/Fakturor.tsx @@ -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 = { }; 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(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 ( + +
+ Commercial +
+ Invoices +
+ +

+ {hamtningsfel + ? `Invoices could not be retrieved: ${hamtningsfel}` + : hamtade + ? "No invoice has been issued for this organization." + : "Retrieving invoices."} +

+
+
+
+ ); + } + return (
@@ -77,17 +130,19 @@ export default function Fakturor() { actually holds.

- - 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. - + {!skarpt && ( + + 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’s own invoices. + + )}
{[ - ["Organization", faktura.organisation], + ["Organization", faktura.organisation ?? "This organization"], ["Period", `${faktura.period.fran} – ${faktura.period.till}`], ["Issued", faktura.utfardad], ["Due", faktura.forfaller], diff --git a/felsokning/app/src/pages/alva/LoggaIn.tsx b/felsokning/app/src/pages/alva/LoggaIn.tsx index d4efe18..7d25f15 100644 --- a/felsokning/app/src/pages/alva/LoggaIn.tsx +++ b/felsokning/app/src/pages/alva/LoggaIn.tsx @@ -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) => { + const skicka = async (e: FormEvent) => { 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() {
Login
- - 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. - + {!skarpt && ( + + 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. + + )}
@@ -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() { )}
- Sign in + + {arbetar ? "Signing in" : "Sign in"} + Forgot password diff --git a/felsokning/app/src/pages/alva/Portalvakt.tsx b/felsokning/app/src/pages/alva/Portalvakt.tsx new file mode 100644 index 0000000..ae20176 --- /dev/null +++ b/felsokning/app/src/pages/alva/Portalvakt.tsx @@ -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 ; + } + return <>{children}; +} diff --git a/felsokning/app/src/pages/alva/Ram.tsx b/felsokning/app/src/pages/alva/Ram.tsx index 5489128..2f86c88 100644 --- a/felsokning/app/src/pages/alva/Ram.tsx +++ b/felsokning/app/src/pages/alva/Ram.tsx @@ -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?
+ {konto && ( +
+
+ + {konto.organisation} · {konto.namn} · {konto.roll} + + +
+
+ )} +
{children}