feat(opax-web): add read-only Git BFF and hardened UI

Replace the generic mcp-call socket path with two authenticated, read-only Git actions. Add response normalization and error masking, a purpose-built frontend, container build configuration, and socket handler unit tests.
This commit is contained in:
Chris Christiansen 2026-09-09 17:26:07 +00:00
parent bb2f61beb4
commit dc9f7b6626
6 changed files with 536 additions and 94 deletions

15
opax-web/.dockerignore Normal file
View File

@ -0,0 +1,15 @@
# Ekskluder lokale avhengighetsmapper fra build context
node_modules
**/node_modules
# Ekskluder lokale build-artefakter
dist
**/dist
# Ekskluder miljøfiler og Git-historikk
.env
.env.*
.git
# Ekskluder npm-debug-logger
npm-debug.log*

50
opax-web/Dockerfile Normal file
View File

@ -0,0 +1,50 @@
# === STADIUM 1: BUILD ===
# Bygger frontend-appen og installerer alle workspace-dependencies for build
FROM node:20-slim AS build
WORKDIR /app
# 1. Kopier kun manifests for å utnytte Docker layer caching
COPY package.json package-lock.json ./
COPY frontend/package.json ./frontend/
COPY backend/package.json ./backend/
# 2. Installer ALLE avhengigheter (inkl. dev) basert på lockfilen
RUN npm ci
# 3. Kopier resten av frontend-kilden og bygg den
COPY frontend/ ./frontend/
RUN npm run build --workspace=frontend
# === STADIUM 2: RUNTIME ===
# Bygger et slankt produksjons-image med kun det nødvendigste
FROM node:20-slim
WORKDIR /app
ENV NODE_ENV=production
# 1. Kopier kun manifests for en ren produksjonsinstallasjon
COPY package.json package-lock.json ./
COPY frontend/package.json ./frontend/
COPY backend/package.json ./backend/
# 2. Installer KUN produksjonsavhengigheter for hele workspacet
# --omit=dev skal hindre at frontend dev-dependencies (vite, etc.) installeres
RUN npm ci --omit=dev
# 3. Kopier backend-kildekoden, eid av non-root 'node'-bruker
COPY --chown=node:node backend/ ./backend/
# 4. Kopier det ferdigbygde frontend-resultatet fra BUILD-stadiet
COPY --chown=node:node --from=build /app/frontend/dist ./frontend/dist
# 5. Bruk non-root bruker for å kjøre applikasjonen
USER node
# Port-dokumentasjon (Cloud Run vil bruke PORT env var)
EXPOSE 8080
# Start backend-serveren
CMD ["node", "backend/server.js"]

View File

