feat: integrate tenant SSO and remote toggle

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Daniel Noulet 2026-10-03 00:14:23 +02:00
parent 660d5291ec
commit ca5765b988
27 changed files with 529 additions and 159 deletions

View file

@ -3,8 +3,6 @@ WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG VITE_GO_LOOSE_API_KEY
ENV VITE_GO_LOOSE_API_KEY=$VITE_GO_LOOSE_API_KEY
RUN npm run build
FROM nginx:1.29-alpine

View file

@ -36,27 +36,15 @@ application shell.
## API integration
For local tenant hosts such as `nmbs.guess.local`, `src/api/client.ts` derives
For local tenant hosts such as `nmbs.guess.dev`, `src/api/client.ts` derives
tenant `nmbs`, calls same-origin `/api` through the shared HTTPS proxy, and sends
`X-Tenant-Id: nmbs` on every JSON, multipart, file-download, and event-stream
request. `ypto.guess.local` behaves identically for tenant `ypto`. Other
request. `ypto.guess.dev` behaves identically for tenant `ypto`. Other
hostnames use same-origin `/api`; Vite retains its `/api` proxy for that case.
Set `VITE_GO_LOOSE_API_KEY` before `npm run dev` or `npm run build` to also send
`X-API-Key`. Vite reads `frontend/.env.local` for local development; that file
matches `*.local` and must not be committed. The standalone frontend image
accepts the same value as a build argument:
```bash
docker build --build-arg VITE_GO_LOOSE_API_KEY='gl_replace_with_issued_secret' \
-t go-guess-frontend .
```
The variable is inlined into the JavaScript bundle. Treat it as an application
credential for Go Loose, not as a secret equivalent to the interviewer JWT. If
it is unset or empty, the client omits the header and protected interviewer
routes fail closed with 401. Login and participant routes do not require the
key, but the client still sends it when the variable is present.
On the configured tenant hosts, the app checks `/api/auth/session` and redirects
to `/api/auth/login` when no Go Loose browser session exists. The callback
exchanges that session for the bearer token used by the typed API client.
Invitation and participant-meeting URLs use the validated request origin so
links stay on the tenant hostname. Copy actions fall back to a temporary selected
textarea when the Clipboard API is unavailable on local HTTP domains.
@ -65,7 +53,9 @@ Login stores the returned JWT in
`sessionStorage`; protected requests send it as `Authorization: Bearer <token>`. API failures are
shown in the UI and are never replaced with demo data. A `401` from a protected
JSON, file, or event-stream request clears authentication and returns the user to
`/login`. The app also schedules logout from the JWT expiration claim so an idle
`/login`. On `nmbs.guess.dev` and `ypto.guess.dev`, the app restores
`/api/auth/session` or redirects to `/api/auth/login`; participant routes stay
token-based. The app also schedules logout from the JWT expiration claim so an idle
expired session cannot remain on a protected page.
The machine-readable OpenAPI 3.0 contract at `/api/openapi.json` is the source
@ -120,7 +110,5 @@ receives interviewer notes or assessment reference answers.
## Container
Build with `docker build -t go-guess-frontend .`. Pass
`--build-arg VITE_GO_LOOSE_API_KEY=...` when the image must call protected
interviewer routes; see the API integration section. The production Nginx server
Build with `docker build -t go-guess-frontend .`. The production Nginx server
serves the SPA and proxies `/api` to the Docker Compose service `api:8080`.

View file

@ -93,11 +93,7 @@ function apiURL(path: string): string {
function tenantHeader(): Record<string, string> {
const tenant = tenantFromHostname(window.location.hostname)
const apiKey = import.meta.env.VITE_GO_LOOSE_API_KEY
return {
...(tenant ? { 'X-Tenant-Id': tenant } : {}),
...(typeof apiKey === 'string' && apiKey !== '' ? { 'X-API-Key': apiKey } : {}),
}
return tenant ? { 'X-Tenant-Id': tenant } : {}
}
async function request<T>(

View file

@ -9,7 +9,7 @@ import {DataProvider} from './DataContext'
export default function App() {
const location = useLocation();
const {authenticated, sessionExpired, completeLogin, logout} = useAuthSession();
const {authenticated, sessionExpired, ssoStatus, completeLogin, logout} = useAuthSession();
if (location.pathname.startsWith('/participant/')) {
return (
<Routes>
@ -20,6 +20,9 @@ export default function App() {
)
}
if (!authenticated) {
if (ssoStatus !== 'idle') {
return <main className="login-shell"><p>Redirecting to sign in…</p></main>
}
return <LoginPage onLogin={completeLogin} sessionExpired={sessionExpired}/>
}
return (

View file

@ -1,16 +1,31 @@
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import { authStorage, invalidateSession, SESSION_EXPIRED_EVENT } from '../../api/client'
const ssoHosts = new Set(['nmbs.guess.dev', 'ypto.guess.dev'])
export function isSSOHost() {
return ssoHosts.has(window.location.hostname)
}
export function useAuthSession() {
const location = useLocation()
const sso = isSSOHost() && !location.pathname.startsWith('/participant/')
const [authenticated, setAuthenticated] = useState(() => Boolean(authStorage.token()))
const [sessionExpired, setSessionExpired] = useState(false)
const [redirecting, setRedirecting] = useState(false)
const needsSSO = sso && !authenticated && !authStorage.token()
const ssoStatus = !needsSSO ? 'idle' : redirecting ? 'redirecting' : 'checking'
const navigate = useNavigate()
useEffect(() => {
function handleSessionExpired() {
setAuthenticated(false)
setSessionExpired(true)
if (isSSOHost() && !window.location.pathname.startsWith('/participant/')) {
window.location.replace('/api/auth/login')
return
}
navigate('/login', { replace: true })
}
@ -18,6 +33,36 @@ export function useAuthSession() {
return () => window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired)
}, [navigate])
useEffect(() => {
if (!needsSSO) return
let cancelled = false
void (async () => {
try {
const response = await fetch('/api/auth/session', {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
redirect: 'manual',
})
if (cancelled) return
if (response.ok) {
authStorage.save(await response.json())
setAuthenticated(true)
setSessionExpired(false)
return
}
} catch {
// The browser redirect below starts a fresh Go Loose login.
}
if (!cancelled) {
setRedirecting(true)
window.location.replace('/api/auth/login')
}
})()
return () => {
cancelled = true
}
}, [needsSSO])
useEffect(() => {
if (!authenticated) return
const expiresAt = authStorage.expiresAt()
@ -41,7 +86,8 @@ export function useAuthSession() {
authStorage.clear()
setAuthenticated(false)
setSessionExpired(false)
if (isSSOHost()) window.location.assign('/api/auth/logout')
}
return { authenticated, sessionExpired, completeLogin, logout }
return { authenticated, sessionExpired, ssoStatus, completeLogin, logout }
}