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/.gitignore b/opax-web/.gitignore new file mode 100644 index 0000000..ddfa183 --- /dev/null +++ b/opax-web/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +.env +dist/ + +backend/.env 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/README-opax-web.md b/opax-web/README-opax-web.md new file mode 100644 index 0000000..65dde98 --- /dev/null +++ b/opax-web/README-opax-web.md @@ -0,0 +1,55 @@ +# OPAX / Web TUI Intern Dokumentasjon + +Denne filen beskriver hvordan man kjører og overvåker backend-tjenesten for OPAX / Web TUI. + +## Kjøre i produksjonsmodus + +Applikasjonen er designet for å kjøre i en Node.js-container. For å starte serveren i produksjonsmodus, sørg for at følgende er satt: + +1. **Miljøvariabel:** `NODE_ENV` må være satt til `production`. +2. **Secrets:** Følgende miljøvariabler må være tilgjengelige i kjøremiljøet, f.eks. via Secret Manager: + * `GOOGLE_CLIENT_ID` + * `GOOGLE_CLIENT_SECRET` + * `SESSION_SECRET` +3. **Konfigurasjon:** + * `FRONTEND_URL`: Den fulle URL-en til frontend-applikasjonen (f.eks. `https://opax.vauco.no`). + * `ALLOWED_EMAILS`: En komma-separert liste med e-postadresser som har lov til å logge inn. + * `PORT`: Porten serveren skal lytte på (default er `8080`). + +Serveren startes ved å kjøre hovedfilen: + +```bash +node opax-web/backend/server.js +``` + +## Health Check + +Tjenesten har et health check-endepunkt for ekstern overvåking. + +- **URL:** `/health` +- **Metode:** `GET` +- **Suksessrespons (HTTP 200):** + ```json + { + "status": "ok" + } + ``` + +## API-endepunkter (Socket.IO) + +Klienten kommuniserer med serveren via Socket.IO for å hente Git-informasjon. All kommunikasjon krever en aktiv, autentisert sesjon. + +1. **Hent Git Status** + - **Klient-event:** `git:getStatus` + - **Server-respons (suksess):** `git:status:result` med payload `{ data: { status: string, details: string } }` + - **Server-respons (feil):** `git:status:error` med payload `{ message: string }` + +2. **Hent Aktiv Branch** + - **Klient-event:** `git:getBranch` + - **Server-respons (suksess):** `git:branch:result` med payload `{ data: { current_branch: string } }` + - **Server-respons (feil):** `git:branch:error` med payload `{ message: string }` + +## Driftstips + +- **Overvåking:** Health-check-endepunktet `/health` kan brukes av en ekstern monitortjeneste (f.eks. Google Cloud Monitoring Uptime Check). Konfigurer monitoren til å sende en GET-forespørsel hvert minutt og varsle ved ikke-200-status over en periode på for eksempel 3-5 minutter. +- **Logging:** All applikasjonslogging (inkludert feil) skrives til `stdout`/`stderr`. I et container-miljø som Cloud Run, blir disse loggene automatisk samlet inn og kan sees i Google Cloud Logging. Bruk filter i Logging for å isolere logger fra denne spesifikke Cloud Run-tjenesten. diff --git a/opax-web/README.md b/opax-web/README.md new file mode 100644 index 0000000..b5a1e8d --- /dev/null +++ b/opax-web/README.md @@ -0,0 +1,41 @@ +# OPAX Web TUI + +This project provides a starter scaffold for a Google-login-gated web control plane. It consists of a React frontend and a Node.js backend. + +The frontend provides a terminal-like interface for interacting with the system. The backend handles user authentication via Google OAuth2 and provides a secure boundary for potential agent integrations. + +## Local Development + +To run the application locally, first install the dependencies: +```bash +npm install +``` + +Then, start the development server: +```bash +npm run dev +``` + +### Configuration + +The backend requires several environment variables for configuration. For local development, you can create a `.env` file in the `opax-web/backend/` directory. + +- Do not commit `.env` files to Git. +- **NEVER** use or copy production secrets into a local `.env` file. +- For configuration that requires secrets, use local, rotatable test values. +- Production secrets must be stored and delivered via the dedicated secrets solution described in `SECURITY.md`. + +Required configuration keys: +- `FRONTEND_URL` +- `ALLOWED_EMAILS` +- `GOOGLE_CLIENT_ID` +- `GOOGLE_CLIENT_SECRET` +- `SESSION_SECRET` + +**Note:** The outbound agent integration is currently disabled by default (fail-closed). The backend will not attempt to connect to any external agent services. + +## Security + +**IMPORTANT:** Never commit passwords, tokens, OAuth client secrets, or service account keys to the Git repository. If any credential is accidentally exposed, it **MUST** be revoked and rotated immediately. + +For more details, see `SECURITY.md`. diff --git a/opax-web/SECURITY.md b/opax-web/SECURITY.md new file mode 100644 index 0000000..d9d4d01 --- /dev/null +++ b/opax-web/SECURITY.md @@ -0,0 +1,14 @@ +# Security Policy + +## Responsible Disclosure + +To report a security vulnerability, please send a private email to `chris.christiansen@vauco.no`. + +Do not include sensitive information, customer data, or secrets in public GitHub issues or chat discussions. + +## Credential Security + +- **NEVER** commit credentials, secrets, or tokens to the Git repository. +- **NEVER** include credentials in logs, frontend code, or any other uncontrolled files. +- If a credential is accidentally exposed, it **MUST** be rotated immediately. +- For production environments, all secrets must be stored in a dedicated secret management service (e.g., Google Secret Manager) and accessed using least-privilege IAM principles. diff --git a/opax-web/backend/.env.example b/opax-web/backend/.env.example new file mode 100644 index 0000000..1bd4990 --- /dev/null +++ b/opax-web/backend/.env.example @@ -0,0 +1,7 @@ +PORT=8080 +FRONTEND_URL=http://localhost:5173 +GOOGLE_CLIENT_ID=replace-me +GOOGLE_CLIENT_SECRET=store-only-in-secret-manager +SESSION_SECRET=generate-a-long-random-secret +ALLOWED_EMAILS=your-email@example.com +MCP_GATEWAY_URL=https://your-controlled-gateway.example diff --git a/opax-web/backend/agentClient.js b/opax-web/backend/agentClient.js new file mode 100644 index 0000000..1902a82 --- /dev/null +++ b/opax-web/backend/agentClient.js @@ -0,0 +1,139 @@ +import { randomUUID } from 'node:crypto'; + +const TIMEOUT_MS = 10000; +const MAX_REQUEST_SIZE = 16384; +const MAX_RESPONSE_SIZE = 1024 * 1024; +const GENERIC_ERROR_MESSAGE = 'Agent request failed.'; + +const READ_ONLY_TOOLS = new Set([ + 'get_health', + 'get_build_status', + 'get_state', + 'get_telemetry', +]); + +export class AgentNotConfiguredError extends Error { + constructor() { + super('Agent integration is not configured.'); + this.name = 'AgentNotConfiguredError'; + } +} + +function validateMcpUrl(urlString, allowedOrigin) { + const url = new URL(urlString); + + if (url.username || url.password || url.hash) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + if (url.protocol === 'http:') { + const isLoopback = + url.hostname === 'localhost' || + url.hostname === '127.0.0.1' || + url.hostname === '[::1]'; + + if (!isLoopback) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + return url; + } + + if (url.protocol !== 'https:' || !allowedOrigin) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + const configuredOrigin = new URL(allowedOrigin); + + if (url.origin !== configuredOrigin.origin) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + return url; +} + +export async function callAgent(tool, args) { + const mcpUrl = process.env.OPAX_MCP_URL?.trim(); + const mcpSecret = process.env.MCP_SECRET?.trim(); + + if (!mcpUrl || !mcpSecret) { + throw new AgentNotConfiguredError(); + } + + try { + const allowedOrigin = process.env.OPAX_MCP_ALLOWED_ORIGIN?.trim(); + + if (!READ_ONLY_TOOLS.has(tool)) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + const validatedUrl = validateMcpUrl(mcpUrl, allowedOrigin); + + const requestBody = JSON.stringify({ + jsonrpc: '2.0', + id: randomUUID(), + method: 'tools/call', + params: { + name: tool, + arguments: args, + }, + }); + + if (requestBody.length > MAX_REQUEST_SIZE) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS); + + try { + const response = await fetch(validatedUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${mcpSecret}`, + }, + body: requestBody, + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + const responseText = await response.text(); + + if (responseText.length > MAX_RESPONSE_SIZE) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + const rpcResponse = JSON.parse(responseText); + + if (rpcResponse.error) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + const content = rpcResponse.result?.content; + + if (!Array.isArray(content) || content.length === 0) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + const firstContent = content[0]; + + if ( + firstContent?.type !== 'text' || + typeof firstContent.text !== 'string' || + firstContent.text.length > MAX_RESPONSE_SIZE + ) { + throw new Error(GENERIC_ERROR_MESSAGE); + } + + return JSON.parse(firstContent.text); + } finally { + clearTimeout(timeoutId); + } + } catch { + throw new Error(GENERIC_ERROR_MESSAGE); + } +} diff --git a/opax-web/backend/agentClient.test.js b/opax-web/backend/agentClient.test.js new file mode 100644 index 0000000..dbf1789 --- /dev/null +++ b/opax-web/backend/agentClient.test.js @@ -0,0 +1,403 @@ +import { after, afterEach, before, beforeEach, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { + AgentNotConfiguredError, + callAgent, +} from './agentClient.js'; + +const DUMMY_SECRET = 'test-secret-not-production'; +const DUMMY_TOOL = 'get_health'; +const GENERIC_ERROR_MESSAGE = 'Agent request failed.'; +const MAX_RESPONSE_SIZE = 1024 * 1024; +const ENV_KEYS = [ + 'OPAX_MCP_URL', + 'MCP_SECRET', + 'OPAX_MCP_ALLOWED_ORIGIN', +]; + +const originalEnv = Object.fromEntries( + ENV_KEYS.map((key) => [key, process.env[key]]), +); +const originalFetch = globalThis.fetch; + +function restoreEnvironment() { + for (const key of ENV_KEYS) { + if (originalEnv[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = originalEnv[key]; + } + } +} + +function clearTestEnvironment() { + for (const key of ENV_KEYS) { + delete process.env[key]; + } +} + +function configureLocalMock(url) { + process.env.OPAX_MCP_URL = url; + process.env.MCP_SECRET = DUMMY_SECRET; + delete process.env.OPAX_MCP_ALLOWED_ORIGIN; +} + +async function expectGenericFailure(action) { + await assert.rejects(action, { + message: GENERIC_ERROR_MESSAGE, + }); +} + +async function expectNoFetch(action) { + let fetchCalled = false; + + globalThis.fetch = async () => { + fetchCalled = true; + throw new Error('fetch must not be called'); + }; + + try { + await action(); + assert.equal(fetchCalled, false); + } finally { + globalThis.fetch = originalFetch; + } +} + +function sendJson(response, statusCode, body) { + response.writeHead(statusCode, { + 'Content-Type': 'application/json', + }); + response.end(JSON.stringify(body)); +} + +describe('callAgent', { concurrency: false }, () => { + beforeEach(() => { + clearTestEnvironment(); + globalThis.fetch = originalFetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + restoreEnvironment(); + }); + + after(() => { + globalThis.fetch = originalFetch; + restoreEnvironment(); + }); + + describe('configuration and local validation', () => { + it('rejects a missing MCP URL with AgentNotConfiguredError', async () => { + process.env.MCP_SECRET = DUMMY_SECRET; + + await assert.rejects( + () => callAgent(DUMMY_TOOL, {}), + AgentNotConfiguredError, + ); + }); + + it('rejects a missing MCP secret with AgentNotConfiguredError', async () => { + process.env.OPAX_MCP_URL = 'http://127.0.0.1:12345'; + + await assert.rejects( + () => callAgent(DUMMY_TOOL, {}), + AgentNotConfiguredError, + ); + }); + + it('rejects whitespace-only MCP configuration', async () => { + process.env.OPAX_MCP_URL = ' '; + process.env.MCP_SECRET = DUMMY_SECRET; + + await assert.rejects( + () => callAgent(DUMMY_TOOL, {}), + AgentNotConfiguredError, + ); + + process.env.OPAX_MCP_URL = 'http://127.0.0.1:12345'; + process.env.MCP_SECRET = ' '; + + await assert.rejects( + () => callAgent(DUMMY_TOOL, {}), + AgentNotConfiguredError, + ); + }); + + it('rejects invalid and unsafe URLs before fetch', async () => { + const unsafeUrls = [ + 'not a URL', + 'ftp://127.0.0.1:12345', + 'http://example.com', + 'http://user:password@127.0.0.1:12345', + 'http://127.0.0.1:12345#fragment', + ]; + + for (const url of unsafeUrls) { + process.env.OPAX_MCP_URL = url; + process.env.MCP_SECRET = DUMMY_SECRET; + + await expectNoFetch(() => + expectGenericFailure(() => callAgent(DUMMY_TOOL, {})), + ); + } + }); + + it('rejects HTTPS without an allowed origin before fetch', async () => { + process.env.OPAX_MCP_URL = 'https://example.invalid'; + process.env.MCP_SECRET = DUMMY_SECRET; + + await expectNoFetch(() => + expectGenericFailure(() => callAgent(DUMMY_TOOL, {})), + ); + }); + + it('rejects HTTPS with a mismatched origin before fetch', async () => { + process.env.OPAX_MCP_URL = 'https://example.invalid'; + process.env.MCP_SECRET = DUMMY_SECRET; + process.env.OPAX_MCP_ALLOWED_ORIGIN = 'https://allowed.invalid'; + + await expectNoFetch(() => + expectGenericFailure(() => callAgent(DUMMY_TOOL, {})), + ); + }); + + it('rejects non-allowlisted and mutating tools before fetch', async () => { + process.env.OPAX_MCP_URL = 'http://127.0.0.1:12345'; + process.env.MCP_SECRET = DUMMY_SECRET; + + for (const tool of ['unknown_tool', 'commit_and_push_files']) { + await expectNoFetch(() => + expectGenericFailure(() => callAgent(tool, {})), + ); + } + }); + + it('rejects circular arguments before fetch', async () => { + process.env.OPAX_MCP_URL = 'http://127.0.0.1:12345'; + process.env.MCP_SECRET = DUMMY_SECRET; + + const circular = {}; + circular.self = circular; + + await expectNoFetch(() => + expectGenericFailure(() => callAgent(DUMMY_TOOL, circular)), + ); + }); + + it('rejects an oversized request before fetch', async () => { + process.env.OPAX_MCP_URL = 'http://127.0.0.1:12345'; + process.env.MCP_SECRET = DUMMY_SECRET; + + const largeArguments = { + data: 'a'.repeat(17000), + }; + + await expectNoFetch(() => + expectGenericFailure(() => callAgent(DUMMY_TOOL, largeArguments)), + ); + }); + }); + + describe('local MCP mock', () => { + let server; + let serverUrl; + let handler; + + before(async () => { + server = createServer((request, response) => { + Promise.resolve(handler?.(request, response)) + .catch(() => { + if (!response.headersSent) { + response.writeHead(500); + } + response.end(); + }); + }); + + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + + const address = server.address(); + serverUrl = `http://127.0.0.1:${address.port}`; + }); + + beforeEach(() => { + configureLocalMock(serverUrl); + + handler = (_request, response) => { + response.writeHead(500); + response.end(); + }; + }); + + after(async () => { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + }); + + it('returns parsed inner JSON from a valid JSON-RPC response', async () => { + const expected = { + ok: true, + status: 'healthy', + }; + + handler = (_request, response) => { + sendJson(response, 200, { + jsonrpc: '2.0', + id: 'mock-response-id', + result: { + content: [{ + type: 'text', + text: JSON.stringify(expected), + }], + }, + }); + }; + + const result = await callAgent(DUMMY_TOOL, { + check: true, + }); + + assert.deepEqual(result, expected); + }); + + it('sends the required JSON-RPC request contract', async () => { + const argumentsValue = { + scope: 'local-test', + }; + let received; + + handler = async (request, response) => { + let body = ''; + + for await (const chunk of request) { + body += chunk; + } + + received = { + method: request.method, + contentType: request.headers['content-type'], + authorization: request.headers.authorization, + body: JSON.parse(body), + }; + + sendJson(response, 200, { + jsonrpc: '2.0', + id: received.body.id, + result: { + content: [{ + type: 'text', + text: '{}', + }], + }, + }); + }; + + await callAgent(DUMMY_TOOL, argumentsValue); + + assert.equal(received.method, 'POST'); + assert.equal(received.contentType, 'application/json'); + assert.equal( + received.authorization, + `Bearer ${DUMMY_SECRET}`, + ); + assert.equal(received.body.jsonrpc, '2.0'); + assert.equal(received.body.method, 'tools/call'); + assert.equal(typeof received.body.id, 'string'); + assert.ok(received.body.id.length > 0); + assert.equal(received.body.params.name, DUMMY_TOOL); + assert.deepEqual( + received.body.params.arguments, + argumentsValue, + ); + }); + + it('normalizes an HTTP error', async () => { + handler = (_request, response) => { + response.writeHead(500); + response.end(); + }; + + await expectGenericFailure(() => callAgent(DUMMY_TOOL, {})); + }); + + it('normalizes malformed outer JSON', async () => { + handler = (_request, response) => { + response.writeHead(200, { + 'Content-Type': 'application/json', + }); + response.end('not-json'); + }; + + await expectGenericFailure(() => callAgent(DUMMY_TOOL, {})); + }); + + it('normalizes a JSON-RPC error', async () => { + handler = (_request, response) => { + sendJson(response, 200, { + jsonrpc: '2.0', + id: 'mock-response-id', + error: { + code: -32000, + message: 'mock failure', + }, + }); + }; + + await expectGenericFailure(() => callAgent(DUMMY_TOOL, {})); + }); + + it('normalizes invalid content responses', async () => { + const invalidResponses = [ + { + result: { + content: [], + }, + }, + { + result: { + content: [{ + type: 'image', + text: '{}', + }], + }, + }, + { + result: { + content: [{ + type: 'text', + text: 'not-json', + }], + }, + }, + ]; + + for (const invalidResponse of invalidResponses) { + handler = (_request, response) => { + sendJson(response, 200, { + jsonrpc: '2.0', + id: 'mock-response-id', + ...invalidResponse, + }); + }; + + await expectGenericFailure(() => callAgent(DUMMY_TOOL, {})); + } + }); + + it('normalizes a response body larger than 1 MiB', async () => { + handler = (_request, response) => { + response.writeHead(200, { + 'Content-Type': 'text/plain', + }); + response.end('a'.repeat(MAX_RESPONSE_SIZE + 1)); + }; + + await expectGenericFailure(() => callAgent(DUMMY_TOOL, {})); + }); + }); +}); diff --git a/opax-web/backend/routes/consoleApi.js b/opax-web/backend/routes/consoleApi.js new file mode 100644 index 0000000..9416c18 --- /dev/null +++ b/opax-web/backend/routes/consoleApi.js @@ -0,0 +1,70 @@ +import { Router } from 'express'; +import { callAgent } from '../agentClient.js'; + +const router = Router(); + +const unavailable = { + status: 'unavailable', + summary: null, + message: 'Unavailable', +}; + +const adaptHealthSummary = (raw) => { + if (raw?.status !== 'ok' || !raw.service || !raw.version) { + return null; + } + return { + status: raw.status, + service: raw.service, + version: raw.version, + }; +}; + +router.get('/status', async (req, res) => { + let health; + try { + const rawHealth = await callAgent('get_health', {}); + const summary = adaptHealthSummary(rawHealth); + if (summary) { + health = { status: 'success', summary, message: 'Available' }; + } else { + health = unavailable; + console.warn('[Console API] Platform health data has an unexpected shape.'); + } + } catch (error) { + console.warn('[Console API] Platform health data is unavailable.'); + health = unavailable; + } + + res.json({ + health, + buildStatus: unavailable, + platformState: unavailable, + }); +}); + +router.get('/projects', (req, res) => { + res.json({ + status: 'unavailable', + message: 'No project registry configured yet.', + projects: [], + }); +}); + +router.get('/work-queue', (req, res) => { + res.json({ + status: 'unavailable', + message: 'No ticket read model configured yet.', + tickets: [], + }); +}); + +router.get('/approvals', (req, res) => { + res.json({ + status: 'unavailable', + message: 'No approval read model configured yet.', + approvals: [], + }); +}); + +export default router; diff --git a/opax-web/backend/server.js b/opax-web/backend/server.js new file mode 100644 index 0000000..cf6556e --- /dev/null +++ b/opax-web/backend/server.js @@ -0,0 +1,122 @@ +import { callAgent } from './agentClient.js'; +import { registerBrowserSocketHandlers } from './socketHandlers.js'; +import 'dotenv/config'; +import express from 'express'; +import cors from 'cors'; +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'; +import consoleApiRouter from './routes/consoleApi.js'; + +const app = express(); +const http = createServer(app); +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); +} + +const sessionSecret = process.env.SESSION_SECRET; +if (!sessionSecret || (sessionSecret === 'CHANGE_ME' && process.env.NODE_ENV !== 'production')) { + throw new Error('A valid SESSION_SECRET must be set.'); +} + +const sm = session({ + secret: sessionSecret, + resave: false, + saveUninitialized: false, + cookie: { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax' + }, +}); +const io = new Server(http, { cors: { origin: front, credentials: true } }); + +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' })); + +app.get('/auth/google', (q, r) => r.redirect(oauth.generateAuthUrl({ scope: ['openid', 'email', 'profile'], prompt: 'consent' }))); + +app.get('/auth/callback', async (req, res, next) => { + try { + let { tokens } = await oauth.getToken(req.query.code); + let p = (await oauth.verifyIdToken({ idToken: tokens.id_token, audience: process.env.GOOGLE_CLIENT_ID })).getPayload(); + if (!p?.email || !emails.has(p.email)) return res.status(403).send('Not authorised'); + req.session.user = { email: p.email, name: p.name || p.email }; + res.redirect('/'); + } catch (e) { + next(e); + } +}); + +app.get('/auth/me', (q, r) => (q.session.user ? r.json(q.session.user) : r.status(401).end())); +app.post('/auth/logout', (q, r) => q.session.destroy(() => r.json({ ok: true }))); + +const ensureAuthenticated = (req, res, next) => { + if (req.session.user) { + return next(); + } + res.status(401).end(); +}; + +app.use('/api/console', ensureAuthenticated, consoleApiRouter); + + +io.on('connection', (s) => { + const u = s.request.session?.user; + if (!u) return s.disconnect(); + + registerBrowserSocketHandlers(s, { callAgentFn: callAgent }); +}); + +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(); + } + } +}); + +// Central Error Handler +app.use((err, req, res, next) => { + // TODO: Integrate with an external error tracking service like Sentry + console.error(`[Express Error] Path: ${req.path}, Error:`, err); + + // Avoid leaking stack trace to client + if (res.headersSent) { + return next(err); + } + + res.status(500).json({ error: 'Internal Server Error' }); +}); + +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..c768ee2 --- /dev/null +++ b/opax-web/backend/socketHandlers.js @@ -0,0 +1,40 @@ +// FORSLAG TIL opax-web/backend/socketHandlers.js (ENDELIG KORRIGERT) + +const SAFE_ACTIONS = {}; + +/** + * 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) { + console.error(`[Socket Handler Error] Event: ${eventName}, User: ${socket.request.session.user.email}, Error:`, 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/index.html b/opax-web/frontend/index.html new file mode 100644 index 0000000..c41b5d7 --- /dev/null +++ b/opax-web/frontend/index.html @@ -0,0 +1 @@ +
diff --git a/opax-web/frontend/src/App.tsx b/opax-web/frontend/src/App.tsx new file mode 100644 index 0000000..28b883a --- /dev/null +++ b/opax-web/frontend/src/App.tsx @@ -0,0 +1,39 @@ +import React, { useState, useEffect } from 'react'; +import OperationsConsole from './pages/OperationsConsole'; + +interface User { + email: string; + name?: string; +} + +function App() { + const [user, setUser] = useState(null); + + useEffect(() => { + fetch('/auth/me', { credentials: 'include' }) + .then((r) => (r.ok ? r.json() : null)) + .then(setUser); + }, []); + + if (!user) { + return ( +
+

OPAX

+ Continue with Google +
+ ); + } + + return ( +
+
+ OPAX / Operations Console + {user.email} +
+ +
+ ); +} + +export default App; + diff --git a/opax-web/frontend/src/components/AgentConsoleView.tsx b/opax-web/frontend/src/components/AgentConsoleView.tsx new file mode 100644 index 0000000..b66c9c5 --- /dev/null +++ b/opax-web/frontend/src/components/AgentConsoleView.tsx @@ -0,0 +1,15 @@ +import React from 'react'; + +const AgentConsoleView = () => { + return ( +
+

Agent Console

+
+ DraftPlanScope ApprovalImplementationDiff ApprovalValidationDeployment ApprovalDeploy +
+

Ticket-bound agent execution is not configured yet.

+
+ ); +}; + +export default AgentConsoleView; diff --git a/opax-web/frontend/src/components/ApprovalsView.tsx b/opax-web/frontend/src/components/ApprovalsView.tsx new file mode 100644 index 0000000..5075d7e --- /dev/null +++ b/opax-web/frontend/src/components/ApprovalsView.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +const ApprovalsView = () => { + return ( +
+

Approvals

+

No approval read model configured yet.

+
+ ); +}; + +export default ApprovalsView; diff --git a/opax-web/frontend/src/components/PlatformStatus.tsx b/opax-web/frontend/src/components/PlatformStatus.tsx new file mode 100644 index 0000000..a9cdc7d --- /dev/null +++ b/opax-web/frontend/src/components/PlatformStatus.tsx @@ -0,0 +1,78 @@ +import React from 'react'; + +type ConsoleStatus = 'success' | 'unavailable' | 'loading'; + +type SafeHealthSummary = { + status: 'ok'; + service: string; + version: string; +}; + +type ConsoleStatusItem = { + status: Exclude; + summary: SafeHealthSummary | null; + message: 'Available' | 'Unavailable'; +}; + +interface ConsoleStatusResponse { + health?: ConsoleStatusItem; + buildStatus?: ConsoleStatusItem; + platformState?: ConsoleStatusItem; +} + +interface StatusBlockProps { + title: string; + status: ConsoleStatus; + summary: SafeHealthSummary | null; +} + +const StatusBlock: React.FC = ({ title, status, summary }) => { + return ( +
+

{title}

+ {status === 'loading' &&

Loading...

} + {status === 'unavailable' &&

Unavailable

} + {status === 'success' && summary && ( +
    +
  • Status: {summary.status}
  • +
  • Service: {summary.service}
  • +
  • Version: {summary.version}
  • +
+ )} +
+ ); +}; + +interface PlatformStatusProps { + statusData: ConsoleStatusResponse | null; + loading: boolean; +} + +const unavailableStatus: ConsoleStatus = 'unavailable'; + +const PlatformStatus: React.FC = ({ statusData, loading }) => { + return ( +
+

Platform Overview

+
+ + + +
+
+ ); +}; + +export default PlatformStatus; diff --git a/opax-web/frontend/src/components/ProjectRegistryView.tsx b/opax-web/frontend/src/components/ProjectRegistryView.tsx new file mode 100644 index 0000000..945b196 --- /dev/null +++ b/opax-web/frontend/src/components/ProjectRegistryView.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +const ProjectRegistryView = () => { + return ( +
+

Projects

+

No project registry configured yet.

+
+ ); +}; + +export default ProjectRegistryView; diff --git a/opax-web/frontend/src/components/WorkQueueView.tsx b/opax-web/frontend/src/components/WorkQueueView.tsx new file mode 100644 index 0000000..c46374d --- /dev/null +++ b/opax-web/frontend/src/components/WorkQueueView.tsx @@ -0,0 +1,12 @@ +import React from 'react'; + +const WorkQueueView = () => { + return ( +
+

Work Queue

+

No ticket read model configured yet.

+
+ ); +}; + +export default WorkQueueView; diff --git a/opax-web/frontend/src/main.tsx b/opax-web/frontend/src/main.tsx new file mode 100644 index 0000000..2e5d9d3 --- /dev/null +++ b/opax-web/frontend/src/main.tsx @@ -0,0 +1 @@ +import React from'react';import{createRoot}from'react-dom/client';import App from'./App';import'./style.css';createRoot(document.getElementById('root')!).render(); diff --git a/opax-web/frontend/src/pages/OperationsConsole.tsx b/opax-web/frontend/src/pages/OperationsConsole.tsx new file mode 100644 index 0000000..7de4721 --- /dev/null +++ b/opax-web/frontend/src/pages/OperationsConsole.tsx @@ -0,0 +1,138 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import PlatformStatus from '../components/PlatformStatus'; +import ProjectRegistryView from '../components/ProjectRegistryView'; +import WorkQueueView from '../components/WorkQueueView'; +import ApprovalsView from '../components/ApprovalsView'; +import AgentConsoleView from '../components/AgentConsoleView'; + +// --- Types for Safe Data Handling --- +type ConsoleStatus = 'success' | 'unavailable'; + +type SafeHealthSummary = { + status: 'ok'; + service: string; + version: string; +}; + +type ConsoleStatusItem = { + status: ConsoleStatus; + summary: SafeHealthSummary | null; + message: 'Available' | 'Unavailable'; +}; + +export interface ConsoleStatusResponse { + health: ConsoleStatusItem; + buildStatus: ConsoleStatusItem; + platformState: ConsoleStatusItem; +} + +const unavailableItem: ConsoleStatusItem = { + status: 'unavailable', + summary: null, + message: 'Unavailable', +}; + +// --- Normalizer Function --- +type UnknownRecord = Record; + +function isRecord(value: unknown): value is UnknownRecord { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isHealthSummary(value: unknown): value is SafeHealthSummary { + return ( + isRecord(value) && + value.status === 'ok' && + typeof value.service === 'string' && + typeof value.version === 'string' + ); +} + +function normalizeStatusResponse(raw: unknown): ConsoleStatusResponse { + let health: ConsoleStatusItem = unavailableItem; + + if (isRecord(raw) && isRecord(raw.health)) { + const healthItem = raw.health; + + if ( + healthItem.status === 'success' && + isHealthSummary(healthItem.summary) + ) { + health = { + status: 'success', + summary: { + status: 'ok', + service: healthItem.summary.service, + version: healthItem.summary.version, + }, + message: 'Available', + }; + } + } + + return { + health, + buildStatus: unavailableItem, + platformState: unavailableItem, + }; +} + +// --- Component --- +const OperationsConsole = () => { + const [statusData, setStatusData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [lastRefreshed, setLastRefreshed] = useState(''); + + const fetchData = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch('/api/console/status', { credentials: 'include' }); + if (!res.ok) { + // Handle auth errors or server errors safely + if (res.status === 401) { + throw new Error('Access denied. Please log in again.'); + } else { + throw new Error('Console data is unavailable.'); + } + } + const rawData = await res.json(); + setStatusData(normalizeStatusResponse(rawData)); + setLastRefreshed(new Date().toLocaleTimeString()); + } catch { + setError('Console data is unavailable. Please try again.'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchData(); + }, [fetchData]); + + return ( +
+
+

