Märkesspecifika kopplingar — kunden lägger in sina egna credentials

Verkstaden har redan sina avtal: Volvo-verkstaden har VIDA, VAG-verkstaden
har erWin, den fria verkstaden har en fordonsdataleverantör. Kopplingarna
konfigureras därför av kunden själv under Inställningar, med sina egna
uppgifter — vi tillhandahåller ramen, inte kontot.

Uppgifterna når aldrig webbläsaren. De krypteras med AES-256-GCM
(INTEGRATION_NYCKEL) innan de skrivs till tabellen integrationer, och
API:t returnerar hemliga fält maskerade. Alla uppslag görs av servern.
Saknas krypteringsnyckeln sparas ingenting alls — 503 och en förklaring i
gränssnittet i stället för klartext i databasen. Endast
systemadministratören hanterar uppgifterna; kopplingarna är
organisationsknutna som all annan ärendedata.

Leverantörer är data, inte kod: URL-mall, autentiseringstyp
(bearer/header/basic/query) och svarsmappning beskrivs i
services/plattform/integrationer.json, utbytbar via ConfigMap
(INTEGRATIONER_FIL). Nya märken läggs till utan att appen byggs om.

Varje uppslag skriver senast_testad och senaste_status på kopplingen, så
ett utgånget abonnemang syns i inställningarna i stället för att ge tysta
tomma svar.

Två latenta krascher hittade av klicktestet och åtgärdade: TextFalt och
UNDANTAGSORSAKER användes utan import. vite build typkontrollerar inte,
så de passerade bygget — därav nya npm-skriptet typkontroll, nu del av
verifieringen.

