From dc9f7b66266c9e194ee6930fc507a6584aecfed2 Mon Sep 17 00:00:00 2001 From: Chris Christiansen Date: Wed, 9 Sep 2026 17:26:07 +0000 Subject: [PATCH] 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. --- opax-web/.dockerignore | 15 ++ opax-web/Dockerfile | 50 ++++++ opax-web/backend/server.js | 73 ++++----- opax-web/backend/socketHandlers.js | 100 ++++++++++++ opax-web/backend/socketHandlers.test.js | 204 ++++++++++++++++++++++++ opax-web/frontend/src/App.tsx | 188 ++++++++++++++++------ 6 files changed, 536 insertions(+), 94 deletions(-) create mode 100644 opax-web/.dockerignore create mode 100644 opax-web/Dockerfile create mode 100644 opax-web/backend/socketHandlers.js create mode 100644 opax-web/backend/socketHandlers.test.js diff --git a/opax-web/.dockerignore b/opax-web/.dockerignore new file mode 100644 index 0000000..1e4b14a --- /dev/null +++ b/opax-web/.dockerignore @@ -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* diff --git a/opax-web/Dockerfile b/opax-web/Dockerfile new file mode 100644 index 0000000..c6afbb6 --- /dev/null +++ b/opax-web/Dockerfile @@ -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"] diff --git a/opax-web/backend/server.js b/opax-web/backend/server.js index c2dc456..7a89287 100644 --- a/opax-web/backend/server.js +++ b/opax-web/backend/server.js @@ -1,5 +1,5 @@ -import { randomUUID } from 'node:crypto'; -import { callAgent, AgentNotConfiguredError } from './agentClient.js'; +import { callAgent } from './agentClient.js'; +import { registerBrowserSocketHandlers } from './socketHandlers.js'; import 'dotenv/config'; import express from 'express'; import cors from 'cors'; @@ -7,6 +7,8 @@ import session from 'express-session'; import { OAuth2Client } from 'google-auth-library'; import { createServer } from 'node:http'; import { Server } from 'socket.io'; +import path from 'path'; +import { fileURLToPath } from 'url'; const app = express(); 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 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') { app.set('trust proxy', 1); } @@ -34,10 +40,10 @@ const sm = session({ }, }); 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(sm); +app.use(express.static(frontendDistPath)); io.use((s, n) => sm(s.request, {}, n)); 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 }))); io.on('connection', (s) => { - let u = s.request.session?.user; + const u = s.request.session?.user; if (!u) return s.disconnect(); - s.on('mcp-call', async ({ id, tool, args }) => { - 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' }); - } + registerBrowserSocketHandlers(s, { callAgentFn: callAgent }); +}); - const request_id = randomUUID(); - try { - const result = await callAgent(tool, args); - s.emit('mcp-result', { id, result }); - } catch (e) { - if (e instanceof AgentNotConfiguredError) { - console.log(JSON.stringify({ - request_id, - event: 'agent_not_configured', - category: 'agent_not_configured', - })); - s.emit('mcp-result', { id, error: 'Agent integration is not configured.' }); - } else { - console.log(JSON.stringify({ - request_id, - event: 'agent_request_failed', - category: 'agent_request_failed', - })); - s.emit('mcp-result', { id, error: 'An unexpected error occurred.' }); +app.get('*' , (req, res) => { + if (req.method !== 'GET') { + return res.status(404).end(); + } + + const isApiRoute = /^\/(auth|socket\.io|health|api)(\/.*|\/?)$/.test(req.path); + if (isApiRoute) { + return res.status(404).end(); + } + + if (req.accepts('html')) { + res.sendFile(frontendIndexPath, (err) => { + if (err && !res.headersSent) { + res.status(404).end(); } + }); + } else { + if (!res.headersSent) { + res.status(404).end(); } - }); + } }); http.listen(Number(process.env.PORT || 8080), '0.0.0.0'); diff --git a/opax-web/backend/socketHandlers.js b/opax-web/backend/socketHandlers.js new file mode 100644 index 0000000..26c84ad --- /dev/null +++ b/opax-web/backend/socketHandlers.js @@ -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 }); + } + }); + } +} diff --git a/opax-web/backend/socketHandlers.test.js b/opax-web/backend/socketHandlers.test.js new file mode 100644 index 0000000..27f617a --- /dev/null +++ b/opax-web/backend/socketHandlers.test.js @@ -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)); + }); + }); +}); diff --git a/opax-web/frontend/src/App.tsx b/opax-web/frontend/src/App.tsx index 7b485f2..d0e3423 100644 --- a/opax-web/frontend/src/App.tsx +++ b/opax-web/frontend/src/App.tsx @@ -1,18 +1,44 @@ import { useEffect, useRef, useState } from 'react'; import { io, Socket } from 'socket.io-client'; -const tools: string[] = [ - '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', -]; +interface GitStatusData { + status: string; + details: string; +} + +interface GitBranchData { + current_branch: string; +} + +function isDataEnvelope(payload: unknown): payload is { data: unknown } { + 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 { email: string; @@ -21,8 +47,13 @@ interface User { export default function App() { const [user, setUser] = useState(null); - const [output, setOutput] = useState(['OPAX Web TUI', 'Type tools or: mcp get_health', '']); - const [input, setInput] = useState(''); + const [statusData, setStatusData] = useState(null); + const [branchData, setBranchData] = useState(null); + const [statusLoading, setStatusLoading] = useState(false); + const [branchLoading, setBranchLoading] = useState(false); + const [statusError, setStatusError] = useState(null); + const [branchError, setBranchError] = useState(null); + const socketRef = useRef(null); useEffect(() => { @@ -37,15 +68,79 @@ export default function App() { const socket = io('/'); socketRef.current = socket; - socket.on('mcp-result', (x) => { - setOutput((a) => [...a, x.error ? 'ERROR ' + x.error : JSON.stringify(x.result, null, 2), '']); - }); + const onStatusResult = (payload: unknown) => { + 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 () => { + 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(); + socketRef.current = null; }; }, [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) { return (
@@ -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 [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 (
@@ -85,18 +157,30 @@ export default function App() { {user.email}
- -
-
{output.join('\n')}
-
- $ setInput(e.target.value)} /> -
+
+

Git Status

+ + {statusError &&

{statusError}

} + {statusData && ( +
+              Status: {statusData.status}\n\n
+              Detaljer:\n{statusData.details || '(ingen endringer)'}
+            
+ )} +
+
+

Aktiv Branch

+ + {branchError &&

{branchError}

} + {branchData && ( +

+ Nåværende branch: {branchData.current_branch} +

+ )}