From 5f8c2efe30e42b7c97d95478042035ec4ca00833 Mon Sep 17 00:00:00 2001 From: u1 Date: Tue, 6 Jan 2026 14:30:39 +0100 Subject: [PATCH] feat(ui): show user and logout --- apps/visualizer/src/App.tsx | 3 +- apps/visualizer/src/layout/AuthStatus.tsx | 55 +++++++++++++++++++++++ apps/visualizer/src/styles.css | 6 +++ services/frontend/server.mjs | 33 ++++++++++++++ 4 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 apps/visualizer/src/layout/AuthStatus.tsx diff --git a/apps/visualizer/src/App.tsx b/apps/visualizer/src/App.tsx index de07e8e..503e78f 100644 --- a/apps/visualizer/src/App.tsx +++ b/apps/visualizer/src/App.tsx @@ -9,6 +9,7 @@ import Tabs from './ui/Tabs'; import MarketHeader from './features/market/MarketHeader'; import Button from './ui/Button'; import TopNav from './layout/TopNav'; +import AuthStatus from './layout/AuthStatus'; function envNumber(name: string, fallback: number): number { const v = (import.meta as any).env?.[name]; @@ -155,7 +156,7 @@ export default function App() { return ( } + header={} />} top={} main={
diff --git a/apps/visualizer/src/layout/AuthStatus.tsx b/apps/visualizer/src/layout/AuthStatus.tsx new file mode 100644 index 0000000..9ed3d4d --- /dev/null +++ b/apps/visualizer/src/layout/AuthStatus.tsx @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; +import Button from '../ui/Button'; + +type WhoamiResponse = { + ok?: boolean; + user?: string | null; +}; + +export default function AuthStatus() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + + fetch('/whoami', { cache: 'no-store' }) + .then(async (res) => { + const json = (await res.json().catch(() => null)) as WhoamiResponse | null; + const u = typeof json?.user === 'string' ? json.user.trim() : ''; + return u || null; + }) + .then((u) => { + if (cancelled) return; + setUser(u); + }) + .catch(() => { + if (cancelled) return; + setUser(null); + }) + .finally(() => { + if (cancelled) return; + setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); + + const logout = () => { + window.location.href = '/logout'; + }; + + return ( +
+ +
+
{loading ? '…' : user || 'unknown'}
+
zalogowany
+
+
+ ); +} diff --git a/apps/visualizer/src/styles.css b/apps/visualizer/src/styles.css index 860b3fd..0dbf625 100644 --- a/apps/visualizer/src/styles.css +++ b/apps/visualizer/src/styles.css @@ -282,6 +282,12 @@ a:hover { align-items: center; } +.authStatus { + display: flex; + gap: 10px; + align-items: center; +} + .topNav__iconBtn { background: rgba(255, 255, 255, 0.02); border: 1px solid rgba(255, 255, 255, 0.12); diff --git a/services/frontend/server.mjs b/services/frontend/server.mjs index 449ce1a..f324208 100644 --- a/services/frontend/server.mjs +++ b/services/frontend/server.mjs @@ -19,6 +19,9 @@ const BASIC_AUTH_MODE = String(process.env.BASIC_AUTH_MODE || 'on') .trim() .toLowerCase(); const BASIC_AUTH_ENABLED = !['off', 'false', '0', 'disabled', 'none'].includes(BASIC_AUTH_MODE); +const AUTH_USER_HEADER = String(process.env.AUTH_USER_HEADER || 'x-trade-user') + .trim() + .toLowerCase(); function readJson(filePath) { const raw = fs.readFileSync(filePath, 'utf8'); @@ -54,6 +57,10 @@ function send(res, status, headers, body) { res.end(body); } +function sendJson(res, status, body) { + send(res, status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' }, JSON.stringify(body)); +} + function basicAuthRequired(res) { res.setHeader('www-authenticate', 'Basic realm="trade"'); send(res, 401, { 'content-type': 'text/plain; charset=utf-8' }, 'unauthorized'); @@ -169,6 +176,17 @@ function stripHopByHopHeaders(headers) { return out; } +function readHeader(req, name) { + const v = req.headers[String(name).toLowerCase()]; + return Array.isArray(v) ? v[0] : v; +} + +function resolveAuthUser(req) { + const user = readHeader(req, AUTH_USER_HEADER) || readHeader(req, 'x-webauth-user'); + const value = typeof user === 'string' ? user.trim() : ''; + return value || null; +} + function proxyApi(req, res, apiReadToken) { const upstreamBase = new URL(API_UPSTREAM); const inUrl = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); @@ -230,6 +248,20 @@ function handler(req, res) { return; } + const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`); + if (req.method === 'GET' && url.pathname === '/whoami') { + sendJson(res, 200, { ok: true, user: resolveAuthUser(req) }); + return; + } + + if (req.method === 'GET' && url.pathname === '/logout') { + // NOTE: With HTTP basic auth handled upstream (Traefik), browser "logout" is best-effort. + // This endpoint forces a 401 so the browser prompts again, allowing the user to switch accounts. + res.setHeader('www-authenticate', 'Basic realm="trade"'); + send(res, 401, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' }, 'logged_out'); + return; + } + if (BASIC_AUTH_ENABLED) { let creds; try { @@ -272,6 +304,7 @@ server.listen(PORT, () => { basicAuthFile: BASIC_AUTH_FILE, basicAuthMode: BASIC_AUTH_MODE, apiReadTokenFile: API_READ_TOKEN_FILE, + authUserHeader: AUTH_USER_HEADER, }, null, 2