Verifierat: 80 vitest-tester, typkontroll, eslint, OpenAPI-validering,
integrationstest mot riktig Postgres (rollstyrning, kryptering i vila,
maskering, organisationsisolering, fail closed, borttagning) och
klickgenomgång mot en körande plattform.

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-03 12:50:44 +00:00
parent 64b2124cd8
commit 3a0be7261e
18 changed files with 1112 additions and 21 deletions
+1
View File
@@ -63,6 +63,7 @@ import {
reproduceringsText,
sparbarhetspaket,
underlagFinns,
UNDANTAGSORSAKER,
} from "@/felsokning/ecm";
import { FelsokningSkal, NivaBadge, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
import { lasVideo, skalaNerFoto, tidDatum, tidKlockslag } from "@/felsokning/format";
+152 -2
View File
@@ -12,8 +12,17 @@ import {
sparaInstallningar,
type Installningar as Inst,
} from "@/felsokning/installningar";
import { plattformAktiv, plattformKonto } from "@/felsokning/plattform";
import { FelsokningSkal, Panel, StorKnapp } from "@/felsokning/ui";
import {
hamtaIntegrationer,
hamtaLeverantorer,
plattformAktiv,
plattformKonto,
sparaIntegration,
taBortIntegration,
type Integration,
type Leverantor,
} from "@/felsokning/plattform";
import { FelsokningSkal, Panel, StorKnapp, TextFalt } from "@/felsokning/ui";
function Vallista({
alla,
@@ -47,6 +56,145 @@ function Vallista({
);
}
// Märkesspecifika kopplingar: systemadministratören lägger in
// organisationens egna leverantörsuppgifter. Värdena skickas till
// servern, lagras krypterade och kommer aldrig tillbaka i klartext —
// hemliga fält visas alltid maskerade. Alla uppslag görs av servern.
function Integrationer() {
const [leverantorer, setLeverantorer] = useState<Leverantor[]>([]);
const [befintliga, setBefintliga] = useState<Integration[]>([]);
const [krypteringKlar, setKrypteringKlar] = useState(true);
const [oppen, setOppen] = useState<string | null>(null);
const [varden, setVarden] = useState<Record<string, string>>({});
const [fel, setFel] = useState("");
const [sparat, setSparat] = useState("");
const ladda = () => {
hamtaLeverantorer().then(setLeverantorer).catch(() => setLeverantorer([]));
hamtaIntegrationer()
.then((svar) => {
setBefintliga(svar.integrationer);
setKrypteringKlar(svar.krypteringKonfigurerad);
})
.catch(() => setBefintliga([]));
};
useEffect(ladda, []);
if (leverantorer.length === 0) return null;
return (
<Panel rubrik="Märkesspecifika kopplingar">
<p className="mb-2 text-[12px] text-[#707070]">
Organisationens egna avtal hos tillverkare och dataleverantörer. Uppgifterna lagras krypterat
plattformen och används bara av servern de skickas aldrig till teknikernas enheter.
</p>
{!krypteringKlar && (
<p className="mb-2 rounded border border-[#E0C36A] bg-[#FFF8E1] p-2 text-[12px] font-semibold text-[#9A6700]">
Kryptering är inte konfigurerad i driften (INTEGRATION_NYCKEL) uppgifter kan inte sparas förrän
nyckeln finns.
</p>
)}
{leverantorer.map((lev) => {
const finns = befintliga.find((i) => i.leverantor === lev.id);
return (
<div key={lev.id} className="border-b border-[#EBEBEB] py-2 last:border-0">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[13px] font-semibold">{lev.namn}</p>
{lev.beskrivning && <p className="text-[12px] text-[#707070]">{lev.beskrivning}</p>}
{finns && (
<p className="mt-1 text-[12px] text-[#4A5560]">
{Object.entries(finns.uppgifter)
.map(([nyckel, varde]) => `${nyckel}: ${varde || "—"}`)
.join(" · ")}
{finns.senaste_status && ` · senaste uppslag: ${finns.senaste_status}`}
</p>
)}
</div>
<span className={`shrink-0 text-[11px] font-semibold ${finns ? "text-[#1E6B34]" : "text-[#707070]"}`}>
{finns ? "Konfigurerad" : "Ej konfigurerad"}
</span>
</div>
{oppen === lev.id ? (
<div className="mt-2 rounded border border-[#C6C6C6] bg-white p-2">
{lev.falt.map((falt) => (
<TextFalt
key={falt.nyckel}
label={`${falt.etikett}${falt.hemlig ? " (lagras krypterat)" : ""}`}
varde={varden[falt.nyckel] ?? ""}
satt={(v) => setVarden((f) => ({ ...f, [falt.nyckel]: v }))}
losenord={falt.hemlig}
/>
))}
{fel && <p className="mb-2 text-[12px] font-semibold text-[#8B1A1A]">{fel}</p>}
<div className="grid grid-cols-2 gap-2">
<StorKnapp variant="sekundar" onClick={() => { setOppen(null); setFel(""); }}>
Avbryt
</StorKnapp>
<StorKnapp
disabled={!krypteringKlar}
onClick={async () => {
setFel("");
try {
await sparaIntegration(lev.id, varden);
setVarden({});
setOppen(null);
setSparat(lev.namn);
ladda();
} catch (misslyckande) {
setFel(misslyckande instanceof Error ? misslyckande.message : "Kunde inte spara.");
}
}}
>
Spara uppgifter
</StorKnapp>
</div>
<p className="mt-1 text-[11px] text-[#707070]">
Hemliga fält visas aldrig igen efter sparande fyll i nytt för att byta värde.
</p>
</div>
) : (
<div className="mt-2 grid grid-cols-2 gap-2">
<StorKnapp
variant="sekundar"
onClick={() => {
setOppen(lev.id);
setFel("");
// Icke-hemliga värden förifylls så bara hemligheten
// behöver skrivas om.
const start: Record<string, string> = {};
for (const falt of lev.falt) {
if (!falt.hemlig && finns?.uppgifter[falt.nyckel]) start[falt.nyckel] = finns.uppgifter[falt.nyckel];
}
setVarden(start);
}}
>
{finns ? "Uppdatera uppgifter" : "Lägg till uppgifter"}
</StorKnapp>
{finns && (
<StorKnapp
variant="fara"
onClick={async () => {
await taBortIntegration(lev.id);
ladda();
}}
>
Ta bort
</StorKnapp>
)}
</div>
)}
</div>
);
})}
{sparat && <p className="mt-2 text-[12px] font-semibold text-[#1E6B34]">Uppgifter sparade för {sparat}.</p>}
</Panel>
);
}
export default function Installningar() {
const konto = plattformKonto();
const inloggad = plattformAktiv() && !!konto;
@@ -121,6 +269,8 @@ export default function Installningar() {
/>
</Panel>
{inloggad && <Integrationer />}
{status === "fel" && <p className="mb-3 font-semibold text-[#8B1A1A]">{felText}</p>}
{status === "sparat" && <p className="mb-3 font-semibold text-[#1E6B34]"> Sparat</p>}
<StorKnapp disabled={status === "sparar"} onClick={spara}>