feat: avatar, account menu, and single sign-on from Go Loose

Signing in now starts at the sign-in screen and one button that hands the
browser to Go Loose, which returns to the welcome page. Nothing redirects to the
identity provider on its own any more: that fired on every anonymous visit and
read as a redirect loop rather than as a choice to sign in. The password form
remains only where the API reports that Go Loose login is not configured for the
host, because otherwise the application is unreachable on a bare localhost.

A person's picture is carried through from the Go Loose identity and rendered in
the account menu and on the profile page, with their initials as the fallback.
Two things make that fallback necessary rather than decorative: Go Loose retires a
picture key when a picture is replaced, so a link cached in a browser can be dead,
and most people have not uploaded one. The URL is deliberately not persisted
locally, since a stored key would keep failing after the picture changed.

The account menu is a disclosure rather than a hover menu: a real button with
aria-expanded, Escape and outside clicks closing it, and focus returning to the
trigger. It is not an ARIA menu, because its actions are a link and a button and
menuitem roles would strip the link semantics from one of them for no gain.

The account URL of the Go Loose identity is resolved on each sign-in and is read
from the live identity provider every time, which is also what makes it correct
after a picture change.

Depends on the Go Loose revision that serves avatar_url through
/connect/userinfo, so the picture URL reaches this application at all.
This commit is contained in:
Daniel Noulet 2026-10-05 21:50:06 +02:00
parent 6be21597d5
commit 5449cd3d8d
52 changed files with 1927 additions and 434 deletions

View file

