feat: integrate tenant SSO and remote toggle
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
parent
660d5291ec
commit
ca5765b988
27 changed files with 529 additions and 159 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue