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:
+26
-2
@@ -22,7 +22,7 @@ flowchart LR
|
||||
| `plattform` | Självhostad backend (`services/plattform`): **multi-tenant** — registrering skapar organisation + systemadministratör, admin hanterar användare (tekniker/arbetsledare/admin), all ärendedata organisationsisolerad. Inloggning (bcrypt via pgcrypto, HS256-JWT med roll + org i anspråken), append-only händelse-API, publik delningsendpoint | Deployment + Service + HPA + PDB |
|
||||
| `ai-orkester` | AI-orkestern (`services/ai-orkester`): fyra uppgifter routade till Sonnet 5 / Opus 5 / Haiku 4.5 — verifierar plattformens JWT (delad hemlighet) | Deployment + Service + HPA + PDB |
|
||||
| `postgres` | Händelselogg + användare; **append-only garanterat med databastriggers** — historik kan inte ändras eller raderas oavsett roll | StatefulSet + PVC (10 Gi). Produktion: CloudNativePG-operatorn för backup/failover/PITR |
|
||||
| Hemligheter | `anthropic-api-key`, `jwt-secret` (delas av plattform + orkester), `postgres-losenord` | Secret `felsokning-hemligheter` — aldrig i bilder eller manifest |
|
||||
| Hemligheter | `anthropic-api-key`, `jwt-secret` (delas av plattform + orkester), `postgres-losenord`, `integration-nyckel` (krypterar kundernas märkesspecifika credentials) | Secret `felsokning-hemligheter` — aldrig i bilder eller manifest |
|
||||
|
||||
**Klienten har två driftlägen**, valda vid bygget: med `VITE_PLATTFORM_URL` går inloggning, synk, Live Share och AI mot klustret (helt självhostat); utan den används Supabase-läget (edge-funktion + managerad Postgres/Auth) som tidigare. Samma händelsemodell, samma orkester — låst av paritetstester.
|
||||
|
||||
@@ -43,7 +43,8 @@ kubectl create namespace guidad-felsokning
|
||||
kubectl -n guidad-felsokning create secret generic felsokning-hemligheter \
|
||||
--from-literal=anthropic-api-key='sk-ant-…' \
|
||||
--from-literal=jwt-secret="$(openssl rand -base64 48)" \
|
||||
--from-literal=postgres-losenord="$(openssl rand -base64 24)"
|
||||
--from-literal=postgres-losenord="$(openssl rand -base64 24)" \
|
||||
--from-literal=integration-nyckel="$(openssl rand -hex 32)"
|
||||
|
||||
# 3. Applicera manifesten (Postgres initieras med schema + append-only-triggers)
|
||||
kubectl apply -k infra/k8s
|
||||
@@ -56,6 +57,29 @@ curl https://app.exempel.se/api/openapi.yaml # API-first: hela API-specen
|
||||
|
||||
Byt domän och cert-issuer i `infra/k8s/ingress.yaml`. Att skapa nya organisationer är öppet i beta — stäng med `REGISTRERING_OPPEN=false` på plattformens Deployment; användare inom en organisation skapas alltid av dess systemadministratör.
|
||||
|
||||
## Märkesspecifika kopplingar
|
||||
|
||||
Varje verkstad har sina egna avtal med tillverkare och dataleverantörer.
|
||||
Kopplingarna konfigureras därför av kunden själv under **Inställningar →
|
||||
Märkesspecifika kopplingar**: systemadministratören väljer leverantör och
|
||||
fyller i sina credentials.
|
||||
|
||||
* **Uppgifterna når aldrig webbläsaren.** De krypteras med AES-256-GCM
|
||||
(`INTEGRATION_NYCKEL`, 32 byte hex eller base64) innan de skrivs till
|
||||
tabellen `integrationer`, och API:t returnerar hemliga fält maskerade
|
||||
(`••••3456`). Alla uppslag mot leverantören görs av servern.
|
||||
* **Fail closed.** Saknas `INTEGRATION_NYCKEL` sparas ingenting — API:t
|
||||
svarar 503 och inställningssidan säger varför. Inga uppgifter hamnar
|
||||
någonsin i klartext.
|
||||
* **Leverantörer är data, inte kod.** Registret ligger i
|
||||
`services/plattform/integrationer.json` och kan bytas mot en
|
||||
ConfigMap-mount via `INTEGRATIONER_FIL`. Nya märken läggs till genom
|
||||
att beskriva URL-mall, autentiseringstyp och svarsmappning — ingen
|
||||
ombyggnad av applikationen krävs.
|
||||
* **Testresultat loggas på kopplingen.** Varje uppslag skriver
|
||||
`senast_testad` och `senaste_status`, så ett trasigt abonnemang syns i
|
||||
inställningarna i stället för att tyst ge tomma svar.
|
||||
|
||||
## Multi-tenant och roller
|
||||
|
||||
Enligt Master Prompt: varje kund är en egen tenant, ingen data blandas mellan kunder.
|
||||
|
||||
+2
-1
@@ -43,6 +43,7 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st
|
||||
| Ärendeidentitet | ✅ Fordonsobjektet som röd tråd: identiteten (AO-nummer, claim-/garantinummer, skadenummer, regnr, VIN, miltal, kund) registreras en gång — normalt via arbetsorderskanningen — och återanvänds i identitetsraden i arbetsytan (med ärendetypsval), låst panel överst i Live Share, slutrapportens första sida (Ärendeinformation + Fordonsinformation) och exporten. |
|
||||
| Instrumentavläsning (visual-first) | ✅ Kameran som universellt gränssnitt: `📷 Instrument` i Dokumentera-panelen fotograferar multimetrar, diagnosskärmar, batteritestare m.m. — bildtolkningen identifierar instrumenttyp och extraherar värden/enheter/felkoder med konfidens per värde; teknikern bekräftar innan något loggas. Originalbilden loggas alltid tillsammans med de strukturerade mätvärdena — strukturerad data ersätter aldrig originalevidensen. Ingen integration mot diagnossystem krävs. |
|
||||
| Utskrift | ✅ Kundrapport och Live Share-vy skrivs ut svart på vitt; interaktiva element döljs automatiskt. Utskriften går genom ECM-kvalitetsgrinden. |
|
||||
| Märkesspecifika kopplingar | ✅ Verkstaden konfigurerar sina egna OEM-/fordonsdataleverantörer under Inställningar med sina egna credentials ([moduler/markesspecifika-kopplingar.md](moduler/markesspecifika-kopplingar.md)): uppgifterna krypteras med AES-256-GCM i vila, returneras alltid maskerade (`••••3456`) och **alla uppslag görs av servern** — leverantörsnycklar når aldrig webbläsaren. Endast systemadministratören hanterar dem, kopplingarna är organisationsknutna och saknas krypteringsnyckeln sparas ingenting alls (fail closed). Leverantörer är data, inte kod: URL-mall, autentiseringstyp (bearer/header/basic/query) och svarsmappning beskrivs i `integrationer.json` (ConfigMap-utbytbar via `INTEGRATIONER_FIL`) — nya märken läggs till utan ombyggnad. Varje uppslag loggar teststatus, så ett utgånget abonnemang syns i inställningarna i stället för att ge tysta tomma svar. Verifierat i integrationstestet (rollstyrning, maskering, kryptering i databasen, organisationsisolering, fail closed). |
|
||||
| Öppet API | ✅ Plattforms-API:t är dokumenterat med OpenAPI 3.0 (`services/plattform/openapi.yaml`) — auth, användare, ärenden/händelser (append-only), översikt, publik delning och AI-orkestern, med scheman för alla händelsetyper. Specen valideras maskinellt, paritetstestas mot serverns rutter och serveras live på `GET /api/openapi.yaml`. |
|
||||
|
||||
## Arkitekturprinciper i koden
|
||||
@@ -57,4 +58,4 @@ Demomanus för visning: [DEMO.md](DEMO.md). Knappen **Skapa demoärende** på st
|
||||
|
||||
## Medvetna avgränsningar
|
||||
|
||||
- VIN-avkodning mot fordonsdatabaser (utrustningsnivå, återkallelser, TSB:er) och tillverkarintegrationer ingår inte ännu — arbetsorderskanningen och QR-/VIN-avläsningen ger strukturen de kopplas in i.
|
||||
- Ramen för tillverkarintegrationer finns (kunden lägger in sina egna credentials och slår upp fordonsuppgifter), men de anrikade datamängderna — utrustningsnivå, återkallelser, TSB:er — mappas inte ännu; registret behöver fler svarsfält och leverantörsprofiler innan det är meningsfullt.
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
# Märkesspecifika kopplingar
|
||||
|
||||
Verkstaden har redan sina avtal. Volvo-verkstaden har VIDA, VAG-verkstaden
|
||||
har erWin, den fria verkstaden har en fordonsdataleverantör. Ingen av dem
|
||||
vill att vi ska vara mellanhand för deras abonnemang — och ingen av dem
|
||||
har samma uppsättning som grannen.
|
||||
|
||||
Därför konfigurerar **kunden själv** sina kopplingar under
|
||||
**Inställningar → Märkesspecifika kopplingar**, med sina egna credentials.
|
||||
Vi tillhandahåller ramen, inte kontot.
|
||||
|
||||
## Principer
|
||||
|
||||
**Uppgifterna når aldrig webbläsaren.** Samma regel som för
|
||||
plattformens egna API-nycklar: hemligheter bor på servern. Credentials
|
||||
krypteras med AES-256-GCM innan de skrivs till databasen, och API:t
|
||||
returnerar hemliga fält maskerade (`••••3456`). Klienten kan se *att* en
|
||||
koppling finns och när den senast fungerade — aldrig vad nyckeln är.
|
||||
|
||||
**Alla uppslag görs av servern.** Klienten skickar en identifierare
|
||||
(VIN eller regnr); servern hämtar uppgifterna, dekrypterar dem i minnet,
|
||||
anropar leverantören och returnerar bara de mappade fordonsfälten.
|
||||
|
||||
**Fail closed.** Saknas krypteringsnyckeln (`INTEGRATION_NYCKEL`) sparas
|
||||
ingenting — API:t svarar 503 och inställningssidan förklarar varför.
|
||||
Alternativet, att lagra i klartext "så länge", finns inte.
|
||||
|
||||
**Endast systemadministratören.** Att lägga till, ändra och ta bort
|
||||
kopplingar kräver rollen `admin`. Teknikern kan läsa registret över
|
||||
tillgängliga leverantörer (annars kan inställningssidan inte visa dem)
|
||||
men aldrig någon organisations uppgifter.
|
||||
|
||||
**Organisationsknutet.** Kopplingarna hör till organisationen, precis
|
||||
som ärendedata. Ingen tenant ser en annans.
|
||||
|
||||
## Leverantörer är data, inte kod
|
||||
|
||||
Registret ligger i `services/plattform/integrationer.json` och kan bytas
|
||||
mot en ConfigMap-mount via `INTEGRATIONER_FIL`. En leverantör beskrivs
|
||||
helt deklarativt:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "volvo_vida",
|
||||
"namn": "Volvo VIDA",
|
||||
"falt": [
|
||||
{ "nyckel": "bas_url", "etikett": "Bas-URL (använd {vin} som platshållare)", "hemlig": false },
|
||||
{ "nyckel": "api_nyckel", "etikett": "API-nyckel", "hemlig": true }
|
||||
],
|
||||
"uppslag": {
|
||||
"urlFalt": "bas_url",
|
||||
"auth": "header",
|
||||
"authHeader": "X-Api-Key",
|
||||
"authFalt": "api_nyckel",
|
||||
"svarsfalt": { "marke": "make", "modell": "model", "arsmodell": "year" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
* `falt` — vad administratören ska fylla i. `hemlig: true` styr både
|
||||
kryptering och maskering.
|
||||
* `uppslag.auth` — `bearer`, `header`, `basic` eller `query`. Inga
|
||||
leverantörsspecifika kodgrenar; all variation ligger i registret.
|
||||
* `svarsfalt` — mappning från leverantörens JSON (punktnotation stöds)
|
||||
till våra fordonsfält.
|
||||
* `nyckeltyp: "regnr"` — uppslaget sker på registreringsnummer i stället
|
||||
för VIN. `{vin}`/`{regnr}` i URL-mallen ersätts URL-kodat.
|
||||
|
||||
Ett nytt märke läggs alltså till genom att beskriva det — inte genom att
|
||||
bygga om applikationen.
|
||||
|
||||
## Vad ett uppslag gör och inte gör
|
||||
|
||||
Uppslaget fyller i **fordonsbeskrivningen** (märke, modell, årsmodell,
|
||||
motor, växellåda). Det är kontextdata, inte evidens: ett svar från en
|
||||
leverantör är aldrig en utförd kontroll och räknas inte i
|
||||
[evidensmotorn](evidensmotor.md). Returnerar leverantören inga kända fält
|
||||
säger systemet det rakt ut i stället för att visa tomma rader.
|
||||
|
||||
Varje uppslag skriver `senast_testad` och `senaste_status` på
|
||||
kopplingen. Ett utgånget abonnemang syns därför i inställningarna som ett
|
||||
felmeddelande från leverantören, inte som tysta tomma svar.
|
||||
|
||||
## API
|
||||
|
||||
| Väg | Metod | Roll | Vad |
|
||||
| --- | --- | --- | --- |
|
||||
| `/api/integrationer/leverantorer` | GET | inloggad | Registret (fältdefinitioner, inga uppgifter) |
|
||||
| `/api/integrationer` | GET | admin | Organisationens kopplingar, hemligheter maskerade |
|
||||
| `/api/integrationer` | POST | admin | Spara/uppdatera credentials (krypteras) |
|
||||
| `/api/integrationer/{leverantor}` | DELETE | admin | Ta bort |
|
||||
| `/api/integrationer/{leverantor}/uppslag` | POST | inloggad | Slå upp VIN/regnr via servern |
|
||||
|
||||
Fullständigt dokumenterat i `services/plattform/openapi.yaml`.
|
||||
|
||||
## Drift
|
||||
|
||||
`INTEGRATION_NYCKEL` är 32 byte hex eller base64 (`openssl rand -hex 32`),
|
||||
levererad via secret:en `felsokning-hemligheter` — se
|
||||
[DRIFT.md](../DRIFT.md). Byts nyckeln måste kopplingarna sparas om;
|
||||
tjänsten visar då inga värden i stället för att gissa.
|
||||
@@ -3,7 +3,8 @@
|
||||
# kubectl -n guidad-felsokning create secret generic felsokning-hemligheter \
|
||||
# --from-literal=anthropic-api-key='sk-ant-…' \
|
||||
# --from-literal=jwt-secret="$(openssl rand -base64 48)" \
|
||||
# --from-literal=postgres-losenord="$(openssl rand -base64 24)"
|
||||
# --from-literal=postgres-losenord="$(openssl rand -base64 24)" \
|
||||
# --from-literal=integration-nyckel="$(openssl rand -hex 32)"
|
||||
#
|
||||
# I produktion: använd extern secrets-hantering (External Secrets Operator,
|
||||
# Sealed Secrets eller molnleverantörens secret manager).
|
||||
@@ -17,3 +18,7 @@ stringData:
|
||||
anthropic-api-key: ERSATT-MIG
|
||||
jwt-secret: ERSATT-MIG
|
||||
postgres-losenord: ERSATT-MIG
|
||||
# 32 byte hex (openssl rand -hex 32) — krypterar kundernas
|
||||
# märkesspecifika credentials i vila. Byts nyckeln måste kopplingarna
|
||||
# sparas om; tjänsten visar då inga värden i stället för att gissa.
|
||||
integration-nyckel: ERSATT-MIG
|
||||
|
||||
@@ -24,6 +24,12 @@ spec:
|
||||
- name: JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef: { name: felsokning-hemligheter, key: jwt-secret }
|
||||
# Krypteringsnyckel för kundernas märkesspecifika credentials
|
||||
# (32 byte hex eller base64). Saknas den sparas inga uppgifter
|
||||
# alls — tjänsten failar closed i stället för att lagra i klartext.
|
||||
- name: INTEGRATION_NYCKEL
|
||||
valueFrom:
|
||||
secretKeyRef: { name: felsokning-hemligheter, key: integration-nyckel }
|
||||
- name: POSTGRES_LOSENORD
|
||||
valueFrom:
|
||||
secretKeyRef: { name: felsokning-hemligheter, key: postgres-losenord }
|
||||
|
||||
@@ -16,6 +16,21 @@ create table if not exists organisationer (
|
||||
);
|
||||
alter table organisationer add column if not exists installningar jsonb not null default '{}'::jsonb;
|
||||
|
||||
-- Märkesspecifika kopplingar per organisation. Uppgifterna lagras
|
||||
-- krypterade (AES-256-GCM, nyckel ur INTEGRATION_NYCKEL) och lämnar
|
||||
-- aldrig servern i klartext — klienten ser bara maskerade värden.
|
||||
create table if not exists integrationer (
|
||||
organisation_id uuid not null references organisationer(id),
|
||||
leverantor text not null,
|
||||
uppgifter_krypt text not null,
|
||||
aktiv boolean not null default true,
|
||||
skapad timestamptz not null default now(),
|
||||
uppdaterad timestamptz not null default now(),
|
||||
senast_testad timestamptz,
|
||||
senaste_status text,
|
||||
primary key (organisation_id, leverantor)
|
||||
);
|
||||
|
||||
create table if not exists anvandare (
|
||||
id uuid primary key default gen_random_uuid(),
|
||||
organisation_id uuid not null references organisationer(id),
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"build:dev": "vite build --mode development",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"typkontroll": "tsc --noEmit -p tsconfig.app.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,7 @@ FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev --no-audit --no-fund && npm cache clean --force
|
||||
COPY server.mjs openapi.yaml ecm-regler.json ./
|
||||
COPY server.mjs openapi.yaml ecm-regler.json integrationer.json ./
|
||||
|
||||
ENV NODE_ENV=production PORT=8080
|
||||
USER node
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"version": "1.0",
|
||||
"beskrivning": "Register över märkesspecifika kopplingar. Leverantörer är data, inte kod: nya märken/dataleverantörer läggs till här (eller via ConfigMap-mount, INTEGRATIONER_FIL) utan att applikationen byggs om. Fält märkta hemlig=true maskeras alltid i API-svar och lagras krypterat.",
|
||||
"leverantorer": [
|
||||
{
|
||||
"id": "generisk_vin",
|
||||
"namn": "Generisk VIN-tjänst",
|
||||
"beskrivning": "Valfri dataleverantör med VIN-uppslag över HTTP. Ange bas-URL och nyckel enligt leverantörens dokumentation.",
|
||||
"falt": [
|
||||
{ "nyckel": "bas_url", "etikett": "Bas-URL (använd {vin} som platshållare)", "hemlig": false },
|
||||
{ "nyckel": "api_nyckel", "etikett": "API-nyckel", "hemlig": true }
|
||||
],
|
||||
"uppslag": {
|
||||
"urlFalt": "bas_url",
|
||||
"auth": "bearer",
|
||||
"authFalt": "api_nyckel",
|
||||
"svarsfalt": {
|
||||
"marke": "make",
|
||||
"modell": "model",
|
||||
"arsmodell": "year",
|
||||
"motor": "engine",
|
||||
"vaxellada": "transmission"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "vag_erwin",
|
||||
"namn": "Volkswagen Group erWin",
|
||||
"beskrivning": "OEM-portal för VW, Audi, Škoda och SEAT. Kräver eget abonnemang hos tillverkaren.",
|
||||
"falt": [
|
||||
{ "nyckel": "bas_url", "etikett": "Bas-URL (använd {vin} som platshållare)", "hemlig": false },
|
||||
{ "nyckel": "anvandarnamn", "etikett": "Användarnamn", "hemlig": false },
|
||||
{ "nyckel": "losenord", "etikett": "Lösenord", "hemlig": true }
|
||||
],
|
||||
"uppslag": {
|
||||
"urlFalt": "bas_url",
|
||||
"auth": "basic",
|
||||
"authFalt": "anvandarnamn",
|
||||
"authFalt2": "losenord",
|
||||
"svarsfalt": {
|
||||
"marke": "brand",
|
||||
"modell": "model",
|
||||
"arsmodell": "modelYear",
|
||||
"motor": "engineCode",
|
||||
"vaxellada": "gearbox"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "volvo_vida",
|
||||
"namn": "Volvo VIDA",
|
||||
"beskrivning": "Volvos verkstadsportal. Kräver eget abonnemang och verkstads-ID.",
|
||||
"falt": [
|
||||
{ "nyckel": "bas_url", "etikett": "Bas-URL (använd {vin} som platshållare)", "hemlig": false },
|
||||
{ "nyckel": "verkstadsid", "etikett": "Verkstads-ID", "hemlig": false },
|
||||
{ "nyckel": "api_nyckel", "etikett": "API-nyckel", "hemlig": true }
|
||||
],
|
||||
"uppslag": {
|
||||
"urlFalt": "bas_url",
|
||||
"auth": "header",
|
||||
"authHeader": "X-Api-Key",
|
||||
"authFalt": "api_nyckel",
|
||||
"svarsfalt": {
|
||||
"marke": "make",
|
||||
"modell": "model",
|
||||
"arsmodell": "year",
|
||||
"motor": "engine",
|
||||
"vaxellada": "transmission"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "fordonsregister",
|
||||
"namn": "Fordonsregisteruppslag (regnr → fordon)",
|
||||
"beskrivning": "Nationell fordonsdata via vald leverantör. Uppslag sker på registreringsnummer i stället för VIN.",
|
||||
"nyckeltyp": "regnr",
|
||||
"falt": [
|
||||
{ "nyckel": "bas_url", "etikett": "Bas-URL (använd {regnr} som platshållare)", "hemlig": false },
|
||||
{ "nyckel": "api_nyckel", "etikett": "API-nyckel", "hemlig": true }
|
||||
],
|
||||
"uppslag": {
|
||||
"urlFalt": "bas_url",
|
||||
"auth": "query",
|
||||
"authParam": "key",
|
||||
"authFalt": "api_nyckel",
|
||||
"svarsfalt": {
|
||||
"marke": "make",
|
||||
"modell": "model",
|
||||
"arsmodell": "year",
|
||||
"vin": "vin",
|
||||
"motor": "engine"
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -253,6 +253,88 @@ for i in 1 2 3 4 5 6 7; do
|
||||
done
|
||||
kontroll "takt-begränsning stoppar upprepade försök" "$SISTA" "429"
|
||||
|
||||
# 10d. Märkesspecifika kopplingar: kundens egna credentials
|
||||
# Registret är läsbart för alla inloggade (inställningssidan behöver
|
||||
# veta vilka leverantörer som finns) men innehåller inga uppgifter.
|
||||
LEV=$(curl -s "$BAS/api/integrationer/leverantorer" -H "Authorization: Bearer $TOKEN_J")
|
||||
kontroll "leverantörsregistret är läsbart för tekniker" \
|
||||
"$(echo "$LEV" | falt '.leverantorer.some(l=>l.id==="generisk_vin")')" "true"
|
||||
kontroll "registret pekar ut hemliga fält" \
|
||||
"$(echo "$LEV" | falt '.leverantorer.every(l=>l.falt.some(f=>f.hemlig===true))')" "true"
|
||||
|
||||
# Uppgifterna är administratörens ensak
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_J")
|
||||
kontroll "tekniker ser inte kopplingarnas uppgifter" "$KOD" "403"
|
||||
|
||||
# Utan konfigurerad krypteringsnyckel sparas ingenting — fail closed
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"leverantor":"generisk_vin","uppgifter":{"bas_url":"https://x.se/{vin}","api_nyckel":"k"}}')
|
||||
kontroll "utan krypteringsnyckel sparas inga uppgifter" "$KOD" "503"
|
||||
|
||||
# Starta om tjänsten med krypteringsnyckel konfigurerad
|
||||
kill "$SERVER_PID" 2>/dev/null || true
|
||||
wait "$SERVER_PID" 2>/dev/null || true
|
||||
DATABASE_URL="postgresql://plattform:test@127.0.0.1:$PGPORT/felsokning" \
|
||||
JWT_SECRET=integrationshemlighet PORT=$APPPORT \
|
||||
INTEGRATION_NYCKEL=$(node -pe "require('crypto').randomBytes(32).toString('hex')") \
|
||||
node server.mjs &
|
||||
SERVER_PID=$!
|
||||
sleep 1
|
||||
|
||||
# Okänd leverantör och ofullständiga uppgifter avvisas
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \
|
||||
-H 'Content-Type: application/json' -d '{"leverantor":"hittepa","uppgifter":{"a":"b"}}')
|
||||
kontroll "okänd leverantör avvisas" "$KOD" "400"
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \
|
||||
-H 'Content-Type: application/json' -d '{"leverantor":"generisk_vin","uppgifter":{"bas_url":"https://x.se/{vin}"}}')
|
||||
kontroll "ofullständiga uppgifter avvisas" "$KOD" "400"
|
||||
|
||||
# Administratören sparar organisationens egna credentials
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"leverantor":"generisk_vin","uppgifter":{"bas_url":"http://127.0.0.1:9/vin/{vin}","api_nyckel":"sk-verkstad-123456"}}')
|
||||
kontroll "administratören kan spara credentials" "$KOD" "200"
|
||||
|
||||
# Hemligheten lämnar aldrig servern i klartext
|
||||
INT=$(curl -s "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A")
|
||||
kontroll "krypteringen är konfigurerad" "$(echo "$INT" | falt .krypteringKonfigurerad)" "true"
|
||||
kontroll "hemligt fält maskeras i svaret" "$(echo "$INT" | falt '.integrationer[0].uppgifter.api_nyckel')" "••••3456"
|
||||
kontroll "öppet fält visas som det är" "$(echo "$INT" | falt '.integrationer[0].uppgifter.bas_url')" "http://127.0.0.1:9/vin/{vin}"
|
||||
|
||||
# … och ligger krypterad i databasen
|
||||
RAD=$(PGPASSWORD=test "$PGBIN/psql" -h 127.0.0.1 -p $PGPORT -U plattform -d felsokning \
|
||||
-tAc "select uppgifter_krypt from integrationer where leverantor='generisk_vin'")
|
||||
case "$RAD" in
|
||||
*sk-verkstad-123456*) echo "✗ uppgifterna ligger i klartext i databasen"; exit 1 ;;
|
||||
*) echo "✓ uppgifterna ligger krypterade i databasen" ;;
|
||||
esac
|
||||
|
||||
# Uppslag: identifieraren valideras, okonfigurerad koppling ger 404
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer/generisk_vin/uppslag" \
|
||||
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"identifierare":"x"}')
|
||||
kontroll "ogiltig identifierare avvisas" "$KOD" "400"
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer/volvo_vida/uppslag" \
|
||||
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"identifierare":"YV1DZ8256F2123456"}')
|
||||
kontroll "okonfigurerad koppling ger 404" "$KOD" "404"
|
||||
|
||||
# Leverantören är onåbar i testmiljön — felet rapporteras ärligt, inte tyst
|
||||
KOD=$(curl -s -o /dev/null -w "%{http_code}" -X POST "$BAS/api/integrationer/generisk_vin/uppslag" \
|
||||
-H "Authorization: Bearer $TOKEN_A" -H 'Content-Type: application/json' -d '{"identifierare":"YV1DZ8256F2123456"}')
|
||||
kontroll "onåbar leverantör rapporteras som 502" "$KOD" "502"
|
||||
STATUS=$(curl -s "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" \
|
||||
| falt '.integrationer[0].senaste_status.slice(0,3)')
|
||||
kontroll "senaste testresultat sparas på kopplingen" "$STATUS" "fel"
|
||||
|
||||
# Kopplingar är organisationsknutna
|
||||
ANTAL_B=$(curl -s "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_B" | falt .integrationer.length)
|
||||
kontroll "org B ser inte org A:s kopplingar" "$ANTAL_B" "0"
|
||||
|
||||
# Borttagning
|
||||
curl -s -X DELETE "$BAS/api/integrationer/generisk_vin" -H "Authorization: Bearer $TOKEN_A" >/dev/null
|
||||
ANTAL=$(curl -s "$BAS/api/integrationer" -H "Authorization: Bearer $TOKEN_A" | falt .integrationer.length)
|
||||
kontroll "kopplingen kan tas bort" "$ANTAL" "0"
|
||||
|
||||
# 11. API-first: OpenAPI-specen serveras live, utan inloggning
|
||||
SPEC=$(curl -s "$BAS/api/openapi.yaml")
|
||||
case "$SPEC" in
|
||||
|
||||
@@ -26,6 +26,7 @@ tags:
|
||||
- name: Översikt
|
||||
- name: Delning
|
||||
- name: AI
|
||||
- name: Integrationer
|
||||
- name: Drift
|
||||
|
||||
paths:
|
||||
@@ -229,6 +230,185 @@ paths:
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"403": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
/api/integrationer/leverantorer:
|
||||
get:
|
||||
tags: [Integrationer]
|
||||
summary: Registret över märkesspecifika kopplingar
|
||||
description: >
|
||||
Leverantörer är data, inte kod: registret läses ur
|
||||
`integrationer.json` (eller filen i `INTEGRATIONER_FIL`) och kan
|
||||
bytas via ConfigMap utan att applikationen byggs om. Innehåller
|
||||
endast fältdefinitioner — aldrig någon organisations uppgifter.
|
||||
responses:
|
||||
"200":
|
||||
description: Leverantörsdefinitioner.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
version: { type: string }
|
||||
leverantorer:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
id: { type: string }
|
||||
namn: { type: string }
|
||||
beskrivning: { type: string }
|
||||
nyckeltyp:
|
||||
type: string
|
||||
enum: [vin, regnr]
|
||||
description: Vad uppslaget sker på. Utelämnat betyder VIN.
|
||||
falt:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
nyckel: { type: string }
|
||||
etikett: { type: string }
|
||||
hemlig:
|
||||
type: boolean
|
||||
description: >
|
||||
Hemliga fält maskeras alltid i svar och
|
||||
visas aldrig igen efter sparande.
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
/api/integrationer:
|
||||
get:
|
||||
tags: [Integrationer]
|
||||
summary: Organisationens konfigurerade kopplingar
|
||||
description: >
|
||||
Kräver rollen `admin`. Uppgifterna lagras krypterade
|
||||
(AES-256-GCM) och returneras alltid maskerade — hemliga värden
|
||||
lämnar aldrig servern i klartext.
|
||||
responses:
|
||||
"200":
|
||||
description: Konfigurerade kopplingar med maskerade uppgifter.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
krypteringKonfigurerad:
|
||||
type: boolean
|
||||
description: Falskt om `INTEGRATION_NYCKEL` saknas — då kan inget sparas.
|
||||
integrationer:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
leverantor: { type: string }
|
||||
namn: { type: string }
|
||||
aktiv: { type: boolean }
|
||||
uppdaterad: { type: string, format: date-time }
|
||||
senast_testad: { type: string, format: date-time, nullable: true }
|
||||
senaste_status: { type: string, nullable: true }
|
||||
uppgifter:
|
||||
type: object
|
||||
additionalProperties: { type: string }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"403": { $ref: "#/components/responses/Fel" }
|
||||
post:
|
||||
tags: [Integrationer]
|
||||
summary: Spara eller uppdatera en kopplings uppgifter
|
||||
description: >
|
||||
Kräver rollen `admin`. Endast leverantörens definierade fält
|
||||
sparas och samtliga måste fyllas i. Uppgifterna krypteras innan
|
||||
de skrivs. Ett sparande nollställer tidigare testresultat.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [leverantor, uppgifter]
|
||||
properties:
|
||||
leverantor: { type: string }
|
||||
aktiv: { type: boolean, default: true }
|
||||
uppgifter:
|
||||
type: object
|
||||
additionalProperties: { type: string }
|
||||
responses:
|
||||
"200": { description: Sparad. }
|
||||
"400": { $ref: "#/components/responses/Fel" }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"403": { $ref: "#/components/responses/Fel" }
|
||||
"503":
|
||||
description: Kryptering är inte konfigurerad (`INTEGRATION_NYCKEL` saknas).
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Fel" }
|
||||
|
||||
/api/integrationer/{leverantor}:
|
||||
delete:
|
||||
tags: [Integrationer]
|
||||
summary: Ta bort en kopplings uppgifter
|
||||
description: Kräver rollen `admin`.
|
||||
parameters:
|
||||
- name: leverantor
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
responses:
|
||||
"200": { description: Borttagen. }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"403": { $ref: "#/components/responses/Fel" }
|
||||
|
||||
/api/integrationer/{leverantor}/uppslag:
|
||||
post:
|
||||
tags: [Integrationer]
|
||||
summary: Slå upp ett fordon hos leverantören
|
||||
description: >
|
||||
Anropet görs alltid av servern — kundens leverantörsnycklar når
|
||||
aldrig webbläsaren. Svaret mappas till våra fält enligt
|
||||
registrets `svarsfalt`. Resultatet skrivs som kopplingens
|
||||
senaste teststatus.
|
||||
parameters:
|
||||
- name: leverantor
|
||||
in: path
|
||||
required: true
|
||||
schema: { type: string }
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [identifierare]
|
||||
properties:
|
||||
identifierare:
|
||||
type: string
|
||||
description: VIN eller registreringsnummer beroende på leverantörens `nyckeltyp`.
|
||||
responses:
|
||||
"200":
|
||||
description: Fordonsuppgifter från leverantören.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
fordon:
|
||||
type: object
|
||||
additionalProperties: { type: string }
|
||||
"400": { $ref: "#/components/responses/Fel" }
|
||||
"401": { $ref: "#/components/responses/Fel" }
|
||||
"404":
|
||||
description: Kopplingen är inte konfigurerad för organisationen.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Fel" }
|
||||
"502":
|
||||
description: Leverantören svarade med fel eller inga kända fält.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Fel" }
|
||||
"503":
|
||||
description: Kryptering är inte konfigurerad.
|
||||
content:
|
||||
application/json:
|
||||
schema: { $ref: "#/components/schemas/Fel" }
|
||||
|
||||
/api/delad/{delningskod}/beslut:
|
||||
post:
|
||||
tags: [Delning]
|
||||
@@ -617,6 +797,10 @@ components:
|
||||
properties:
|
||||
error: { type: string }
|
||||
schemas:
|
||||
Fel:
|
||||
type: object
|
||||
properties:
|
||||
error: { type: string }
|
||||
Roll:
|
||||
type: string
|
||||
enum: [tekniker, arbetsledare, admin]
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
// PORT default 8080
|
||||
|
||||
import { createServer } from "node:http";
|
||||
import crypto, { createHmac, timingSafeEqual } from "node:crypto";
|
||||
import crypto, { createCipheriv, createDecipheriv, createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
@@ -33,6 +33,16 @@ const ECM_REGLER = readFileSync(
|
||||
"utf8",
|
||||
);
|
||||
|
||||
// Register över märkesspecifika kopplingar — data, inte kod. Nya
|
||||
// leverantörer läggs till i filen (eller via ConfigMap-mount) utan att
|
||||
// applikationen byggs om.
|
||||
const INTEGRATIONER = JSON.parse(
|
||||
readFileSync(
|
||||
process.env.INTEGRATIONER_FIL ?? join(dirname(fileURLToPath(import.meta.url)), "integrationer.json"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 8080);
|
||||
const MAX_KROPP = 4 * 1024 * 1024;
|
||||
const TOKEN_LIVSTID_S = 12 * 60 * 60;
|
||||
@@ -84,6 +94,46 @@ export function verifieraJwt(token, hemlighet) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Integrationsuppgifter: kryptering i vila ------------------------
|
||||
//
|
||||
// Kundens egna leverantörsnycklar lagras krypterade med AES-256-GCM.
|
||||
// Nyckeln kommer ur INTEGRATION_NYCKEL (32 byte, hex eller base64) och
|
||||
// finns bara i driftens hemlighetshantering. Saknas nyckeln kan
|
||||
// integrationer varken sparas eller användas — fail closed.
|
||||
|
||||
function integrationsNyckel() {
|
||||
const ra = process.env.INTEGRATION_NYCKEL;
|
||||
if (!ra) return null;
|
||||
const buf = /^[0-9a-fA-F]{64}$/.test(ra) ? Buffer.from(ra, "hex") : Buffer.from(ra, "base64");
|
||||
return buf.length === 32 ? buf : null;
|
||||
}
|
||||
|
||||
export function kryptera(klartext, nyckel) {
|
||||
const iv = randomBytes(12);
|
||||
const chiffer = createCipheriv("aes-256-gcm", nyckel, iv);
|
||||
const data = Buffer.concat([chiffer.update(klartext, "utf8"), chiffer.final()]);
|
||||
return `${iv.toString("base64")}.${chiffer.getAuthTag().toString("base64")}.${data.toString("base64")}`;
|
||||
}
|
||||
|
||||
export function dekryptera(paket, nyckel) {
|
||||
const [iv, tagg, data] = paket.split(".");
|
||||
const dechiffer = createDecipheriv("aes-256-gcm", nyckel, Buffer.from(iv, "base64"));
|
||||
dechiffer.setAuthTag(Buffer.from(tagg, "base64"));
|
||||
return Buffer.concat([dechiffer.update(Buffer.from(data, "base64")), dechiffer.final()]).toString("utf8");
|
||||
}
|
||||
|
||||
// Hemliga fält lämnar aldrig servern i klartext — klienten ser bara att
|
||||
// ett värde finns och dess sista tecken.
|
||||
export function maskera(varde) {
|
||||
if (typeof varde !== "string" || varde.length === 0) return "";
|
||||
if (varde.length <= 4) return "••••";
|
||||
return `••••${varde.slice(-4)}`;
|
||||
}
|
||||
|
||||
function leverantorsDef(id) {
|
||||
return INTEGRATIONER.leverantorer.find((l) => l.id === id);
|
||||
}
|
||||
|
||||
// ---- Hjälpare ---------------------------------------------------------
|
||||
|
||||
function svara(res, status, kropp) {
|
||||
@@ -127,6 +177,44 @@ async function arendeIOrg(arendeId, organisationId) {
|
||||
return rader.rowCount > 0;
|
||||
}
|
||||
|
||||
// Generiskt uppslag mot en leverantör. All variation ligger i registret
|
||||
// (URL-mall, autentiseringstyp, svarsmappning) — inga leverantörs-
|
||||
// specifika kodgrenar.
|
||||
export async function gorUppslag(def, uppgifter, identifierare, hamtare = fetch) {
|
||||
const u = def.uppslag ?? {};
|
||||
const mall = uppgifter[u.urlFalt ?? "bas_url"];
|
||||
if (typeof mall !== "string" || !/^https?:\/\//.test(mall)) {
|
||||
return { ok: false, fel: "Bas-URL saknas eller är ogiltig." };
|
||||
}
|
||||
let url = mall.replace(/\{vin\}/gi, encodeURIComponent(identifierare)).replace(/\{regnr\}/gi, encodeURIComponent(identifierare));
|
||||
const headers = { Accept: "application/json" };
|
||||
|
||||
if (u.auth === "bearer") headers.Authorization = `Bearer ${uppgifter[u.authFalt]}`;
|
||||
if (u.auth === "header") headers[u.authHeader ?? "X-Api-Key"] = uppgifter[u.authFalt];
|
||||
if (u.auth === "basic") {
|
||||
const par = `${uppgifter[u.authFalt]}:${uppgifter[u.authFalt2]}`;
|
||||
headers.Authorization = `Basic ${Buffer.from(par).toString("base64")}`;
|
||||
}
|
||||
if (u.auth === "query") {
|
||||
url += `${url.includes("?") ? "&" : "?"}${encodeURIComponent(u.authParam ?? "key")}=${encodeURIComponent(uppgifter[u.authFalt])}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const svarFran = await hamtare(url, { headers, signal: AbortSignal.timeout(10_000) });
|
||||
if (!svarFran.ok) return { ok: false, fel: `Leverantören svarade ${svarFran.status}.` };
|
||||
const data = await svarFran.json();
|
||||
const fordon = {};
|
||||
for (const [vart, deras] of Object.entries(u.svarsfalt ?? {})) {
|
||||
const varde = deras.split(".").reduce((niva, del) => (niva == null ? niva : niva[del]), data);
|
||||
if (varde !== undefined && varde !== null && `${varde}`.trim()) fordon[vart] = `${varde}`.trim();
|
||||
}
|
||||
if (Object.keys(fordon).length === 0) return { ok: false, fel: "Leverantören returnerade inga kända fält." };
|
||||
return { ok: true, fordon };
|
||||
} catch (fel) {
|
||||
return { ok: false, fel: fel?.name === "TimeoutError" ? "Leverantören svarade inte i tid." : "Anropet misslyckades." };
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Server -----------------------------------------------------------
|
||||
|
||||
export function skapaServer() {
|
||||
@@ -364,6 +452,135 @@ export function skapaServer() {
|
||||
return res.end(ECM_REGLER);
|
||||
}
|
||||
|
||||
// -- Märkesspecifika kopplingar (integrationer) --
|
||||
// Registret läses av alla inloggade (så inställningssidan kan visa
|
||||
// vilka leverantörer som finns); uppgifterna hanteras endast av
|
||||
// systemadministratören och returneras alltid maskerade.
|
||||
if (req.method === "GET" && vag === "/api/integrationer/leverantorer") {
|
||||
return svara(res, 200, INTEGRATIONER);
|
||||
}
|
||||
|
||||
if (vag === "/api/integrationer") {
|
||||
if (req.method === "GET") {
|
||||
if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." });
|
||||
const nyckel = integrationsNyckel();
|
||||
const rader = await pool.query(
|
||||
`select leverantor, uppgifter_krypt, aktiv, uppdaterad, senast_testad, senaste_status
|
||||
from integrationer where organisation_id = $1 order by leverantor`,
|
||||
[anspr.org],
|
||||
);
|
||||
const integrationer = rader.rows.map((rad) => {
|
||||
const def = leverantorsDef(rad.leverantor);
|
||||
let uppgifter = {};
|
||||
try {
|
||||
if (nyckel) uppgifter = JSON.parse(dekryptera(rad.uppgifter_krypt, nyckel));
|
||||
} catch {
|
||||
// Fel nyckel eller manipulerad rad — visa inga värden.
|
||||
}
|
||||
const maskerade = {};
|
||||
for (const falt of def?.falt ?? []) {
|
||||
const varde = uppgifter[falt.nyckel];
|
||||
maskerade[falt.nyckel] = falt.hemlig ? maskera(varde) : (varde ?? "");
|
||||
}
|
||||
return {
|
||||
leverantor: rad.leverantor,
|
||||
namn: def?.namn ?? rad.leverantor,
|
||||
aktiv: rad.aktiv,
|
||||
uppdaterad: rad.uppdaterad,
|
||||
senast_testad: rad.senast_testad,
|
||||
senaste_status: rad.senaste_status,
|
||||
uppgifter: maskerade,
|
||||
};
|
||||
});
|
||||
return svara(res, 200, { integrationer, krypteringKonfigurerad: !!nyckel });
|
||||
}
|
||||
if (req.method === "POST") {
|
||||
if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." });
|
||||
const nyckel = integrationsNyckel();
|
||||
if (!nyckel) {
|
||||
return svara(res, 503, {
|
||||
error: "Kryptering är inte konfigurerad (INTEGRATION_NYCKEL saknas) — uppgifter kan inte sparas.",
|
||||
});
|
||||
}
|
||||
const { leverantor, uppgifter, aktiv } = await lasKropp(req);
|
||||
const def = leverantorsDef(leverantor);
|
||||
if (!def) return svara(res, 400, { error: "Okänd leverantör." });
|
||||
if (!uppgifter || typeof uppgifter !== "object") {
|
||||
return svara(res, 400, { error: "Uppgifter saknas." });
|
||||
}
|
||||
// Endast leverantörens definierade fält sparas, och varje fält
|
||||
// måste ha ett värde — inga tomma nycklar i vila.
|
||||
const rena = {};
|
||||
for (const falt of def.falt) {
|
||||
const varde = uppgifter[falt.nyckel];
|
||||
if (typeof varde !== "string" || !varde.trim()) {
|
||||
return svara(res, 400, { error: `Fältet "${falt.etikett}" måste fyllas i.` });
|
||||
}
|
||||
if (varde.length > 2000) return svara(res, 400, { error: "Ett värde är för långt." });
|
||||
rena[falt.nyckel] = varde.trim();
|
||||
}
|
||||
await pool.query(
|
||||
`insert into integrationer (organisation_id, leverantor, uppgifter_krypt, aktiv)
|
||||
values ($1, $2, $3, $4)
|
||||
on conflict (organisation_id, leverantor)
|
||||
do update set uppgifter_krypt = excluded.uppgifter_krypt,
|
||||
aktiv = excluded.aktiv,
|
||||
uppdaterad = now(),
|
||||
senast_testad = null,
|
||||
senaste_status = null`,
|
||||
[anspr.org, leverantor, kryptera(JSON.stringify(rena), nyckel), aktiv !== false],
|
||||
);
|
||||
return svara(res, 200, { ok: true });
|
||||
}
|
||||
}
|
||||
|
||||
const integrationVag = vag.match(/^\/api\/integrationer\/([a-z0-9_]+)$/);
|
||||
if (req.method === "DELETE" && integrationVag) {
|
||||
if (anspr.roll !== "admin") return svara(res, 403, { error: "Kräver administratörsbehörighet." });
|
||||
await pool.query(`delete from integrationer where organisation_id = $1 and leverantor = $2`, [
|
||||
anspr.org,
|
||||
integrationVag[1],
|
||||
]);
|
||||
return svara(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
// Uppslag mot märkesspecifik koppling. Anropet görs alltid av
|
||||
// servern — kundens leverantörsnycklar når aldrig webbläsaren.
|
||||
const uppslagVag = vag.match(/^\/api\/integrationer\/([a-z0-9_]+)\/uppslag$/);
|
||||
if (req.method === "POST" && uppslagVag) {
|
||||
const nyckel = integrationsNyckel();
|
||||
const def = leverantorsDef(uppslagVag[1]);
|
||||
if (!def) return svara(res, 400, { error: "Okänd leverantör." });
|
||||
if (!nyckel) return svara(res, 503, { error: "Kryptering är inte konfigurerad." });
|
||||
|
||||
const { identifierare } = await lasKropp(req);
|
||||
if (typeof identifierare !== "string" || !/^[A-Za-z0-9-]{4,20}$/.test(identifierare.trim())) {
|
||||
return svara(res, 400, { error: "Ogiltig identifierare." });
|
||||
}
|
||||
const rad = await pool.query(
|
||||
`select uppgifter_krypt from integrationer
|
||||
where organisation_id = $1 and leverantor = $2 and aktiv = true`,
|
||||
[anspr.org, uppslagVag[1]],
|
||||
);
|
||||
if (rad.rowCount === 0) return svara(res, 404, { error: "Kopplingen är inte konfigurerad." });
|
||||
|
||||
let uppgifter;
|
||||
try {
|
||||
uppgifter = JSON.parse(dekryptera(rad.rows[0].uppgifter_krypt, nyckel));
|
||||
} catch {
|
||||
return svara(res, 500, { error: "Uppgifterna kunde inte läsas — spara om kopplingen." });
|
||||
}
|
||||
|
||||
const resultat = await gorUppslag(def, uppgifter, identifierare.trim().toUpperCase());
|
||||
await pool.query(
|
||||
`update integrationer set senast_testad = now(), senaste_status = $3
|
||||
where organisation_id = $1 and leverantor = $2`,
|
||||
[anspr.org, uppslagVag[1], resultat.ok ? "ok" : `fel: ${resultat.fel}`.slice(0, 200)],
|
||||
);
|
||||
if (!resultat.ok) return svara(res, 502, { error: resultat.fel });
|
||||
return svara(res, 200, { fordon: resultat.fordon });
|
||||
}
|
||||
|
||||
// Organisationens inställningar: vad som visas när ett ärende
|
||||
// startas (objekttyper, identifieringsmetoder). Alla inloggade
|
||||
// läser; endast systemadministratören ändrar.
|
||||
|
||||
@@ -82,6 +82,10 @@ describe("AI-orkestern", () => {
|
||||
["/api/oversikt", "/api/oversikt"],
|
||||
["/api/delad/{delningskod}", "delad"],
|
||||
["/api/delad/{delningskod}/beslut", "beslut"],
|
||||
["/api/integrationer", "/api/integrationer"],
|
||||
["/api/integrationer/leverantorer", "/api/integrationer/leverantorer"],
|
||||
["/api/integrationer/{leverantor}", "integrationVag"],
|
||||
["/api/integrationer/{leverantor}/uppslag", "uppslagVag"],
|
||||
];
|
||||
for (const [iSpec, iServer] of vagar) {
|
||||
expect(spec).toContain(`${iSpec}:`);
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// @vitest-environment node
|
||||
// Servermodulen körs bara i Node — jsdom saknar bland annat
|
||||
// AbortSignal.timeout, som uppslaget använder.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { dekryptera, gorUppslag, kryptera, maskera } from "../../../services/plattform/server.mjs";
|
||||
|
||||
const REGISTER = JSON.parse(readFileSync("services/plattform/integrationer.json", "utf8"));
|
||||
|
||||
describe("integrationsuppgifter i vila", () => {
|
||||
it("krypterar och dekrypterar med AES-256-GCM", () => {
|
||||
const nyckel = randomBytes(32);
|
||||
const hemlighet = JSON.stringify({ api_nyckel: "sk-verkstad-123", bas_url: "https://api.exempel.se/{vin}" });
|
||||
const paket = kryptera(hemlighet, nyckel);
|
||||
// Chiffertexten avslöjar ingenting.
|
||||
expect(paket).not.toContain("sk-verkstad-123");
|
||||
expect(paket.split(".")).toHaveLength(3);
|
||||
expect(dekryptera(paket, nyckel)).toBe(hemlighet);
|
||||
});
|
||||
|
||||
it("manipulerad chiffertext avvisas av autentiseringstaggen", () => {
|
||||
const nyckel = randomBytes(32);
|
||||
const paket = kryptera("hemligt", nyckel);
|
||||
const [iv, tagg, data] = paket.split(".");
|
||||
const trasig = `${iv}.${tagg}.${Buffer.from("annat innehåll").toString("base64")}`;
|
||||
expect(() => dekryptera(trasig, nyckel)).toThrow();
|
||||
// Fel nyckel går inte heller.
|
||||
expect(() => dekryptera(paket, randomBytes(32))).toThrow();
|
||||
});
|
||||
|
||||
it("maskerar hemligheter så bara de sista tecknen syns", () => {
|
||||
expect(maskera("sk-verkstad-9821")).toBe("••••9821");
|
||||
expect(maskera("abc")).toBe("••••");
|
||||
expect(maskera("")).toBe("");
|
||||
expect(maskera(undefined)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("leverantörsregistret är data, inte kod", () => {
|
||||
it("varje leverantör har fält och en komplett uppslagsdefinition", () => {
|
||||
expect(REGISTER.leverantorer.length).toBeGreaterThan(0);
|
||||
for (const lev of REGISTER.leverantorer) {
|
||||
expect(lev.id, lev.namn).toMatch(/^[a-z0-9_]+$/);
|
||||
expect(lev.falt.length, lev.namn).toBeGreaterThan(0);
|
||||
for (const falt of lev.falt) {
|
||||
expect(typeof falt.hemlig, `${lev.id}/${falt.nyckel}`).toBe("boolean");
|
||||
}
|
||||
// Varje leverantör måste ha minst ett hemligt fält (annars behövs
|
||||
// ingen kryptering) och en URL-mall att slå upp mot.
|
||||
expect(lev.falt.some((f: { hemlig: boolean }) => f.hemlig), lev.id).toBe(true);
|
||||
expect(Object.keys(lev.uppslag.svarsfalt).length, lev.id).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("uppslag mot leverantör", () => {
|
||||
const def = REGISTER.leverantorer.find((l: { id: string }) => l.id === "generisk_vin");
|
||||
|
||||
it("bygger URL ur mallen och mappar svaret till våra fält", async () => {
|
||||
let anropadUrl = "";
|
||||
let anropadeHeaders: Record<string, string> = {};
|
||||
const resultat = await gorUppslag(
|
||||
def,
|
||||
{ bas_url: "https://api.exempel.se/vin/{vin}", api_nyckel: "hemlig-nyckel" },
|
||||
"YV1DZ8256F2123456",
|
||||
async (url: string, init: { headers: Record<string, string> }) => {
|
||||
anropadUrl = url;
|
||||
anropadeHeaders = init.headers;
|
||||
return { ok: true, json: async () => ({ make: "Volvo", model: "XC60", year: 2019, okant: "x" }) };
|
||||
},
|
||||
);
|
||||
expect(anropadUrl).toBe("https://api.exempel.se/vin/YV1DZ8256F2123456");
|
||||
expect(anropadeHeaders.Authorization).toBe("Bearer hemlig-nyckel");
|
||||
expect(resultat.ok).toBe(true);
|
||||
expect(resultat.fordon).toEqual({ marke: "Volvo", modell: "XC60", arsmodell: "2019" });
|
||||
});
|
||||
|
||||
it("avvisar ogiltig bas-URL och rapporterar leverantörens fel ärligt", async () => {
|
||||
const utanUrl = await gorUppslag(def, { bas_url: "inte-en-url", api_nyckel: "x" }, "ABC123");
|
||||
expect(utanUrl.ok).toBe(false);
|
||||
expect(utanUrl.fel).toContain("Bas-URL");
|
||||
|
||||
const felsvar = await gorUppslag(
|
||||
def,
|
||||
{ bas_url: "https://api.exempel.se/{vin}", api_nyckel: "x" },
|
||||
"ABC123",
|
||||
async () => ({ ok: false, status: 401, json: async () => ({}) }),
|
||||
);
|
||||
expect(felsvar.ok).toBe(false);
|
||||
expect(felsvar.fel).toContain("401");
|
||||
|
||||
const tomtSvar = await gorUppslag(
|
||||
def,
|
||||
{ bas_url: "https://api.exempel.se/{vin}", api_nyckel: "x" },
|
||||
"ABC123",
|
||||
async () => ({ ok: true, json: async () => ({ helt: "andra falt" }) }),
|
||||
);
|
||||
expect(tomtSvar.ok).toBe(false);
|
||||
expect(tomtSvar.fel).toContain("inga kända fält");
|
||||
});
|
||||
|
||||
it("stödjer basic och query-autentisering utan leverantörsspecifik kod", async () => {
|
||||
const basicDef = REGISTER.leverantorer.find((l: { id: string }) => l.id === "vag_erwin");
|
||||
let headers: Record<string, string> = {};
|
||||
await gorUppslag(
|
||||
basicDef,
|
||||
{ bas_url: "https://erwin.exempel.se/{vin}", anvandarnamn: "verkstad", losenord: "hemligt" },
|
||||
"YV1DZ8256F2123456",
|
||||
async (_url: string, init: { headers: Record<string, string> }) => {
|
||||
headers = init.headers;
|
||||
return { ok: true, json: async () => ({ brand: "VW" }) };
|
||||
},
|
||||
);
|
||||
expect(headers.Authorization).toBe(`Basic ${Buffer.from("verkstad:hemligt").toString("base64")}`);
|
||||
|
||||
const queryDef = REGISTER.leverantorer.find((l: { id: string }) => l.id === "fordonsregister");
|
||||
let url = "";
|
||||
await gorUppslag(
|
||||
queryDef,
|
||||
{ bas_url: "https://fordon.exempel.se/{regnr}", api_nyckel: "n1" },
|
||||
"ABC123",
|
||||
async (u: string) => {
|
||||
url = u;
|
||||
return { ok: true, json: async () => ({ make: "Volvo" }) };
|
||||
},
|
||||
);
|
||||
expect(url).toBe("https://fordon.exempel.se/ABC123?key=n1");
|
||||
});
|
||||
});
|
||||
@@ -175,6 +175,75 @@ export async function hamtaFelorsaksstatistik(): Promise<{ orsak: string; antal:
|
||||
return ((await res.json()) as { orsaker: { orsak: string; antal: number }[] }).orsaker;
|
||||
}
|
||||
|
||||
// Märkesspecifika kopplingar. Uppgifterna lagras krypterat på servern
|
||||
// och returneras alltid maskerade — klienten ser aldrig hemligheterna.
|
||||
export interface LeverantorsFalt {
|
||||
nyckel: string;
|
||||
etikett: string;
|
||||
hemlig: boolean;
|
||||
}
|
||||
|
||||
export interface Leverantor {
|
||||
id: string;
|
||||
namn: string;
|
||||
beskrivning?: string;
|
||||
nyckeltyp?: string;
|
||||
falt: LeverantorsFalt[];
|
||||
}
|
||||
|
||||
export interface Integration {
|
||||
leverantor: string;
|
||||
namn: string;
|
||||
aktiv: boolean;
|
||||
uppdaterad: string;
|
||||
senast_testad: string | null;
|
||||
senaste_status: string | null;
|
||||
uppgifter: Record<string, string>;
|
||||
}
|
||||
|
||||
export async function hamtaLeverantorer(): Promise<Leverantor[]> {
|
||||
const res = await plattformFetch("/api/integrationer/leverantorer");
|
||||
if (!res.ok) throw new Error(`Fel ${res.status}`);
|
||||
return ((await res.json()) as { leverantorer: Leverantor[] }).leverantorer;
|
||||
}
|
||||
|
||||
export async function hamtaIntegrationer(): Promise<{ integrationer: Integration[]; krypteringKonfigurerad: boolean }> {
|
||||
const res = await plattformFetch("/api/integrationer");
|
||||
if (!res.ok) throw new Error(`Fel ${res.status}`);
|
||||
return (await res.json()) as { integrationer: Integration[]; krypteringKonfigurerad: boolean };
|
||||
}
|
||||
|
||||
export async function sparaIntegration(leverantor: string, uppgifter: Record<string, string>): Promise<void> {
|
||||
const res = await plattformFetch("/api/integrationer", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ leverantor, uppgifter }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
throw new Error(data.error ?? `Fel ${res.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function taBortIntegration(leverantor: string): Promise<void> {
|
||||
const res = await plattformFetch(`/api/integrationer/${leverantor}`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error(`Fel ${res.status}`);
|
||||
}
|
||||
|
||||
// Uppslag mot leverantören — anropet görs av servern med organisationens
|
||||
// krypterade uppgifter; klienten skickar bara identifieraren.
|
||||
export async function gorUppslag(
|
||||
leverantor: string,
|
||||
identifierare: string,
|
||||
): Promise<Record<string, string>> {
|
||||
const res = await plattformFetch(`/api/integrationer/${leverantor}/uppslag`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ identifierare }),
|
||||
});
|
||||
const data = (await res.json().catch(() => ({}))) as { fordon?: Record<string, string>; error?: string };
|
||||
if (!res.ok) throw new Error(data.error ?? `Fel ${res.status}`);
|
||||
return data.fordon ?? {};
|
||||
}
|
||||
|
||||
// Autentiserat anrop mot plattformen. En utgången token rensas (401) så
|
||||
// att appen faller tillbaka till lokalt läge tills nästa inloggning.
|
||||
export async function plattformFetch(vag: string, init?: RequestInit): Promise<Response> {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 på
|
||||
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 på 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}>
|
||||
|
||||
+18
-13
@@ -1,15 +1,20 @@
|
||||
import "@testing-library/jest-dom";
|
||||
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => {},
|
||||
}),
|
||||
});
|
||||
// Tester som kör servermoduler väljer node-miljön och har ingen window.
|
||||
if (typeof window === "undefined") {
|
||||
// Inget att förbereda — DOM-hjälpmedlen används inte där.
|
||||
} else {
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
writable: true,
|
||||
value: (query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: () => {},
|
||||
removeListener: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
dispatchEvent: () => {},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user