@ -36,14 +36,18 @@ application shell.
## API integration
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
For tenant hosts such as `nmbs.guess-dev.urpi.be` locally and
`nmbs.guess.urpi.be` in production, `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.dev` behaves identically for tenant `ypto`. Other
request. `ypto.guess-dev.urpi.be` behaves identically for tenant `ypto`. Other
hostnames use same-origin `/api`; Vite retains its `/api` proxy for that case.
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
`src/components/actions/Account.tsx` treats any host whose first DNS label is a
tenant as a possible Go Loose host and asks `/api/auth/session` whether browser
login is available there. A `404` means the API has no Go Loose login for the
host, so the password form is shown; any other unauthenticated answer means Go
Loose is configured and the app redirects to `/api/auth/login`. 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
@ -53,7 +57,7 @@ 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`. On `nmbs.guess.dev` and `ypto.guess.dev`, the app restores
`/login`. On a tenant host with Go Loose configured, 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.

View file

@ -1,5 +1,5 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { api, apiBaseURL, authStorage, SESSION_EXPIRED_EVENT, tenantFromHostname } from './client'
import { api, authStorage, SESSION_EXPIRED_EVENT, tenantFromHostname } from './client'
import { subscribeToDomainEvent } from './domainEvents'
describe('API client', () => {
@ -8,12 +8,11 @@ describe('API client', () => {
})
it('uses the same-origin API behind the local HTTPS proxy', () => {
expect(tenantFromHostname('nmbs.guess.local')).toBe('nmbs')
expect(tenantFromHostname('ypto.guess.local')).toBe('ypto')
expect(tenantFromHostname('nmbs.guess-dev.urpi.be')).toBe('nmbs')
expect(tenantFromHostname('ypto.guess-dev.urpi.be')).toBe('ypto')
expect(tenantFromHostname('nmbs.guess.urpi.be')).toBe('nmbs')
expect(tenantFromHostname('localhost')).toBeNull()
expect(tenantFromHostname('127.0.0.1')).toBeNull()
expect(apiBaseURL({ hostname: 'nmbs.guess.local', protocol: 'https:' })).toBe('/api')
expect(apiBaseURL({ hostname: 'app.example.com', protocol: 'https:' })).toBe('/api')
})
it('sends the persisted bearer token on protected requests', async () => {

View file

@ -79,16 +79,10 @@ export function tenantFromHostname(hostname: string): string | null {
return labels.length >= 3 ? labels[0] : null
}
export function apiBaseURL(location: Pick<Location, 'hostname' | 'protocol'>): string {
const hostname = location.hostname.toLowerCase().replace(/\.$/, '')
if (hostname.endsWith('.guess.local')) {
return '/api'
}
return '/api'
}
// Every tenant host serves the API from its own origin: nginx and Traefik proxy
// /api to the API container, so the base is always the same-origin path.
function apiURL(path: string): string {
return `${apiBaseURL(window.location)}${path}`
return `/api${path}`
}
function tenantHeader(): Record<string, string> {

View file

@ -258,10 +258,17 @@ describe('Go Guess frontend', () => {
'href',
'/invitations',
)
expect(within(accountNavigation).getByRole('link', { name: 'Admin' })).toHaveAttribute(
// The person is reached through the account menu rather than a bare profile
// link, so the menu trigger names them and holds the actions.
const menu = within(accountNavigation).getByRole('button', { name: /Admin/ })
expect(menu).toHaveAttribute('aria-expanded', 'false')
await userEvent.click(menu)
expect(within(accountNavigation).getByRole('link', { name: 'Profile' })).toHaveAttribute(
'href',
'/profile',
)
expect(within(accountNavigation).getByRole('button', { name: 'Sign out' })).toBeInTheDocument()
await userEvent.keyboard('{Escape}')
const primaryNavigation = screen.getByRole('navigation', { name: 'Primary navigation' })
expect(
@ -329,7 +336,10 @@ describe('Go Guess frontend', () => {
renderApp('/participants/3')
const input = await screen.findByLabelText(/replace cv/i)
await user.upload(input, new File(['replacement'], 'replacement.pdf', { type: 'application/pdf' }))
await user.upload(
input,
new File(['replacement'], 'replacement.pdf', { type: 'application/pdf' }),
)
expect(await screen.findByText('React')).toBeInTheDocument()
expect(fetch).toHaveBeenCalledWith(

View file

@ -1,33 +1,40 @@
import {Route, Routes, useLocation} from 'react-router-dom'
import {useAuthSession} from '../components/actions'
import {NotFound} from '../components/ui/NotFound'
import {ParticipantInterviewPage} from '../features/assessments'
import {ParticipantMeetingPage} from '../features/interviews'
import {LoginPage} from '../features/login/LoginPage'
import {AuthenticatedApp} from './AuthenticatedApp'
import {DataProvider} from './DataContext'
import { Route, Routes, useLocation } from 'react-router-dom'
import { useAuthSession } from '../components/actions'
import { NotFound } from '../components/ui/NotFound'
import { ParticipantInterviewPage } from '../features/assessments'
import { ParticipantMeetingPage } from '../features/interviews'
import { LoginPage } from '../features/login/LoginPage'
import { AuthenticatedApp } from './AuthenticatedApp'
import { DataProvider } from './DataContext'
export default function App() {
const location = useLocation();
const {authenticated, sessionExpired, ssoStatus, completeLogin, logout} = useAuthSession();
if (location.pathname.startsWith('/participant/')) {
return (
<Routes>
<Route path="/participant/meeting/:token" element={<ParticipantMeetingPage/>}/>
<Route path="/participant/:invitationId" element={<ParticipantInterviewPage/>}/>
<Route path="*" element={<NotFound/>}/>
</Routes>
)
}
if (!authenticated) {
if (ssoStatus !== 'idle') {
return <main className="login-shell"><p>Redirecting to sign in…</p></main>
}
return <LoginPage onLogin={completeLogin} sessionExpired={sessionExpired}/>
}
const location = useLocation()
const { authenticated, sessionExpired, ssoAvailable, completeLogin, logout } = useAuthSession()
if (location.pathname.startsWith('/participant/')) {
return (
<DataProvider>
<AuthenticatedApp onLogout={logout}/>
</DataProvider>
<Routes>
<Route path="/participant/meeting/:token" element={<ParticipantMeetingPage />} />
<Route path="/participant/:invitationId" element={<ParticipantInterviewPage />} />
<Route path="*" element={<NotFound />} />
</Routes>
)
}
// The sign-in screen is the entry point, so nothing redirects to the identity
// provider on its own. While the API is still answering there is nothing
// useful to show, but not a spinner either: the button is correct either way,
// because it is what the screen offers whether or not Go Loose is configured.
if (!authenticated) {
return (
<LoginPage
onLogin={completeLogin}
sessionExpired={sessionExpired}
ssoAvailable={ssoAvailable}
/>
)
}
return (
<DataProvider>
<AuthenticatedApp onLogout={logout} />
</DataProvider>
)
}

View file

@ -13,7 +13,7 @@ export function AuthenticatedApp({onLogout}: { onLogout: () => void }) {
<div className="app-shell">
<GoGuessHeader setOpen={setOpen} open={open} onLogout={onLogout}/>
<main className="main-content">
<WorkspaceUtilities user={user}/>
<WorkspaceUtilities user={user} onSignOut={onLogout}/>
<ApplicationRoutes/>
<AppFooter/>
</main>

View file

@ -0,0 +1,169 @@
import { render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { isSSOHost, useAuthSession } from './Account'
interface FakeLocation {
hostname: string
pathname: string
assign: ReturnType<typeof vi.fn>
replace: ReturnType<typeof vi.fn>
}
function setHost(hostname: string, pathname = '/'): FakeLocation {
const location: FakeLocation = {
hostname,
pathname,
assign: vi.fn(),
replace: vi.fn(),
}
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: location,
})
return location
}
function json(body: unknown, status: number) {
return Promise.resolve(
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
}),
)
}
function Probe() {
const { authenticated, ssoAvailable } = useAuthSession()
return <p>{`${String(ssoAvailable)}|${String(authenticated)}`}</p>
}
function renderProbe(path = '/') {
return render(
<MemoryRouter initialEntries={[path]}>
<Probe />
</MemoryRouter>,
)
}
const authResponse = {
token: 'sso-token',
user: { id: 1, email: 'ssuser@example.com', displayName: 'SSO', role: 'interviewer' },
}
describe('isSSOHost', () => {
it('recognises tenant hosts on the development and production domains', () => {
for (const hostname of [
'nmbs.guess-dev.urpi.be',
'ypto.guess-dev.urpi.be',
'nmbs.guess.urpi.be',
'ypto.guess.urpi.be',
]) {
setHost(hostname)
expect(isSSOHost(), hostname).toBe(true)
}
})
it('ignores localhost and raw addresses', () => {
for (const hostname of ['localhost', '127.0.0.1', '']) {
setHost(hostname)
expect(isSSOHost(), hostname).toBe(false)
}
})
})
describe('useAuthSession Go Loose bootstrap', () => {
beforeEach(() => {
sessionStorage.clear()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('signs in from an existing Go Loose session without leaving the page', async () => {
const location = setHost('nmbs.guess-dev.urpi.be')
vi.stubGlobal(
'fetch',
vi.fn(() => json(authResponse, 200)),
)
renderProbe()
await waitFor(() => expect(screen.getByText('true|true')).toBeInTheDocument())
expect(sessionStorage.getItem('go-guess-token')).toBe('sso-token')
expect(location.replace).not.toHaveBeenCalled()
})
it('leaves the sign-in screen in place when there is no session', async () => {
// Signing in starts from the screen and one button. Redirecting on sight would
// skip past the screen and fire on every anonymous visit, which reads as a
// redirect loop rather than as a choice to sign in.
const location = setHost('nmbs.guess.urpi.be')
vi.stubGlobal(
'fetch',
vi.fn(() => json({ error: 'missing Go Loose session' }, 401)),
)
renderProbe()
await waitFor(() => expect(screen.getByText('true|false')).toBeInTheDocument())
expect(location.replace).not.toHaveBeenCalled()
expect(location.assign).not.toHaveBeenCalled()
})
it('keeps the single sign-on button when the API cannot be reached', async () => {
// Nothing answers, so there is no way to know whether Go Loose is configured.
// The button is the only way in on such a host, so it stays.
const location = setHost('nmbs.guess.urpi.be')
vi.stubGlobal(
'fetch',
vi.fn(() => Promise.reject(new Error('offline'))),
)
renderProbe()
await waitFor(() => expect(screen.getByText('null|false')).toBeInTheDocument())
expect(location.assign).not.toHaveBeenCalled()
})
it('falls back to password sign-in when the API reports no Go Loose login', async () => {
const location = setHost('nmbs.guess-dev.urpi.be')
vi.stubGlobal(
'fetch',
vi.fn(() => json({ error: 'Go Loose browser login is not enabled' }, 404)),
)
renderProbe()
// false is what switches the screen from the button to the password form.
await waitFor(() => expect(screen.getByText('false|false')).toBeInTheDocument())
expect(location.replace).not.toHaveBeenCalled()
})
it('never probes the session outside a tenant host', async () => {
setHost('localhost')
const fetchMock = vi.fn(() => json(authResponse, 200))
vi.stubGlobal('fetch', fetchMock)
renderProbe()
// A host with no tenant label cannot have Go Loose login, so it is false
// without asking the API anything.
await waitFor(() => expect(screen.getByText('false|false')).toBeInTheDocument())
expect(fetchMock).not.toHaveBeenCalled()
})
it('skips the bootstrap on participant routes', async () => {
const location = setHost('nmbs.guess-dev.urpi.be', '/participant/meeting/token')
const fetchMock = vi.fn(() => json(authResponse, 200))
vi.stubGlobal('fetch', fetchMock)
renderProbe('/participant/meeting/token')
await waitFor(() => expect(screen.getByText('null|false')).toBeInTheDocument())
expect(fetchMock).not.toHaveBeenCalled()
expect(location.replace).not.toHaveBeenCalled()
})
})

View file

@ -1,67 +1,93 @@
import { useEffect, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { authStorage, invalidateSession, SESSION_EXPIRED_EVENT } from '../../api/client'
import {
authStorage,
invalidateSession,
tenantFromHostname,
SESSION_EXPIRED_EVENT,
} from '../../api/client'
const ssoHosts = new Set(['nmbs.guess.dev', 'ypto.guess.dev'])
const SESSION_PATH = '/api/auth/session'
const LOGOUT_PATH = '/api/auth/logout'
/**
* Every tenant is served from https://<tenant>.<app domain>, on guess-dev.urpi.be
* locally and guess.urpi.be in production, so the first DNS label of a host
* with an app domain below it names the tenant. Those hosts may authenticate
* through Go Loose. Whether they actually do is answered by the API, which
* reports 404 for /api/auth/session on any host it has no Go Loose login for,
* and that host then falls back to password sign-in.
*/
export function isSSOHost() {
return ssoHosts.has(window.location.hostname)
return tenantFromHostname(window.location.hostname) !== null
}
/**
* Tracks who is signed in.
*
* There is deliberately no automatic redirect to the identity provider: signing in
* starts from the sign-in screen and one button. Redirecting on sight would skip
* past the screen, and would also fire on every anonymous visit to the
* application, which reads as a redirect loop rather than as a choice to sign in.
*/
export function useAuthSession() {
const location = useLocation()
const sso = isSSOHost() && !location.pathname.startsWith('/participant/')
// A participant opens the application through a token link and is never a
// member of the tenant, so nothing here may probe or redirect them.
const participant = location.pathname.startsWith('/participant/')
const navigate = useNavigate()
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()
// Whether Go Loose login is configured for this host. A host with no tenant
// label cannot have it, so that is known without asking: false there, and null
// on a tenant host until /api/auth/session has answered.
const [ssoAvailable, setSsoAvailable] = useState<boolean | null>(() =>
isSSOHost() ? null : false,
)
const storedToken = authStorage.token()
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 })
}
window.addEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired)
return () => window.removeEventListener(SESSION_EXPIRED_EVENT, handleSessionExpired)
}, [navigate])
}, [])
useEffect(() => {
if (!needsSSO) return
// A host without a tenant label cannot have Go Loose login configured, and a
// participant is not signing in at all.
if (participant || !isSSOHost() || storedToken) return
let cancelled = false
void (async () => {
try {
const response = await fetch('/api/auth/session', {
const response = await fetch(SESSION_PATH, {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
redirect: 'manual',
})
if (cancelled) return
if (response.status === 404) {
// Go Loose login is not configured for this host: sign in with a password.
setSsoAvailable(false)
return
}
setSsoAvailable(true)
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')
// An unreachable API leaves ssoAvailable null, which keeps the single
// sign-on button on screen: it is the only way in on such a host anyway.
}
})()
return () => {
cancelled = true
}
}, [needsSSO])
}, [storedToken, participant])
useEffect(() => {
if (!authenticated) return
@ -79,6 +105,8 @@ export function useAuthSession() {
function completeLogin() {
setAuthenticated(true)
setSessionExpired(false)
// The password path can be entered from any URL, so the welcome page is the
// destination. The Go Loose path is already sent here by its redirect.
navigate('/', { replace: true })
}
@ -86,8 +114,10 @@ export function useAuthSession() {
authStorage.clear()
setAuthenticated(false)
setSessionExpired(false)
if (isSSOHost()) window.location.assign('/api/auth/logout')
// Signing out also ends the Go Loose session, so the next application in the
// family asks for credentials again rather than letting this one back in.
if (isSSOHost()) window.location.assign(LOGOUT_PATH)
}
return { authenticated, sessionExpired, ssoStatus, completeLogin, logout }
return { authenticated, sessionExpired, ssoAvailable, completeLogin, logout }
}

View file

@ -0,0 +1,97 @@
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { describe, expect, it, vi } from 'vitest'
import type { User } from '../../types'
import { UserMenu } from './UserMenu'
const user: User = {
id: 1,
email: 'ada@example.test',
displayName: 'Ada Lovelace',
role: 'interviewer',
avatarUrl: 'https://auth.example.test/api/v1/avatars/kZ3abc',
}
function renderMenu(onSignOut = vi.fn()) {
render(
<MemoryRouter>
<UserMenu user={user} onSignOut={onSignOut} />
</MemoryRouter>,
)
return onSignOut
}
function trigger() {
return screen.getByRole('button', { name: /Ada Lovelace/ })
}
describe('UserMenu', () => {
it('starts closed and says so', () => {
renderMenu()
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
expect(screen.queryByRole('link', { name: 'Profile' })).not.toBeInTheDocument()
})
it('opens and closes on the trigger', async () => {
const user_ = userEvent.setup()
renderMenu()
await user_.click(trigger())
expect(trigger()).toHaveAttribute('aria-expanded', 'true')
expect(screen.getByRole('link', { name: 'Profile' })).toHaveAttribute('href', '/profile')
await user_.click(trigger())
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
})
it('closes on Escape and returns focus to the trigger', async () => {
const user_ = userEvent.setup()
renderMenu()
await user_.click(trigger())
await user_.keyboard('{Escape}')
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
expect(trigger()).toHaveFocus()
})
it('closes when the click lands outside it', async () => {
const user_ = userEvent.setup()
render(
<MemoryRouter>
<div>
<UserMenu user={user} onSignOut={vi.fn()} />
<button type="button">elsewhere</button>
</div>
</MemoryRouter>,
)
await user_.click(trigger())
await user_.click(screen.getByRole('button', { name: 'elsewhere' }))
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
})
it('signs out and closes', async () => {
const user_ = userEvent.setup()
const onSignOut = renderMenu()
await user_.click(trigger())
await user_.click(screen.getByRole('button', { name: 'Sign out' }))
expect(onSignOut).toHaveBeenCalledTimes(1)
expect(trigger()).toHaveAttribute('aria-expanded', 'false')
})
it('shows the picture and the address once open', async () => {
const user_ = userEvent.setup()
renderMenu()
await user_.click(trigger())
const panel = screen.getByRole('link', { name: 'Profile' }).closest('.user-menu-panel')!
expect(within(panel as HTMLElement).getByText('ada@example.test')).toBeInTheDocument()
expect(within(panel as HTMLElement).getByTestId('avatar-image')).toBeInTheDocument()
})
it('falls back to the address when there is no name', () => {
render(
<MemoryRouter>
<UserMenu user={{ ...user, displayName: '' }} onSignOut={vi.fn()} />
</MemoryRouter>,
)
expect(screen.getByRole('button', { name: /ada@example.test/ })).toBeInTheDocument()
})
})

View file

@ -0,0 +1,97 @@
import { LogOut, UserRound } from 'lucide-react'
import { useEffect, useId, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
import type { User } from '../../types'
import { Avatar } from '../ui/Avatar'
/**
* The account menu in the workspace utilities bar.
*
* It is a disclosure rather than a hover menu: the trigger is a real button with
* aria-expanded, the panel is labelled, Escape closes it, a click anywhere else
* closes it, and focus returns to the trigger. A menu that only opens on hover
* cannot be used from the keyboard or on a touch screen.
*
* The panel is not an ARIA menu. Its two actions are a link and a button, and
* giving them menuitem roles would strip the link semantics from one of them for
* no gain: everything here is reachable by tabbing, which is the behaviour that
* matters.
*/
export function UserMenu({ user, onSignOut }: { user: User | null; onSignOut: () => void }) {
const [open, setOpen] = useState(false)
const container = useRef<HTMLDivElement>(null)
const trigger = useRef<HTMLButtonElement>(null)
const panelId = useId()
useEffect(() => {
if (!open) return
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') {
setOpen(false)
trigger.current?.focus()
}
}
function onPointerDown(event: MouseEvent) {
if (!container.current?.contains(event.target as Node)) {
setOpen(false)
}
}
document.addEventListener('keydown', onKeyDown)
document.addEventListener('mousedown', onPointerDown)
return () => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('mousedown', onPointerDown)
}
}, [open])
const name = (user?.displayName || user?.email || 'Profile').trim()
const email = user?.email ?? ''
return (
<div className="user-menu" ref={container}>
<button
type="button"
ref={trigger}
className="user-menu-trigger"
aria-expanded={open}
aria-haspopup="menu"
aria-controls={panelId}
onClick={() => setOpen((value) => !value)}
>
<Avatar user={user} size="tiny" />
{/* The trigger stays to the name alone: the address is one click away in
the panel, and repeating it on every screen is noise. */}
<span className="user-menu-identity">
<span className="user-menu-name">{name}</span>
</span>
</button>
{open && (
<div className="user-menu-panel" id={panelId} aria-label="Account">
<div className="user-menu-header">
<Avatar user={user} size="large" />
<div>
<p className="user-menu-panel-name">{name}</p>
{email && <p className="user-menu-panel-email">{email}</p>}
</div>
</div>
<Link className="user-menu-item" to="/profile" onClick={() => setOpen(false)}>
<UserRound size={18} aria-hidden="true" />
<span>Profile</span>
</Link>
<button
type="button"
className="user-menu-item"
onClick={() => {
setOpen(false)
onSignOut()
}}
>
<LogOut size={18} aria-hidden="true" />
<span>Sign out</span>
</button>
</div>
)}
</div>
)
}

View file

@ -1,9 +1,15 @@
import { CalendarDays, Mail } from 'lucide-react'
import { NavLink } from 'react-router-dom'
import type { User } from '../../types'
import { UserMenu } from './UserMenu'
export function WorkspaceUtilities({ user }: { user: User | null }) {
const profileLabel = user?.displayName || user?.email || 'Profile'
export function WorkspaceUtilities({
user,
onSignOut,
}: {
user: User | null
onSignOut: () => void
}) {
return (
<nav className="workspace-utilities" aria-label="Account navigation">
<NavLink className="utility-link" to="/invitations">
@ -14,12 +20,7 @@ export function WorkspaceUtilities({ user }: { user: User | null }) {
<CalendarDays size={18} aria-hidden="true" />
<span>Schedule</span>
</NavLink>
<NavLink className="utility-link profile-utility" to="/profile">
<span className="avatar tiny" aria-hidden="true">
{profileLabel.slice(0, 2).toUpperCase()}
</span>
<span>{profileLabel}</span>
</NavLink>
<UserMenu user={user} onSignOut={onSignOut} />
</nav>
)
}

View file

@ -0,0 +1,76 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import type { User } from '../../types'
import { Avatar } from './Avatar'
import { initialsFor } from './initials'
const withPicture = {
displayName: 'Ada Lovelace',
email: 'ada@example.test',
avatarUrl: 'https://auth.example.test/api/v1/avatars/kZ3abc',
}
const withoutPicture = { displayName: 'Ada Lovelace', email: 'ada@example.test' }
function renderAvatar(user: Partial<Pick<User, 'displayName' | 'email' | 'avatarUrl'>> | null) {
return render(<Avatar user={user} />)
}
describe('initialsFor', () => {
it('uses the first letter of the first two words', () => {
expect(initialsFor('Ada Lovelace')).toBe('AL')
expect(initialsFor('Ada Byron King Lovelace')).toBe('AB')
})
it('falls back to the address, and to a single name', () => {
expect(initialsFor('', 'ada@example.test')).toBe('AD')
expect(initialsFor('Prince')).toBe('PR')
})
it('has something to show for nothing at all', () => {
expect(initialsFor('', '')).toBe('?')
expect(initialsFor(null, null)).toBe('?')
})
})
describe('Avatar', () => {
it('renders the picture Go Loose serves', () => {
renderAvatar(withPicture)
const image = screen.getByTestId('avatar-image').querySelector('img')
expect(image).toHaveAttribute('src', withPicture.avatarUrl)
// The surrounding element already names the person, so the image itself is
// decorative and must not be announced twice.
expect(image).toHaveAttribute('alt', '')
})
it('falls back to initials when there is no picture', () => {
renderAvatar(withoutPicture)
expect(screen.getByTestId('avatar-fallback')).toHaveTextContent('AL')
})
it('falls back to initials when the stored link no longer resolves', () => {
// Go Loose retires a picture key when the picture changes, so a cached URL in
// this browser can be dead. A broken image icon would be worse than initials.
renderAvatar(withPicture)
const image = screen.getByTestId('avatar-image').querySelector('img')!
// An error event on an image does not bubble, so React only sees it when the
// event is dispatched through the testing library.
fireEvent.error(image)
expect(screen.getByTestId('avatar-fallback')).toHaveTextContent('AL')
})
it('has something to show for no user at all', () => {
// With no person there is no name either, so the neutral placeholder the rest
// of the interface uses stands in.
renderAvatar(null)
expect(screen.getByTestId('avatar-fallback')).toHaveTextContent('PR')
})
})
describe('Avatar sizing', () => {
it('carries the requested size', () => {
const { container } = render(<Avatar user={withoutPicture} size="tiny" className="extra" />)
const avatar = container.querySelector('.avatar')!
expect(avatar.className).toContain('tiny')
expect(avatar.className).toContain('extra')
})
})

View file

@ -0,0 +1,45 @@
import { useState } from 'react'
import type { User } from '../../types'
import { initialsFor } from './initials'
/**
* A person's picture, falling back to their initials.
*
* The URL comes from Go Loose and stops resolving the moment the picture is
* replaced or removed there, because retiring the key is what stops a stale link
* from being a privacy problem. A cached copy in this browser can therefore be
* dead, so a failed load falls back rather than leaving a broken image. An empty
* or absent URL is the common case for anyone who has not uploaded one.
*/
export function Avatar({
user,
size = 'default',
className = '',
}: {
user: Partial<Pick<User, 'displayName' | 'email' | 'avatarUrl'>> | null
size?: 'tiny' | 'default' | 'large'
className?: string
}) {
const url = user?.avatarUrl ?? ''
// Remembering which URL failed, rather than a bare flag, means a new picture
// for the same person is retried without an effect to reset anything.
const [failedUrl, setFailedUrl] = useState<string | null>(null)
const failed = failedUrl === url && url !== ''
const classes = ['avatar', size === 'default' ? '' : size, className].filter(Boolean).join(' ')
const label = (user?.displayName || user?.email || 'Profile').trim()
if (!url || failed) {
return (
<span className={classes} aria-hidden="true" data-testid="avatar-fallback">
{initialsFor(label)}
</span>
)
}
return (
<span className={classes} data-testid="avatar-image">
<img src={url} alt="" onError={() => setFailedUrl(url)} />
<span className="sr-only">{label}</span>
</span>
)
}

View file

@ -0,0 +1,16 @@
/**
* The person's initials, used until a picture loads and whenever there is none.
* The first letter of the first two words reads better than one letter, so
* "Ada Lovelace" gives AL rather than A.
*/
export function initialsFor(name: string | null | undefined, email?: string | null): string {
const source = (name ?? '').trim() || (email ?? '').trim()
if (!source) return '?'
// An address is not a name: "ada@example.test" must not become AE, so only the
// part before the @ counts.
const local = source.includes('@') ? source.slice(0, source.indexOf('@')) : source
const words = local.split(/[\s._-]+/).filter(Boolean)
if (words.length === 0) return '?'
if (words.length === 1) return words[0]!.slice(0, 2).toUpperCase()
return (words[0]!.slice(0, 1) + words[1]!.slice(0, 1)).toUpperCase()
}

View file

@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { LoginPage } from './LoginPage'
function stubLocation() {
const location = { assign: vi.fn(), replace: vi.fn(), hostname: 'nmbs.guess.urpi.be' }
Object.defineProperty(window, 'location', {
configurable: true,
writable: true,
value: location,
})
return location
}
function renderPage(ssoAvailable: boolean | null) {
return render(
<MemoryRouter>
<LoginPage onLogin={vi.fn()} sessionExpired={false} ssoAvailable={ssoAvailable} />
</MemoryRouter>,
)
}
describe('LoginPage', () => {
afterEach(() => {
vi.restoreAllMocks()
})
it('offers only the Go Loose button while the answer is unknown', () => {
renderPage(null)
expect(screen.getByRole('button', { name: /sign in with go loose/i })).toBeInTheDocument()
expect(screen.queryByLabelText(/email address/i)).not.toBeInTheDocument()
expect(screen.queryByLabelText(/^password$/i)).not.toBeInTheDocument()
})
it('offers only the Go Loose button where Go Loose is configured', () => {
renderPage(true)
expect(screen.getByRole('button', { name: /sign in with go loose/i })).toBeInTheDocument()
expect(screen.queryByLabelText(/email address/i)).not.toBeInTheDocument()
})
it('sends the browser to Go Loose rather than routing there', async () => {
// A router push cannot follow a redirect chain across two origins, so this has
// to be a real navigation.
const location = stubLocation()
const user = userEvent.setup()
renderPage(true)
await user.click(screen.getByRole('button', { name: /sign in with go loose/i }))
expect(location.assign).toHaveBeenCalledWith('/api/auth/login')
})
it('falls back to the password form on a host without Go Loose login', () => {
renderPage(false)
expect(screen.getByLabelText(/email address/i)).toBeInTheDocument()
expect(screen.getByLabelText(/^password$/i)).toBeInTheDocument()
expect(screen.queryByRole('button', { name: /sign in with go loose/i })).not.toBeInTheDocument()
})
it('says why the password form is being shown', () => {
renderPage(false)
expect(screen.getByText(/single sign-on is not configured/i)).toBeInTheDocument()
})
})

View file

@ -1,20 +1,46 @@
import { ArrowRight, CircleHelp, Sparkles } from 'lucide-react'
import { ArrowRight, CircleHelp, KeyRound, Sparkles } from 'lucide-react'
import { SyntheticEvent, useState } from 'react'
import Logo from '../../components/layout/Logo'
import { FormField } from '../../components/form'
import { ErrorAlert, errorMessage } from '../../components/actions'
import { api, authStorage } from '../../api/client'
const LOGIN_PATH = '/api/auth/login'
/**
* The sign-in screen.
*
* Signing in happens at Go Loose, so the usual case is one button that hands the
* browser over to it and comes back to the welcome page. That also means a person
* who already signed in at another application passes straight through, because
* Go Loose recognises the session it already holds and issues a code without
* asking anything.
*
* The password form is only for a host where Go Loose login is not configured:
* the API answers /api/auth/session with 404 there, and without a fallback the
* application would be unreachable on a bare localhost. It is not offered where
* the identity provider is in charge.
*/
export function LoginPage({
onLogin,
sessionExpired,
ssoAvailable,
}: {
onLogin: () => void
sessionExpired: boolean
/** null until the API has said whether Go Loose login is configured here. */
ssoAvailable: boolean | null
}) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
function beginExternalLogin() {
setLoading(true)
// A full navigation, not a router push: Go Loose answers with a redirect
// chain across two origins that the router has no part in.
window.location.assign(LOGIN_PATH)
}
async function submit(event: SyntheticEvent<HTMLFormElement>) {
event.preventDefault()
setLoading(true)
@ -58,25 +84,46 @@ export function LoginPage({
Your session is no longer active. Sign in again to continue.
</div>
)}
<p className="muted">Use your work account to continue.</p>
<form onSubmit={submit} className="stack-form">
<FormField label="Email address" htmlFor="email">
<input id="email" name="email" type="email" autoComplete="email" required />
</FormField>
<FormField label="Password" htmlFor="password">
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
/>
</FormField>
{error && <ErrorAlert message={error} />}
<button className="button primary wide" type="submit" disabled={loading}>
{loading ? 'Signing in…' : 'Sign in'} <ArrowRight size={18} />
</button>
</form>
<p className="muted">
Use your work account to continue. If you are already signed in elsewhere, this takes
you straight through.
</p>
{ssoAvailable === false ? (
<form onSubmit={submit} className="stack-form">
<p className="muted">
Single sign-on is not configured for this host. Sign in with a local account.
</p>
<FormField label="Email address" htmlFor="email">
<input id="email" name="email" type="email" autoComplete="email" required />
</FormField>
<FormField label="Password" htmlFor="password">
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
/>
</FormField>
{error && <ErrorAlert message={error} />}
<button className="button primary wide" type="submit" disabled={loading}>
{loading ? 'Signing in…' : 'Sign in'} <ArrowRight size={18} />
</button>
</form>
) : (
<div className="stack-form">
<button
className="button primary wide"
type="button"
onClick={beginExternalLogin}
disabled={loading}
>
{loading ? 'Redirecting…' : 'Sign in with Go Loose'} <KeyRound size={18} />
</button>
</div>
)}
<p className="demo-note">
<CircleHelp size={16} /> Contact your administrator for access.
</p>

View file

@ -1,5 +1,6 @@
import { Mail, ShieldCheck, UserRound } from 'lucide-react'
import { authStorage } from '../../api/client'
import { Avatar } from '../../components/ui/Avatar'
export function ProfilePage() {
const user = authStorage.user()
@ -14,9 +15,8 @@ export function ProfilePage() {
</header>
<section className="card profile-card">
<div className="profile-identity">
<span className="avatar profile-avatar" aria-hidden="true">
{user.displayName.slice(0, 2).toUpperCase()}
</span>
{/* The picture Go Loose serves, with the initials as the fallback. */}
<Avatar user={user} size="large" className="profile-avatar" />
<div>
<h1>{user.displayName}</h1>
<span className={`status status-${user.role}`}>{user.role}</span>

View file

@ -2802,3 +2802,117 @@ fieldset {
animation-iteration-count: 1 !important;
}
}
/* Account menu.
The trigger sits in the utilities bar and the panel is anchored to it, so the
panel is positioned against a relatively positioned wrapper rather than the
viewport: it must stay with its trigger when the utilities bar wraps. */
.user-menu {
position: relative;
}
.user-menu-trigger {
display: inline-flex;
align-items: center;
gap: 0.55rem;
min-height: 40px;
padding: 0.3rem 0.7rem 0.3rem 0.35rem;
background: transparent;
border: 1px solid transparent;
border-radius: 10px;
font: inherit;
color: var(--ink);
cursor: pointer;
text-align: left;
transition:
border-color 0.2s,
background 0.2s;
}
.user-menu-trigger:hover,
.user-menu-trigger:focus-visible {
border-color: var(--line);
background: var(--surface);
}
.user-menu-trigger:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: 2px;
}
.user-menu-identity {
display: grid;
line-height: 1.2;
}
.user-menu-name {
font-size: 0.84rem;
font-weight: 700;
}
.user-menu-email {
font-size: 0.72rem;
color: var(--muted);
}
.user-menu-panel {
position: absolute;
top: calc(100% + 0.5rem);
right: 0;
z-index: 30;
min-width: 16rem;
padding: 0.5rem;
background: var(--surface);
border: 1px solid var(--line);
border-radius: 14px;
box-shadow: var(--shadow);
}
.user-menu-header {
display: flex;
align-items: center;
gap: 0.85rem;
padding: 0.6rem 0.7rem 0.85rem;
border-bottom: 1px solid var(--line);
margin-bottom: 0.4rem;
}
.user-menu-header p {
margin: 0;
}
.user-menu-panel-name {
font-weight: 700;
}
.user-menu-panel-email {
font-size: 0.78rem;
color: var(--muted);
overflow-wrap: anywhere;
}
.user-menu-item {
display: flex;
align-items: center;
gap: 0.6rem;
width: 100%;
padding: 0.6rem 0.7rem;
background: transparent;
border: 0;
border-radius: 10px;
font: inherit;
font-size: 0.86rem;
font-weight: 600;
color: var(--ink);
text-decoration: none;
cursor: pointer;
}
.user-menu-item:hover,
.user-menu-item:focus-visible {
background: #eef6f7;
}
.user-menu-item:focus-visible {
outline: 2px solid var(--cyan);
outline-offset: -2px;
}
.user-menu-panel + .user-menu-item {
margin-top: 0.15rem;
}
@media (max-width: 720px) {
/* The sidebar carries the same actions on a narrow screen, so the menu keeps
only the picture and its accessible name. */
.user-menu-identity {
display: none;
}
.user-menu-trigger {
padding: 0.3rem;
}
}

View file

@ -5,6 +5,12 @@ export interface User {
email: string
displayName: string
role: string
/**
* Absolute URL of the picture Go Loose serves for this person, or empty when
* they have not uploaded one. Resolved on every sign-in rather than stored, so
* it always reflects the current picture.
*/
avatarUrl?: string
}
export interface AuthResponse {

View file

@ -4,7 +4,14 @@ import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [react()],
server: {
allowedHosts: ['nmbs.guess.local', 'ypto.guess.local'],
allowedHosts: [
'nmbs.guess-dev.urpi.be',
'ypto.guess-dev.urpi.be',
'nmbs.guess.urpi.be',
'ypto.guess.urpi.be',
'nmbs.guess.local',
'ypto.guess.local',
],
proxy: {
'/api': {
target: 'http://localhost:8080',