OSVauco/opax-web/frontend/src/App.tsx
Chris Christiansen dc9f7b6626 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.
2026-09-09 17:26:07 +00:00

189 lines
5.1 KiB
TypeScript

import { useEffect, useRef, useState } from 'react';
import { io, Socket } from 'socket.io-client';
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;
name?: string;
}
export default function App() {
const [user, setUser] = useState<User | null>(null);
const [statusData, setStatusData] = useState<GitStatusData | null>(null);
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);
useEffect(() => {
fetch('/auth/me', { credentials: 'include' })
.then((r) => (r.ok ? r.json() : null))
.then(setUser);
}, []);
useEffect(() => {
if (!user) return;
const socket = io('/');
socketRef.current = socket;
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 (
<main className="login">
<h1>OPAX</h1>
<a href="/auth/google">Continue with Google</a>
</main>
);
}
return (
<main>
<header>
<b>OPAX / Web TUI</b>
<span>{user.email}</span>
</header>
<section>
<div>
<h2>Git Status</h2>
<button onClick={handleGetStatus} disabled={statusLoading}>
{statusLoading ? 'Laster...' : 'Hent Git-status'}
</button>
{statusError && <p className="error">{statusError}</p>}
{statusData && (
<pre>
<strong>Status:</strong> {statusData.status}\n\n
<strong>Detaljer:</strong>\n{statusData.details || '(ingen endringer)'}
</pre>
)}
</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>
</section>
</main>
);
}