@ -1,5 +1,5 @@
import { randomUUID } from 'node:crypto'; import { callAgent } from './agentClient.js';
import { callAgent, AgentNotConfiguredError } from './agentClient.js'; import { registerBrowserSocketHandlers } from './socketHandlers.js';
import 'dotenv/config'; import 'dotenv/config';
import express from 'express'; import express from 'express';
import cors from 'cors'; import cors from 'cors';
@ -7,6 +7,8 @@ import session from 'express-session';
import { OAuth2Client } from 'google-auth-library'; import { OAuth2Client } from 'google-auth-library';
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import { Server } from 'socket.io'; import { Server } from 'socket.io';
import path from 'path';
import { fileURLToPath } from 'url';
const app = express(); const app = express();
const http = createServer(app); const http = createServer(app);
@ -14,6 +16,10 @@ const front = process.env.FRONTEND_URL || 'http://localhost:5173';
const emails = new Set((process.env.ALLOWED_EMAILS || '').split(',').filter(Boolean)); const emails = new Set((process.env.ALLOWED_EMAILS || '').split(',').filter(Boolean));
const oauth = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, `${front}/auth/callback`); const oauth = new OAuth2Client(process.env.GOOGLE_CLIENT_ID, process.env.GOOGLE_CLIENT_SECRET, `${front}/auth/callback`);
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const frontendDistPath = path.join(__dirname, '../frontend/dist');
const frontendIndexPath = path.join(frontendDistPath, 'index.html');
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
app.set('trust proxy', 1); app.set('trust proxy', 1);
} }
@ -34,10 +40,10 @@ const sm = session({
}, },
}); });
const io = new Server(http, { cors: { origin: front, credentials: true } }); const io = new Server(http, { cors: { origin: front, credentials: true } });
const allowed = new Set(['get_health', 'get_build_status', 'get_state', 'get_telemetry', 'get_git_status', 'get_git_branch', 'get_git_log', 'get_git_diff', 'list_repo_files', 'read_repo_file']);
app.use(cors({ origin: front, credentials: true })); app.use(cors({ origin: front, credentials: true }));
app.use(sm); app.use(sm);
app.use(express.static(frontendDistPath));
io.use((s, n) => sm(s.request, {}, n)); io.use((s, n) => sm(s.request, {}, n));
app.get('/health', (_, r) => r.json({ status: 'ok' })); app.get('/health', (_, r) => r.json({ status: 'ok' }));
@ -60,50 +66,33 @@ app.get('/auth/me', (q, r) => (q.session.user ? r.json(q.session.user) : r.statu
app.post('/auth/logout', (q, r) => q.session.destroy(() => r.json({ ok: true }))); app.post('/auth/logout', (q, r) => q.session.destroy(() => r.json({ ok: true })));
io.on('connection', (s) => { io.on('connection', (s) => {
let u = s.request.session?.user; const u = s.request.session?.user;
if (!u) return s.disconnect(); if (!u) return s.disconnect();
s.on('mcp-call', async ({ id, tool, args }) => { registerBrowserSocketHandlers(s, { callAgentFn: callAgent });
if (typeof id !== 'string' || id.length > 128) { });
return s.emit('mcp-result', { id, error: 'Invalid request id' });
}
if (typeof tool !== 'string' || !allowed.has(tool)) {
return s.emit('mcp-result', { id, error: 'Tool denied by server allowlist' });
}
try {
if (typeof args !== 'object' || args === null || Array.isArray(args)) {
return s.emit('mcp-result', { id, error: 'Invalid arguments' });
}
const argsString = JSON.stringify(args);
if (argsString.length > 16384) {
return s.emit('mcp-result', { id, error: 'Invalid arguments' });
}
} catch {
return s.emit('mcp-result', { id, error: 'Invalid arguments' });
}
const request_id = randomUUID(); app.get('*' , (req, res) => {
try { if (req.method !== 'GET') {
const result = await callAgent(tool, args); return res.status(404).end();
s.emit('mcp-result', { id, result }); }
} catch (e) {
if (e instanceof AgentNotConfiguredError) { const isApiRoute = /^\/(auth|socket\.io|health|api)(\/.*|\/?)$/.test(req.path);
console.log(JSON.stringify({ if (isApiRoute) {
request_id, return res.status(404).end();
event: 'agent_not_configured', }
category: 'agent_not_configured',
})); if (req.accepts('html')) {
s.emit('mcp-result', { id, error: 'Agent integration is not configured.' }); res.sendFile(frontendIndexPath, (err) => {
} else { if (err && !res.headersSent) {
console.log(JSON.stringify({ res.status(404).end();
request_id,
event: 'agent_request_failed',
category: 'agent_request_failed',
}));
s.emit('mcp-result', { id, error: 'An unexpected error occurred.' });
} }
});
} else {
if (!res.headersSent) {
res.status(404).end();
} }
}); }
}); });
http.listen(Number(process.env.PORT || 8080), '0.0.0.0'); http.listen(Number(process.env.PORT || 8080), '0.0.0.0');

View File

@ -0,0 +1,100 @@
// FORSLAG TIL opax-web/backend/socketHandlers.js (ENDELIG KORRIGERT)
/**
* Normaliserer resultatet fra MCP-toolen 'get_git_status'.
* Kaster feil hvis responsen ikke har forventet format.
* @param {any} data - Rådata fra agentClient.
* @returns {{status: string, details: string}} Normalisert status-objekt.
*/
function normalizeGitStatus(data) {
if (
!data ||
typeof data !== 'object' ||
Array.isArray(data) ||
typeof data.status !== 'string' ||
typeof data.details !== 'string'
) {
throw new Error('Unexpected Git status response shape.');
}
return {
status: data.status,
details: data.details,
};
}
/**
* Normaliserer resultatet fra MCP-toolen 'get_git_branch'.
* Kaster feil hvis responsen ikke har forventet format.
* @param {any} data - Rådata fra agentClient.
* @returns {{current_branch: string}} Normalisert branch-objekt.
*/
function normalizeGitBranch(data) {
if (
!data ||
typeof data !== 'object' ||
Array.isArray(data) ||
typeof data.current_branch !== 'string'
) {
// I motsetning til forrige versjon, tillater vi ikke lenger null her.
// Hvis branchen er borte, bør MCP-toolen returnere en tom string.
// Ugyldig format skal alltid feile.
throw new Error('Unexpected Git branch response shape.');
}
return {
current_branch: data.current_branch,
};
}
const SAFE_ACTIONS = {
'git:getStatus': {
tool: 'get_git_status',
responseEvent: 'git:status',
errorMessage: 'Kunne ikke hente Git-status.',
normalize: normalizeGitStatus,
},
'git:getBranch': {
tool: 'get_git_branch',
responseEvent: 'git:branch',
errorMessage: 'Kunne ikke hente aktiv branch.',
normalize: normalizeGitBranch,
},
};
/**
* Registrerer sikre, nettleser-eksponerte Socket.IO-handlere.
* @param {import('socket.io').Socket} socket - Den tilkoblede socketen.
* @param {{callAgentFn: function}} dependencies - Injisert avhengighet for å kalle MCP.
*/
export function registerBrowserSocketHandlers(socket, { callAgentFn }) {
// Avvis den gamle, usikre mcp-call-handleren
socket.on('mcp-call', (payload) => {
const safeId = (payload && typeof payload.id === 'string' && payload.id.length <= 128) ? payload.id : null;
socket.emit('mcp-result', {
id: safeId,
error: 'Denne funksjonen er ikke tilgjengelig.',
});
});
// Registrer de nye, sikre handlerne
for (const eventName in SAFE_ACTIONS) {
const config = SAFE_ACTIONS[eventName];
socket.on(eventName, async () => {
if (!socket.request.session?.user) {
return socket.emit(`${config.responseEvent}:error`, { message: 'Du må være logget inn.' });
}
try {
const result = await callAgentFn(config.tool, {});
const normalizedData = config.normalize(result);
socket.emit(`${config.responseEvent}:result`, { data: normalizedData });
} catch (e) {
// e.message logges ikke eller sendes til klienten.
socket.emit(`${config.responseEvent}:error`, { message: config.errorMessage });
}
});
}
}

View File

@ -0,0 +1,204 @@
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { registerBrowserSocketHandlers } from './socketHandlers.js';
// --- Test-hjelpere ---
class FakeSocket {
constructor(user = null) {
this.request = { session: { user } };
this.handlers = new Map();
this.emitted = [];
}
on(eventName, handler) {
this.handlers.set(eventName, handler);
}
emit(eventName, payload) {
this.emitted.push({ eventName, payload });
}
async trigger(eventName, payload) {
const handler = this.handlers.get(eventName);
if (handler) {
await handler(payload);
}
}
findEmitted(eventName) {
return this.emitted.find(e => e.eventName === eventName);
}
}
function createFakeCallAgent() {
const fakeCallAgent = async (tool, args) => {
fakeCallAgent.calls.push({ tool, args });
if (fakeCallAgent.nextError) {
throw fakeCallAgent.nextError;
}
return fakeCallAgent.nextResult;
};
fakeCallAgent.calls = [];
fakeCallAgent.nextResult = null;
fakeCallAgent.nextError = null;
fakeCallAgent.setNextResult = (result) => {
fakeCallAgent.nextResult = result;
fakeCallAgent.nextError = null;
};
fakeCallAgent.setNextError = (error) => {
fakeCallAgent.nextError = error;
fakeCallAgent.nextResult = null;
};
return fakeCallAgent;
}
// --- Tester ---
describe('registerBrowserSocketHandlers', () => {
let fakeSocket;
let fakeCallAgent;
beforeEach(() => {
fakeCallAgent = createFakeCallAgent();
});
describe('for unauthenticated user', () => {
beforeEach(() => {
fakeSocket = new FakeSocket(null); // Ingen bruker i session
registerBrowserSocketHandlers(fakeSocket, { callAgentFn: fakeCallAgent });
});
it('git:getStatus is rejected and does not call agent', async () => {
await fakeSocket.trigger('git:getStatus');
const errorEvent = fakeSocket.findEmitted('git:status:error');
assert.deepStrictEqual(errorEvent?.payload, { message: 'Du må være logget inn.' });
assert.strictEqual(fakeCallAgent.calls.length, 0);
assert.strictEqual(fakeSocket.emitted.length, 1);
});
it('git:getBranch is rejected and does not call agent', async () => {
await fakeSocket.trigger('git:getBranch');
const errorEvent = fakeSocket.findEmitted('git:branch:error');
assert.deepStrictEqual(errorEvent?.payload, { message: 'Du må være logget inn.' });
assert.strictEqual(fakeCallAgent.calls.length, 0);
assert.strictEqual(fakeSocket.emitted.length, 1);
});
});
describe('for authenticated user', () => {
beforeEach(() => {
fakeSocket = new FakeSocket({ email: 'test@example.com' });
registerBrowserSocketHandlers(fakeSocket, { callAgentFn: fakeCallAgent });
});
it('git:getStatus calls agent correctly and returns normalized data', async () => {
const mcpResult = { status: 'dirty', details: 'M file.txt', secret: 'should-be-stripped' };
fakeCallAgent.setNextResult(mcpResult);
await fakeSocket.trigger('git:getStatus');
assert.strictEqual(fakeCallAgent.calls.length, 1);
assert.deepStrictEqual(fakeCallAgent.calls[0], { tool: 'get_git_status', args: {} });
const resultEvent = fakeSocket.findEmitted('git:status:result');
assert.ok(resultEvent, 'Expected a result event');
assert.deepStrictEqual(resultEvent.payload.data, { status: 'dirty', details: 'M file.txt' });
});
it('git:getBranch calls agent correctly and returns normalized data', async () => {
const mcpResult = { current_branch: 'main', url: 'https://internal.example.invalid' };
fakeCallAgent.setNextResult(mcpResult);
await fakeSocket.trigger('git:getBranch');
assert.strictEqual(fakeCallAgent.calls.length, 1);
assert.deepStrictEqual(fakeCallAgent.calls[0], { tool: 'get_git_branch', args: {} });
const resultEvent = fakeSocket.findEmitted('git:branch:result');
assert.ok(resultEvent, 'Expected a result event');
assert.deepStrictEqual(resultEvent.payload.data, { current_branch: 'main' });
});
it('emits static error if git:getStatus agent response is malformed', async () => {
const malformedResults = [null, {}, [], 'a string', { status: 123, details: 'a' }];
for (const mcpResult of malformedResults) {
fakeCallAgent.setNextResult(mcpResult);
await fakeSocket.trigger('git:getStatus');
}
assert.strictEqual(fakeSocket.emitted.length, malformedResults.length);
assert.ok(fakeSocket.emitted.every(e => e.eventName === 'git:status:error'));
assert.ok(fakeSocket.emitted.every(e => e.payload.message === 'Kunne ikke hente Git-status.'));
});
it('emits static error if git:getBranch agent response is malformed', async () => {
const malformedResults = [null, {}, [], 'a string', { current_branch: 123 }];
for (const mcpResult of malformedResults) {
fakeCallAgent.setNextResult(mcpResult);
await fakeSocket.trigger('git:getBranch');
}
assert.strictEqual(fakeSocket.emitted.length, malformedResults.length);
assert.ok(fakeSocket.emitted.every(e => e.eventName === 'git:branch:error'));
assert.ok(fakeSocket.emitted.every(e => e.payload.message === 'Kunne ikke hente aktiv branch.'));
});
it('masks agent error for git:getStatus and returns static message', async () => {
const internalError = new Error('Contains test-secret-must-not-leak');
fakeCallAgent.setNextError(internalError);
await fakeSocket.trigger('git:getStatus');
const errorEvent = fakeSocket.findEmitted('git:status:error');
assert.ok(errorEvent, 'Expected an error event');
assert.deepStrictEqual(errorEvent.payload, { message: 'Kunne ikke hente Git-status.' });
assert.strictEqual(fakeSocket.findEmitted('git:status:result'), undefined);
});
it('masks agent error for git:getBranch and returns static message', async () => {
const internalError = new Error('Another secret for https://internal.example.invalid');
fakeCallAgent.setNextError(internalError);
await fakeSocket.trigger('git:getBranch');
const errorEvent = fakeSocket.findEmitted('git:branch:error');
assert.ok(errorEvent, 'Expected an error event');
assert.deepStrictEqual(errorEvent.payload, { message: 'Kunne ikke hente aktiv branch.' });
assert.strictEqual(fakeSocket.findEmitted('git:branch:result'), undefined);
});
});
describe('legacy mcp-call handler', () => {
beforeEach(() => {
fakeSocket = new FakeSocket({ email: 'test@example.com' });
registerBrowserSocketHandlers(fakeSocket, { callAgentFn: fakeCallAgent });
});
it('rejects calls and does not call agent', async () => {
const payload = { id: 'test-id', tool: 'commit_and_push_files', args: { message: 'evil' } };
await fakeSocket.trigger('mcp-call', payload);
assert.strictEqual(fakeCallAgent.calls.length, 0);
const resultEvent = fakeSocket.findEmitted('mcp-result');
assert.ok(resultEvent, 'Expected a result event');
assert.deepStrictEqual(resultEvent.payload, { id: 'test-id', error: 'Denne funksjonen er ikke tilgjengelig.' });
});
it('handles malformed payloads gracefully and uses null id', async () => {
const malformed = [null, 'a string', { id: 'a'.repeat(129) }];
for (const payload of malformed) {
await fakeSocket.trigger('mcp-call', payload);
}
assert.strictEqual(fakeCallAgent.calls.length, 0);
assert.strictEqual(fakeSocket.emitted.length, malformed.length);
assert.ok(fakeSocket.emitted.every(e => e.eventName === 'mcp-result'));
assert.ok(fakeSocket.emitted.every(e => e.payload.id === null));
});
});
});

View File

@ -1,18 +1,44 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { io, Socket } from 'socket.io-client'; import { io, Socket } from 'socket.io-client';
const tools: string[] = [ interface GitStatusData {
'get_health', status: string;
'get_build_status', details: string;
'get_state', }
'get_telemetry',
'get_git_status', interface GitBranchData {
'get_git_branch', current_branch: string;
'get_git_log', }
'get_git_diff',
'list_repo_files', function isDataEnvelope(payload: unknown): payload is { data: unknown } {
'read_repo_file', return (
]; typeof payload === 'object' &&
payload !== null &&
!Array.isArray(payload) &&
'data' in payload
);
}
function isGitStatusData(data: unknown): data is GitStatusData {
const d = data as GitStatusData;
return (
!!d &&
typeof d === 'object' &&
!Array.isArray(d) &&
typeof d.status === 'string' &&
typeof d.details === 'string'
);
}
function isGitBranchData(data: unknown): data is GitBranchData {
const d = data as GitBranchData;
return (
!!d &&
typeof d === 'object' &&
!Array.isArray(d) &&
typeof d.current_branch === 'string'
);
}
interface User { interface User {
email: string; email: string;
@ -21,8 +47,13 @@ interface User {
export default function App() { export default function App() {
const [user, setUser] = useState<User | null>(null); const [user, setUser] = useState<User | null>(null);
const [output, setOutput] = useState<string[]>(['OPAX Web TUI', 'Type tools or: mcp get_health', '']); const [statusData, setStatusData] = useState<GitStatusData | null>(null);
const [input, setInput] = useState(''); const [branchData, setBranchData] = useState<GitBranchData | null>(null);
const [statusLoading, setStatusLoading] = useState<boolean>(false);
const [branchLoading, setBranchLoading] = useState<boolean>(false);
const [statusError, setStatusError] = useState<string | null>(null);
const [branchError, setBranchError] = useState<string | null>(null);
const socketRef = useRef<Socket | null>(null); const socketRef = useRef<Socket | null>(null);
useEffect(() => { useEffect(() => {
@ -37,15 +68,79 @@ export default function App() {
const socket = io('/'); const socket = io('/');
socketRef.current = socket; socketRef.current = socket;
socket.on('mcp-result', (x) => { const onStatusResult = (payload: unknown) => {
setOutput((a) => [...a, x.error ? 'ERROR ' + x.error : JSON.stringify(x.result, null, 2), '']); setStatusLoading(false);
}); if (isDataEnvelope(payload) && isGitStatusData(payload.data)) {
setStatusData({ status: payload.data.status, details: payload.data.details });
} else {
setStatusData(null);
setStatusError('Ugyldig svar for Git-status.');
}
};
const onStatusError = () => {
setStatusData(null);
setStatusLoading(false);
setStatusError('Kunne ikke hente Git-status.');
};
const onBranchResult = (payload: unknown) => {
setBranchLoading(false);
if (isDataEnvelope(payload) && isGitBranchData(payload.data)) {
setBranchData({ current_branch: payload.data.current_branch });
} else {
setBranchData(null);
setBranchError('Ugyldig svar for aktiv branch.');
}
};
const onBranchError = () => {
setBranchData(null);
setBranchLoading(false);
setBranchError('Kunne ikke hente aktiv branch.');
};
socket.on('git:status:result', onStatusResult);
socket.on('git:status:error', onStatusError);
socket.on('git:branch:result', onBranchResult);
socket.on('git:branch:error', onBranchError);
return () => { return () => {
socket.off('git:status:result', onStatusResult);
socket.off('git:status:error', onStatusError);
socket.off('git:branch:result', onBranchResult);
socket.off('git:branch:error', onBranchError);
socket.close(); socket.close();
socketRef.current = null;
}; };
}, [user]); }, [user]);
const handleGetStatus = () => {
if (!socketRef.current) {
setStatusData(null);
setStatusLoading(false);
setStatusError('Kunne ikke hente Git-status.');
return;
}
setStatusError(null);
setStatusData(null);
setStatusLoading(true);
socketRef.current.emit('git:getStatus');
};
const handleGetBranch = () => {
if (!socketRef.current) {
setBranchData(null);
setBranchLoading(false);
setBranchError('Kunne ikke hente aktiv branch.');
return;
}
setBranchError(null);
setBranchData(null);
setBranchLoading(true);
socketRef.current.emit('git:getBranch');
};
if (!user) { if (!user) {
return ( return (
<main className="login"> <main className="login">
@ -55,29 +150,6 @@ export default function App() {
); );
} }
function go(e: React.FormEvent) {
e.preventDefault();
let v = input.trim();
setInput('');
setOutput((a) => [...a, '$ ' + v]);
if (v === 'tools') {
return setOutput((a) => [...a, ...tools, '']);
}
let m = v.match(/^mcp\s+(\S+)(?:\s+(.+))?$/);
if (!m) {
return setOutput((a) => [...a, 'Use mcp <tool> [JSON]', '']);
}
try {
socketRef.current?.emit('mcp-call', {
id: crypto.randomUUID(),
tool: m[1],
args: m[2] ? JSON.parse(m[2]) : {},
});
} catch {
setOutput((a) => [...a, 'JSON arguments invalid', '']);
}
}
return ( return (
<main> <main>
<header> <header>
@ -85,18 +157,30 @@ export default function App() {
<span>{user.email}</span> <span>{user.email}</span>
</header> </header>
<section> <section>
<aside> <div>
{tools.map((tool) => ( <h2>Git Status</h2>
<button key={tool} onClick={() => setInput('mcp ' + tool)}> <button onClick={handleGetStatus} disabled={statusLoading}>
{tool} {statusLoading ? 'Laster...' : 'Hent Git-status'}
</button> </button>
))} {statusError && <p className="error">{statusError}</p>}
</aside> {statusData && (
<div className="term"> <pre>
<pre>{output.join('\n')}</pre> <strong>Status:</strong> {statusData.status}\n\n
<form onSubmit={go}> <strong>Detaljer:</strong>\n{statusData.details || '(ingen endringer)'}
$ <input autoFocus value={input} onChange={(e) => setInput(e.target.value)} /> </pre>
</form> )}
</div>
<div>
<h2>Aktiv Branch</h2>
<button onClick={handleGetBranch} disabled={branchLoading}>
{branchLoading ? 'Laster...' : 'Hent aktiv branch'}
</button>
{branchError && <p className="error">{branchError}</p>}
{branchData && (
<p>
<strong>Nåværende branch:</strong> {branchData.current_branch}
</p>
)}
</div> </div>
</section> </section>
</main> </main>