OPAX Operations Console

+
+ Last refreshed: {lastRefreshed || 'never'} + +
+
+ + {error &&

{error}

} + + + + + + + +
+ ); +}; + +export default OperationsConsole; diff --git a/opax-web/frontend/src/style.css b/opax-web/frontend/src/style.css new file mode 100644 index 0000000..c8e88b2 --- /dev/null +++ b/opax-web/frontend/src/style.css @@ -0,0 +1,165 @@ +:root { + --font-family: monospace; + --background-color: #0d1117; + --text-color: #e6edf3; + --text-color-secondary: #8b949e; + --border-color: #30363d; + --container-background: #161b22; + --button-background: #21262d; + --button-background-hover: #30363d; + --button-primary-background: #238636; + --button-primary-background-hover: #2ea043; + --error-color: #f85149; + --spacing-unit: 16px; +} + +*, +*::before, +*::after { + box-sizing: border-box; +} + +body { + margin: 0; + background-color: var(--background-color); + color: var(--text-color); + font-family: var(--font-family); + font-size: 14px; +} + +main { + height: 100vh; + display: flex; + flex-direction: column; +} + +header { + padding: var(--spacing-unit); + background-color: var(--container-background); + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; + font-size: 16px; +} + +header span { + color: var(--text-color-secondary); +} + +.content-wrapper { + padding: calc(var(--spacing-unit) * 2); + max-width: 800px; + margin: 0 auto; + width: 100%; +} + +.page-header { + margin-bottom: calc(var(--spacing-unit) * 2); + text-align: center; +} + +.page-header h1 { + font-size: 24px; + margin-bottom: calc(var(--spacing-unit) / 2); +} + +.page-header p { + font-size: 16px; + color: var(--text-color-secondary); + max-width: 600px; + margin: 0 auto; +} + +.actions-container { + display: grid; + grid-template-columns: 1fr; + gap: calc(var(--spacing-unit) * 2); +} + +@media (min-width: 768px) { + .actions-container { + grid-template-columns: 1fr 1fr; + } +} + +.action-card { + background-color: var(--container-background); + border: 1px solid var(--border-color); + padding: var(--spacing-unit); + border-radius: 6px; +} + +.action-card h2 { + margin-top: 0; + font-size: 18px; + border-bottom: 1px solid var(--border-color); + padding-bottom: var(--spacing-unit); + margin-bottom: var(--spacing-unit); +} + +button { + width: 100%; + background: var(--button-background); + color: var(--text-color); + border: 1px solid var(--border-color); + padding: 10px var(--spacing-unit); + border-radius: 6px; + font-family: inherit; + font-size: 14px; + cursor: pointer; + transition: background-color 0.2s; +} + +button:hover:not(:disabled) { + background-color: var(--button-background-hover); +} + +button:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +button.primary { + background-color: var(--button-primary-background); + border-color: var(--button-primary-background-hover); +} + +button.primary:hover:not(:disabled) { + background-color: var(--button-primary-background-hover); +} + +.result-display { + margin-top: var(--spacing-unit); + padding: var(--spacing-unit); + background-color: var(--background-color); + border: 1px solid var(--border-color); + border-radius: 6px; + white-space: pre-wrap; + word-wrap: break-word; + font-size: 13px; + min-height: 100px; +} + +.result-display strong { + color: var(--text-color-secondary); +} + +.error { + color: var(--error-color); + margin-top: calc(var(--spacing-unit) / 2); + font-size: 13px; +} + +.login { + height: 100vh; + display: grid; + place-content: center; + gap: 20px; + text-align: center; +} + +.login a { + color: #58a6ff; + font-size: 18px; +} \ No newline at end of file diff --git a/opax-web/frontend/vite.config.ts b/opax-web/frontend/vite.config.ts new file mode 100644 index 0000000..073f58e --- /dev/null +++ b/opax-web/frontend/vite.config.ts @@ -0,0 +1 @@ +import{defineConfig}from'vite';import react from'@vitejs/plugin-react';export default defineConfig({plugins:[react()],server:{proxy:{'/auth':'http://localhost:8081','/socket.io':{target:'http://localhost:8081',ws:true}}}});