mirror of
https://github.com/azaion/ui.git
synced 2026-06-21 11:01:11 +00:00
Merge branch 'dev' into feat/dataset-explorer
This commit is contained in:
+8
-10
@@ -1,14 +1,12 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { AuthProvider } from './auth/AuthContext'
|
||||
import { FlightProvider } from './components/FlightContext'
|
||||
import ProtectedRoute from './auth/ProtectedRoute'
|
||||
import LoginPage from './features/login/LoginPage'
|
||||
import FlightsPage from './features/flights/FlightsPage'
|
||||
import AnnotationsPage from './features/annotations/AnnotationsPage'
|
||||
import DatasetPage from './features/dataset/DatasetPage'
|
||||
import AdminPage from './features/admin/AdminPage'
|
||||
import SettingsPage from './features/settings/SettingsPage'
|
||||
import Header from './components/Header'
|
||||
import { AuthProvider, ProtectedRoute } from './auth'
|
||||
import { Header, FlightProvider } from './components'
|
||||
import { LoginPage } from './features/login'
|
||||
import { FlightsPage } from './features/flights'
|
||||
import { AnnotationsPage } from './features/annotations'
|
||||
import { DatasetPage } from './features/dataset'
|
||||
import { AdminPage } from './features/admin'
|
||||
import { SettingsPage } from './features/settings'
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { server } from '../../tests/msw/server'
|
||||
import { api, getToken, setToken } from './client'
|
||||
import { seedBearer, clearBearer } from '../../tests/helpers/auth'
|
||||
import { seedNavigateToLogin } from '../../tests/helpers/navigate'
|
||||
|
||||
// AZ-457 — Auth & token-handling at the apiClient surface.
|
||||
// FT-P-02 / rows 03, 12 — 401 → refresh → retry sequence (fast profile)
|
||||
// NFT-SEC-04 / row 06 — credentials:'include' on the cookie-bound refresh
|
||||
// NFT-PERF-02 / row 12 — exactly one /auth/refresh per refresh cycle
|
||||
// NFT-RES-01 / rows 03, 11 — apiClient half of transparent recovery
|
||||
// (render stability lives in AuthContext.test.tsx)
|
||||
// NFT-RES-08 — expired refresh cookie → setNavigateToLogin spy fires
|
||||
//
|
||||
// FT-P-01 (bootstrap refresh credentials:'include') exercises the React
|
||||
// composition root (<AuthContext>'s mount-time fetch) and lives in
|
||||
// src/auth/AuthContext.test.tsx — the bootstrap path goes through api.get()
|
||||
// today, which does NOT thread credentials. Row 02 is `quarantined` until
|
||||
// the Step 4 bootstrap fix lands; the inverted assertion lives next to its
|
||||
// system-under-test.
|
||||
//
|
||||
// Black-box discipline (per AZ-457 AC-2): we only import the three public
|
||||
// accessors `setToken / getToken / setNavigateToLogin` plus the `api` wrapper
|
||||
// itself. We do NOT reach into request() / refreshToken() / handleResponse().
|
||||
// Outbound requests are observed via MSW `server.use(...)` interception.
|
||||
|
||||
describe('AZ-457 / src/api/client.ts — auth & token handling', () => {
|
||||
afterEach(() => {
|
||||
clearBearer()
|
||||
})
|
||||
|
||||
describe('FT-P-02 (rows 03, 12) — 401 → refresh → retry sequence', () => {
|
||||
it('refreshes once, retries the original request, returns 200', async () => {
|
||||
// Arrange
|
||||
seedBearer('expiring-bearer')
|
||||
let getHits = 0
|
||||
let refreshHits = 0
|
||||
const observedAuthHeaders: Array<string | null> = []
|
||||
|
||||
server.use(
|
||||
http.get('/api/admin/users/me', ({ request }) => {
|
||||
getHits += 1
|
||||
observedAuthHeaders.push(request.headers.get('Authorization'))
|
||||
if (getHits === 1) {
|
||||
return new HttpResponse(null, { status: 401 })
|
||||
}
|
||||
return HttpResponse.json({ id: 'user-alice', email: 'op_alice@test.local' })
|
||||
}),
|
||||
http.post('/api/admin/auth/refresh', () => {
|
||||
refreshHits += 1
|
||||
return HttpResponse.json({ token: 'rotated-bearer' })
|
||||
}),
|
||||
)
|
||||
|
||||
// Act
|
||||
const me = await api.get<{ id: string; email: string }>('/api/admin/users/me')
|
||||
|
||||
// Assert — sequence per row 03 + count per row 12.
|
||||
expect(refreshHits).toBe(1)
|
||||
expect(getHits).toBe(2) // first 401, then retry
|
||||
expect(observedAuthHeaders[0]).toBe('Bearer expiring-bearer')
|
||||
expect(observedAuthHeaders[1]).toBe('Bearer rotated-bearer')
|
||||
expect(me.id).toBe('user-alice')
|
||||
expect(getToken()).toBe('rotated-bearer')
|
||||
})
|
||||
|
||||
it('does NOT refresh when there is no bearer (no in-flight session)', async () => {
|
||||
// Arrange — no seedBearer call → getToken() === null.
|
||||
let refreshHits = 0
|
||||
server.use(
|
||||
http.get('/api/admin/users/me', () => new HttpResponse(null, { status: 401 })),
|
||||
http.post('/api/admin/auth/refresh', () => {
|
||||
refreshHits += 1
|
||||
return HttpResponse.json({ token: 'should-not-be-issued' })
|
||||
}),
|
||||
)
|
||||
|
||||
// Act + Assert — request() short-circuits the refresh branch when
|
||||
// accessToken is null (refreshing without a session would be a security
|
||||
// anti-pattern).
|
||||
await expect(api.get('/api/admin/users/me')).rejects.toThrow(/^401:/)
|
||||
expect(refreshHits).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('NFT-SEC-04 (row 06) — credentials:\'include\' on the cookie-bound refresh', () => {
|
||||
it('the 401-recovery POST /auth/refresh carries credentials:\'include\'', async () => {
|
||||
// Arrange
|
||||
seedBearer('expiring-bearer')
|
||||
let refreshCredentials: RequestCredentials | null = null
|
||||
let refreshMethod: string | null = null
|
||||
server.use(
|
||||
http.get('/api/admin/users/me', () => new HttpResponse(null, { status: 401 })),
|
||||
http.post('/api/admin/auth/refresh', ({ request }) => {
|
||||
refreshCredentials = request.credentials
|
||||
refreshMethod = request.method
|
||||
return new HttpResponse(null, { status: 401 })
|
||||
}),
|
||||
)
|
||||
const navSpy = seedNavigateToLogin()
|
||||
|
||||
// Act
|
||||
await expect(api.get('/api/admin/users/me')).rejects.toThrow('Session expired')
|
||||
|
||||
// Assert
|
||||
expect(refreshMethod).toBe('POST')
|
||||
expect(refreshCredentials).toBe('include')
|
||||
expect(navSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// The broader "every authed fetch" claim is `quarantined` per row 02 in
|
||||
// results_report.md. Today the apiClient does NOT thread credentials onto
|
||||
// non-refresh requests; only the cookie-bound refreshToken() helper does.
|
||||
// The inverted assertion below documents the divergence so a future fix
|
||||
// is a deliberate decision (the test starts failing the day every authed
|
||||
// fetch carries credentials:'include' — at which point the it.fails
|
||||
// wrapper is removed in the same commit as the production fix).
|
||||
it.fails('every authed apiClient fetch carries credentials:\'include\' (quarantined — Step 4 bootstrap fix pending)', async () => {
|
||||
// Arrange
|
||||
seedBearer('test-bearer-default')
|
||||
const credentialsByUrl: Record<string, RequestCredentials> = {}
|
||||
server.use(
|
||||
http.all('/api/admin/*', ({ request }) => {
|
||||
credentialsByUrl[request.url] = request.credentials
|
||||
return new HttpResponse(null, { status: 204 })
|
||||
}),
|
||||
)
|
||||
|
||||
// Act
|
||||
await api.get('/api/admin/users/me')
|
||||
|
||||
// Assert — intentionally fails today; row 02 quarantined.
|
||||
expect(Object.values(credentialsByUrl).every((c) => c === 'include')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('NFT-PERF-02 (row 12) — exactly one refresh round trip per cycle', () => {
|
||||
it('a single 401-retry cycle issues exactly one /auth/refresh', async () => {
|
||||
// Arrange
|
||||
seedBearer('expiring-bearer')
|
||||
let refreshHits = 0
|
||||
const status401Once = vi.fn<() => Response>(() => new HttpResponse(null, { status: 401 }) as Response)
|
||||
let firstGet = true
|
||||
server.use(
|
||||
http.get('/api/admin/users/me', () => {
|
||||
if (firstGet) {
|
||||
firstGet = false
|
||||
return status401Once()
|
||||
}
|
||||
return HttpResponse.json({ id: 'user-alice', email: 'op_alice@test.local' })
|
||||
}),
|
||||
http.post('/api/admin/auth/refresh', () => {
|
||||
refreshHits += 1
|
||||
return HttpResponse.json({ token: 'one-time-rotation' })
|
||||
}),
|
||||
)
|
||||
|
||||
// Act
|
||||
await api.get('/api/admin/users/me')
|
||||
|
||||
// Assert
|
||||
expect(refreshHits).toBe(1)
|
||||
expect(status401Once).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('two parallel authed requests, both 401 → still one refresh per call (no global de-dupe expected)', async () => {
|
||||
// Arrange — Today's client.ts does NOT coalesce parallel refreshes. Each
|
||||
// 401 in-flight produces its own refresh round trip. This test pins the
|
||||
// current behavior so a future "race-coalescing" change is a deliberate
|
||||
// decision (one-cycle == one-refresh per row 12; cycles are per call).
|
||||
seedBearer('expiring-bearer')
|
||||
const tokens = ['rot1', 'rot2']
|
||||
let refreshHits = 0
|
||||
const seenAuthHeaders: string[] = []
|
||||
server.use(
|
||||
http.get('/api/admin/users/me', ({ request }) => {
|
||||
const h = request.headers.get('Authorization')
|
||||
seenAuthHeaders.push(h ?? '<none>')
|
||||
if (h === 'Bearer expiring-bearer') return new HttpResponse(null, { status: 401 })
|
||||
return HttpResponse.json({ id: 'user-alice', email: 'op_alice@test.local' })
|
||||
}),
|
||||
http.get('/api/admin/users', ({ request }) => {
|
||||
const h = request.headers.get('Authorization')
|
||||
seenAuthHeaders.push(h ?? '<none>')
|
||||
if (h === 'Bearer expiring-bearer') return new HttpResponse(null, { status: 401 })
|
||||
return HttpResponse.json({ items: [], totalCount: 0, page: 1, pageSize: 10 })
|
||||
}),
|
||||
http.post('/api/admin/auth/refresh', () => {
|
||||
const t = tokens[refreshHits] ?? 'rot-fallback'
|
||||
refreshHits += 1
|
||||
return HttpResponse.json({ token: t })
|
||||
}),
|
||||
)
|
||||
|
||||
// Act
|
||||
await Promise.all([api.get('/api/admin/users/me'), api.get('/api/admin/users')])
|
||||
|
||||
// Assert — two independent cycles → two refreshes (no coalescing).
|
||||
expect(refreshHits).toBe(2)
|
||||
// Both retries reissued with a non-stale bearer.
|
||||
expect(seenAuthHeaders.filter((h) => h === 'Bearer expiring-bearer')).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('NFT-RES-01 (rows 03, 11) — apiClient half of transparent recovery', () => {
|
||||
it('callers observe a 200 response with no error surface after refresh+retry', async () => {
|
||||
// Arrange — render-stability half lives in AuthContext.test.tsx; this is
|
||||
// strictly the apiClient observable: from the caller's perspective, the
|
||||
// 401 was invisible.
|
||||
seedBearer('expiring-bearer')
|
||||
let firstHit = true
|
||||
server.use(
|
||||
http.get('/api/admin/users/me', () => {
|
||||
if (firstHit) {
|
||||
firstHit = false
|
||||
return new HttpResponse(null, { status: 401 })
|
||||
}
|
||||
return HttpResponse.json({ id: 'user-alice', email: 'op_alice@test.local' })
|
||||
}),
|
||||
http.post('/api/admin/auth/refresh', () =>
|
||||
HttpResponse.json({ token: 'transparent-recovery' }),
|
||||
),
|
||||
)
|
||||
|
||||
// Act
|
||||
const result = await api.get<{ id: string }>('/api/admin/users/me')
|
||||
|
||||
// Assert — caller saw success, never an exception.
|
||||
expect(result.id).toBe('user-alice')
|
||||
// Side-effect: bearer was silently rotated (rows 03, 11).
|
||||
expect(getToken()).toBe('transparent-recovery')
|
||||
})
|
||||
})
|
||||
|
||||
describe('NFT-RES-08 — expired refresh cookie → setNavigateToLogin spy fires', () => {
|
||||
it('clears bearer and invokes navigateToLogin spy when refresh returns 401', async () => {
|
||||
// Arrange
|
||||
seedBearer('expired-bearer')
|
||||
const navSpy = seedNavigateToLogin()
|
||||
server.use(
|
||||
http.get('/api/admin/users/me', () => new HttpResponse(null, { status: 401 })),
|
||||
http.post('/api/admin/auth/refresh', () => new HttpResponse(null, { status: 401 })),
|
||||
)
|
||||
|
||||
// Act + Assert
|
||||
await expect(api.get('/api/admin/users/me')).rejects.toThrow('Session expired')
|
||||
expect(navSpy).toHaveBeenCalledTimes(1)
|
||||
expect(navSpy).toHaveBeenCalledWith() // AC-4 — no args
|
||||
expect(getToken()).toBeNull() // bearer cleared
|
||||
})
|
||||
|
||||
it('does not navigate or clear bearer when 401 occurs without a session', async () => {
|
||||
// Arrange — refresh-bridge invariant: navigate only after a refresh
|
||||
// attempt failed (not on every 401).
|
||||
const navSpy = seedNavigateToLogin()
|
||||
server.use(
|
||||
http.get('/api/admin/users/me', () => new HttpResponse(null, { status: 401 })),
|
||||
)
|
||||
|
||||
// Act + Assert
|
||||
await expect(api.get('/api/admin/users/me')).rejects.toThrow(/^401:/)
|
||||
expect(navSpy).not.toHaveBeenCalled()
|
||||
expect(getToken()).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// FT-P-01 (row 02) bootstrap-credentials assertion lives in
|
||||
// src/auth/AuthContext.test.tsx because it observes <AuthProvider>'s
|
||||
// mount-time fetch. Splitting it out keeps the system-under-test obvious.
|
||||
|
||||
// Compile-time guard — the test never imports a private symbol from client.ts.
|
||||
type _PublicSurface = typeof setToken extends (t: string | null) => void ? true : never
|
||||
const _checkPublicSurface: _PublicSurface = true
|
||||
void _checkPublicSurface
|
||||
+50
-4
@@ -1,13 +1,58 @@
|
||||
import { endpoints } from './endpoints'
|
||||
|
||||
let accessToken: string | null = null
|
||||
|
||||
/**
|
||||
* Single source of truth for the in-memory bearer token.
|
||||
*
|
||||
* The bearer is intentionally held in module scope (NOT in localStorage /
|
||||
* sessionStorage / cookies) per AC-02 / restriction O2 — the only way to
|
||||
* survive a page reload is through the HttpOnly refresh cookie + the
|
||||
* `refreshToken()` round-trip in `request()` below.
|
||||
*
|
||||
* Tests override this hook to seed a bearer without going through the full
|
||||
* login flow. See `_docs/02_document/tests/test-data.md` ("Stubbed bearer /
|
||||
* cookie in test helpers"). DO NOT delete this accessor as "dead code" —
|
||||
* it is reflectively used by the test harness; the import looks dead at
|
||||
* static-grep time but is not.
|
||||
*/
|
||||
export function setToken(token: string | null) {
|
||||
accessToken = token
|
||||
}
|
||||
|
||||
/** Read-side companion to {@link setToken}. */
|
||||
export function getToken() {
|
||||
return accessToken
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the base URL prefix for every API request. Returns `''` (no
|
||||
* prefix) on production builds where the SPA and the suite share the same
|
||||
* nginx (E2); tests + alternative deployments override via the Vite env
|
||||
* var `VITE_API_BASE_URL`. A trailing slash is stripped so a value of
|
||||
* `http://host/` does not produce `http://host//api/...`.
|
||||
*/
|
||||
export function getApiBase(): string {
|
||||
const raw = import.meta.env.VITE_API_BASE_URL
|
||||
if (!raw) return ''
|
||||
return raw.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Indirection for the failed-refresh redirect. Default impl writes
|
||||
* `'/login'` to `window.location.href` — the production behavior. Tests
|
||||
* override via {@link setNavigateToLogin} to assert "redirect was invoked"
|
||||
* without globally stubbing `window.location`. Must be reset by the test
|
||||
* harness in teardown (see `_docs/02_document/tests/test-data.md`).
|
||||
*/
|
||||
let navigateToLoginImpl: () => void = () => {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
|
||||
export function setNavigateToLogin(fn: () => void) {
|
||||
navigateToLoginImpl = fn
|
||||
}
|
||||
|
||||
async function handleResponse<T>(res: Response): Promise<T> {
|
||||
if (res.status === 204) return undefined as T
|
||||
if (!res.ok) {
|
||||
@@ -22,16 +67,17 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
if (accessToken) headers.set('Authorization', `Bearer ${accessToken}`)
|
||||
if (options.body && typeof options.body === 'string') headers.set('Content-Type', 'application/json')
|
||||
|
||||
let res = await fetch(url, { ...options, headers })
|
||||
const fullUrl = getApiBase() + url
|
||||
let res = await fetch(fullUrl, { ...options, headers })
|
||||
|
||||
if (res.status === 401 && accessToken) {
|
||||
const refreshed = await refreshToken()
|
||||
if (refreshed) {
|
||||
headers.set('Authorization', `Bearer ${accessToken}`)
|
||||
res = await fetch(url, { ...options, headers })
|
||||
res = await fetch(fullUrl, { ...options, headers })
|
||||
} else {
|
||||
setToken(null)
|
||||
window.location.href = '/login'
|
||||
navigateToLoginImpl()
|
||||
throw new Error('Session expired')
|
||||
}
|
||||
}
|
||||
@@ -41,7 +87,7 @@ async function request<T>(url: string, options: RequestInit = {}): Promise<T> {
|
||||
|
||||
async function refreshToken(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch('/api/admin/auth/refresh', { method: 'POST', credentials: 'include' })
|
||||
const res = await fetch(getApiBase() + endpoints.admin.authRefresh(), { method: 'POST', credentials: 'include' })
|
||||
if (!res.ok) return false
|
||||
const data = await res.json()
|
||||
setToken(data.token)
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { endpoints } from './endpoints'
|
||||
import { endpoints as endpointsViaBarrel } from '../api'
|
||||
|
||||
// AZ-486 / F7 — this test file IS the wire-contract for the UI ↔ nginx layer
|
||||
// (per `module-layout.md`'s "code-derived documentation" pattern referenced in
|
||||
// the task spec). Every builder is asserted to produce the URL literal that
|
||||
// existed in source before the refactor and that MSW handlers + e2e stubs
|
||||
// intercept today. A change to any assertion below is a wire-contract change
|
||||
// and MUST be coordinated with backend + MSW + e2e stubs in the same commit.
|
||||
|
||||
describe('AZ-486 endpoints — wire-contract URLs', () => {
|
||||
describe('AC-1: admin', () => {
|
||||
it('admin.authRefresh', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.authRefresh()).toBe('/api/admin/auth/refresh')
|
||||
})
|
||||
|
||||
it('admin.authLogin', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.authLogin()).toBe('/api/admin/auth/login')
|
||||
})
|
||||
|
||||
it('admin.authLogout', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.authLogout()).toBe('/api/admin/auth/logout')
|
||||
})
|
||||
|
||||
it('admin.users', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.users()).toBe('/api/admin/users')
|
||||
})
|
||||
|
||||
it('admin.usersMe (AZ-510 — bootstrap chain)', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.usersMe()).toBe('/api/admin/users/me')
|
||||
})
|
||||
|
||||
it('admin.user(id) interpolates the id', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.user('abc')).toBe('/api/admin/users/abc')
|
||||
})
|
||||
|
||||
it('admin.classes', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.classes()).toBe('/api/admin/classes')
|
||||
})
|
||||
|
||||
it('admin.class(id) interpolates the id (string)', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.class('cls-7')).toBe('/api/admin/classes/cls-7')
|
||||
})
|
||||
|
||||
it('admin.class(id) interpolates the id (number — DetectionClass.id today)', () => {
|
||||
// Assert
|
||||
expect(endpoints.admin.class(42)).toBe('/api/admin/classes/42')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-1: annotations', () => {
|
||||
it('annotations.classes', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.classes()).toBe('/api/annotations/classes')
|
||||
})
|
||||
|
||||
it('annotations.settingsUser', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.settingsUser()).toBe(
|
||||
'/api/annotations/settings/user',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.settingsSystem', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.settingsSystem()).toBe(
|
||||
'/api/annotations/settings/system',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.settingsDirectories', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.settingsDirectories()).toBe(
|
||||
'/api/annotations/settings/directories',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.annotations', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.annotations()).toBe(
|
||||
'/api/annotations/annotations',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.annotationsByMedia(mediaId) defaults pageSize=1000', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.annotationsByMedia('m-1')).toBe(
|
||||
'/api/annotations/annotations?mediaId=m-1&pageSize=1000',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.annotationsByMedia(mediaId, pageSize) overrides pageSize', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.annotationsByMedia('m-1', 50)).toBe(
|
||||
'/api/annotations/annotations?mediaId=m-1&pageSize=50',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.annotationImage(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.annotationImage('ann-7')).toBe(
|
||||
'/api/annotations/annotations/ann-7/image',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.annotationThumbnail(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.annotationThumbnail('ann-7')).toBe(
|
||||
'/api/annotations/annotations/ann-7/thumbnail',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.annotationEvents', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.annotationEvents()).toBe(
|
||||
'/api/annotations/annotations/events',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.media(queryString)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.media('page=1&pageSize=50')).toBe(
|
||||
'/api/annotations/media?page=1&pageSize=50',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.mediaFile(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.mediaFile('m-1')).toBe(
|
||||
'/api/annotations/media/m-1/file',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.mediaItem(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.mediaItem('m-1')).toBe(
|
||||
'/api/annotations/media/m-1',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.mediaBatch', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.mediaBatch()).toBe(
|
||||
'/api/annotations/media/batch',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.dataset(queryString)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.dataset('status=PENDING')).toBe(
|
||||
'/api/annotations/dataset?status=PENDING',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.datasetItem(annotationId)', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.datasetItem('ann-7')).toBe(
|
||||
'/api/annotations/dataset/ann-7',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.datasetBulkStatus', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.datasetBulkStatus()).toBe(
|
||||
'/api/annotations/dataset/bulk-status',
|
||||
)
|
||||
})
|
||||
|
||||
it('annotations.datasetClassDistribution', () => {
|
||||
// Assert
|
||||
expect(endpoints.annotations.datasetClassDistribution()).toBe(
|
||||
'/api/annotations/dataset/class-distribution',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-1: flights', () => {
|
||||
it('flights.collection() without query', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.collection()).toBe('/api/flights')
|
||||
})
|
||||
|
||||
it('flights.collection(queryString) appends ?queryString', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.collection('pageSize=1000')).toBe(
|
||||
'/api/flights?pageSize=1000',
|
||||
)
|
||||
})
|
||||
|
||||
it('flights.aircrafts', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.aircrafts()).toBe('/api/flights/aircrafts')
|
||||
})
|
||||
|
||||
it('flights.aircraft(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.aircraft('ac-1')).toBe(
|
||||
'/api/flights/aircrafts/ac-1',
|
||||
)
|
||||
})
|
||||
|
||||
it('flights.flight(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.flight('f-1')).toBe('/api/flights/f-1')
|
||||
})
|
||||
|
||||
it('flights.flightWaypoints(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.flightWaypoints('f-1')).toBe(
|
||||
'/api/flights/f-1/waypoints',
|
||||
)
|
||||
})
|
||||
|
||||
it('flights.flightWaypoint(flightId, waypointId)', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.flightWaypoint('f-1', 'wp-2')).toBe(
|
||||
'/api/flights/f-1/waypoints/wp-2',
|
||||
)
|
||||
})
|
||||
|
||||
it('flights.flightLiveGps(id)', () => {
|
||||
// Assert
|
||||
expect(endpoints.flights.flightLiveGps('f-1')).toBe(
|
||||
'/api/flights/f-1/live-gps',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-1: detect', () => {
|
||||
it('detect.media(mediaId)', () => {
|
||||
// Assert
|
||||
expect(endpoints.detect.media('m-1')).toBe('/api/detect/m-1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-6: barrel re-export', () => {
|
||||
it('endpoints is the same object when imported from src/api (the F4 barrel)', () => {
|
||||
// Assert
|
||||
expect(endpointsViaBarrel).toBe(endpoints)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
// AZ-486 / F7 — Single source of truth for every `/api/<service>/<path>` URL
|
||||
// the UI talks to today. Closes architecture baseline finding F7.
|
||||
//
|
||||
// Every UI callsite of `api.*`, `createSSE`, and raw image/video `src` URLs
|
||||
// pointing at an API resource MUST go through one of these builders. The
|
||||
// STC-ARCH-02 static gate (scripts/check-arch-imports.mjs `--mode=api-literals`,
|
||||
// wired into scripts/run-tests.sh) enforces it.
|
||||
//
|
||||
// **Wire-contract invariant**: the strings produced here are character-identical
|
||||
// to the literals that lived in the source before this refactor and that MSW
|
||||
// handlers + e2e stubs intercept. Any change to a builder's output is a wire-
|
||||
// contract change and MUST be coordinated with the backend + the MSW handler
|
||||
// surface + e2e stubs in the same commit. The accompanying test file
|
||||
// (`endpoints.test.ts`) pins every URL string and is the contract documentation.
|
||||
//
|
||||
// **Why function form (not constants)**: per user direction at task-creation
|
||||
// time; allows parameter interpolation without callsite re-introducing template
|
||||
// literals and keeps tree-shaking per-builder under Vite's production rollup.
|
||||
|
||||
export const endpoints = {
|
||||
admin: {
|
||||
authRefresh: () => '/api/admin/auth/refresh',
|
||||
authLogin: () => '/api/admin/auth/login',
|
||||
authLogout: () => '/api/admin/auth/logout',
|
||||
users: () => '/api/admin/users',
|
||||
// AZ-510 — chained from POST authRefresh() during AuthProvider bootstrap
|
||||
// (the POST refresh response is `{ token }` only; the user shape comes
|
||||
// from this GET). Keeps `01_api-transport` as the single source of truth
|
||||
// for `/api/admin/...` literals (STC-ARCH-02).
|
||||
usersMe: () => '/api/admin/users/me',
|
||||
user: (id: string) => `/api/admin/users/${id}`,
|
||||
classes: () => '/api/admin/classes',
|
||||
// DetectionClass.id is `number` in the type system; widened to accept
|
||||
// string for forward-compat if the backend switches the column to UUID.
|
||||
class: (id: string | number) => `/api/admin/classes/${id}`,
|
||||
},
|
||||
annotations: {
|
||||
classes: () => '/api/annotations/classes',
|
||||
settingsUser: () => '/api/annotations/settings/user',
|
||||
settingsSystem: () => '/api/annotations/settings/system',
|
||||
settingsDirectories: () => '/api/annotations/settings/directories',
|
||||
annotations: () => '/api/annotations/annotations',
|
||||
// page-size is currently always 1000 at every callsite; expose it as an
|
||||
// optional param so future tuning is a single-file change.
|
||||
annotationsByMedia: (mediaId: string, pageSize: number = 1000) =>
|
||||
`/api/annotations/annotations?mediaId=${mediaId}&pageSize=${pageSize}`,
|
||||
annotationImage: (annotationId: string) =>
|
||||
`/api/annotations/annotations/${annotationId}/image`,
|
||||
annotationThumbnail: (annotationId: string) =>
|
||||
`/api/annotations/annotations/${annotationId}/thumbnail`,
|
||||
annotationEvents: () => '/api/annotations/annotations/events',
|
||||
// Callers pre-build a URLSearchParams.toString() and pass it through; the
|
||||
// builder owns the path + `?` only so the wire-contract stays identical.
|
||||
media: (queryString: string) => `/api/annotations/media?${queryString}`,
|
||||
mediaFile: (mediaId: string) => `/api/annotations/media/${mediaId}/file`,
|
||||
mediaItem: (mediaId: string) => `/api/annotations/media/${mediaId}`,
|
||||
mediaBatch: () => '/api/annotations/media/batch',
|
||||
dataset: (queryString: string) => `/api/annotations/dataset?${queryString}`,
|
||||
datasetItem: (annotationId: string) =>
|
||||
`/api/annotations/dataset/${annotationId}`,
|
||||
datasetBulkStatus: () => '/api/annotations/dataset/bulk-status',
|
||||
datasetClassDistribution: () =>
|
||||
'/api/annotations/dataset/class-distribution',
|
||||
},
|
||||
flights: {
|
||||
// GET (with `?pageSize=...`) lists flights; POST (no query) creates one.
|
||||
// The query string is owned by the caller (URLSearchParams.toString()) so
|
||||
// the wire-contract stays identical to today.
|
||||
collection: (queryString?: string) =>
|
||||
queryString ? `/api/flights?${queryString}` : '/api/flights',
|
||||
aircrafts: () => '/api/flights/aircrafts',
|
||||
aircraft: (id: string) => `/api/flights/aircrafts/${id}`,
|
||||
flight: (id: string) => `/api/flights/${id}`,
|
||||
flightWaypoints: (id: string) => `/api/flights/${id}/waypoints`,
|
||||
flightWaypoint: (flightId: string, waypointId: string) =>
|
||||
`/api/flights/${flightId}/waypoints/${waypointId}`,
|
||||
flightLiveGps: (id: string) => `/api/flights/${id}/live-gps`,
|
||||
},
|
||||
detect: {
|
||||
// Trigger detection for a media item. Single-segment service path.
|
||||
media: (mediaId: string) => `/api/detect/${mediaId}`,
|
||||
},
|
||||
} as const
|
||||
@@ -0,0 +1,3 @@
|
||||
export { api, setToken, getToken, getApiBase, setNavigateToLogin } from './client'
|
||||
export { createSSE } from './sse'
|
||||
export { endpoints } from './endpoints'
|
||||
+5
-2
@@ -1,4 +1,4 @@
|
||||
import { getToken } from './client'
|
||||
import { getApiBase, getToken } from './client'
|
||||
|
||||
export function createSSE<T>(
|
||||
url: string,
|
||||
@@ -6,7 +6,10 @@ export function createSSE<T>(
|
||||
onError?: (err: Event) => void,
|
||||
): () => void {
|
||||
const token = getToken()
|
||||
const fullUrl = token ? `${url}${url.includes('?') ? '&' : '?'}access_token=${token}` : url
|
||||
const prefixedUrl = getApiBase() + url
|
||||
const fullUrl = token
|
||||
? `${prefixedUrl}${prefixedUrl.includes('?') ? '&' : '?'}access_token=${token}`
|
||||
: prefixedUrl
|
||||
|
||||
const source = new EventSource(fullUrl)
|
||||
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { act, useRef } from 'react'
|
||||
import { server } from '../../tests/msw/server'
|
||||
import { renderWithProviders, screen, waitFor } from '../../tests/helpers/render'
|
||||
import { api, getToken, setToken } from '../api'
|
||||
import { seedBearer, clearBearer } from '../../tests/helpers/auth'
|
||||
|
||||
// AZ-457 — Auth & token-handling at the React composition root.
|
||||
// FT-P-01 / row 02 — bootstrap refresh sends credentials:'include'
|
||||
// (un-quarantined by AZ-510; bootstrap is now POST
|
||||
// with credentials per the consolidation, so the
|
||||
// `it.fails` wrapper is removed and the assertion
|
||||
// runs as a regression guard)
|
||||
// FT-P-03 / row 11 — refresh transparency — children don't unmount;
|
||||
// re-render delta ≤ 1
|
||||
// NFT-SEC-01 / row 04 — bearer never written to localStorage/sessionStorage
|
||||
// (over the entire test lifetime, not just at one
|
||||
// snapshot — AC-3)
|
||||
// NFT-SEC-02 / rows 05+ — refresh token not exposed via document.cookie at
|
||||
// runtime (the static counterpart lives in
|
||||
// scripts/run-tests.sh)
|
||||
//
|
||||
// Black-box discipline: the test imports the public AuthProvider component,
|
||||
// the public setToken / getToken accessors, and React itself. It never
|
||||
// reaches into AuthContext's internals (state setters, context value, etc.).
|
||||
// All assertions are observable at the DOM, network, or storage surface.
|
||||
|
||||
interface StorageProbe {
|
||||
writes: Array<{ store: 'local' | 'session'; key: string; value: string }>
|
||||
values: Array<{ store: 'local' | 'session'; key: string; value: string }>
|
||||
cookieReads: number
|
||||
cookieWrites: string[]
|
||||
restore: () => void
|
||||
}
|
||||
|
||||
function instrumentStorage(): StorageProbe {
|
||||
const probe: StorageProbe = {
|
||||
writes: [],
|
||||
values: [],
|
||||
cookieReads: 0,
|
||||
cookieWrites: [],
|
||||
restore: () => {
|
||||
/* installed below */
|
||||
},
|
||||
}
|
||||
const originalLocalSet = Storage.prototype.setItem
|
||||
const wrappedSet = function (this: Storage, key: string, value: string) {
|
||||
const store: 'local' | 'session' = this === window.localStorage ? 'local' : 'session'
|
||||
probe.writes.push({ store, key, value })
|
||||
return originalLocalSet.call(this, key, value)
|
||||
}
|
||||
Storage.prototype.setItem = wrappedSet
|
||||
|
||||
// Patch document.cookie getter/setter on the document instance so reads /
|
||||
// writes surface in the probe without escaping jsdom's defaults.
|
||||
const cookieDescriptor = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(document), 'cookie')
|
||||
if (cookieDescriptor) {
|
||||
Object.defineProperty(document, 'cookie', {
|
||||
configurable: true,
|
||||
get() {
|
||||
probe.cookieReads += 1
|
||||
return cookieDescriptor.get?.call(document) ?? ''
|
||||
},
|
||||
set(v: string) {
|
||||
probe.cookieWrites.push(v)
|
||||
cookieDescriptor.set?.call(document, v)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
probe.restore = () => {
|
||||
Storage.prototype.setItem = originalLocalSet
|
||||
if (cookieDescriptor) {
|
||||
Object.defineProperty(document, 'cookie', cookieDescriptor)
|
||||
}
|
||||
}
|
||||
return probe
|
||||
}
|
||||
|
||||
function snapshotAllStorageValues(): Array<{ store: 'local' | 'session'; key: string; value: string }> {
|
||||
const out: Array<{ store: 'local' | 'session'; key: string; value: string }> = []
|
||||
for (let i = 0; i < window.localStorage.length; i += 1) {
|
||||
const k = window.localStorage.key(i)!
|
||||
out.push({ store: 'local', key: k, value: window.localStorage.getItem(k) ?? '' })
|
||||
}
|
||||
for (let i = 0; i < window.sessionStorage.length; i += 1) {
|
||||
const k = window.sessionStorage.key(i)!
|
||||
out.push({ store: 'session', key: k, value: window.sessionStorage.getItem(k) ?? '' })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
describe('AZ-457 / src/auth/AuthContext.tsx — bootstrap, refresh, storage discipline', () => {
|
||||
let probe: StorageProbe
|
||||
|
||||
beforeEach(() => {
|
||||
probe = instrumentStorage()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
probe.restore()
|
||||
window.localStorage.clear()
|
||||
window.sessionStorage.clear()
|
||||
clearBearer()
|
||||
})
|
||||
|
||||
describe('AC-4 (AZ-510) — /users/me failure after refresh success clears the bearer', () => {
|
||||
it('POST refresh 200 then GET /users/me 401 → setToken(null) + setUser(null) + loading false; console.error fires', async () => {
|
||||
// Arrange — refresh succeeds and seeds a bearer; chained /users/me
|
||||
// returns 401 (e.g. user record gone server-side after a stale cookie
|
||||
// hit). Constraint #4 says the bearer must be cleared so an in-flight
|
||||
// re-render does not see (user: null) alongside an active accessToken.
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => { /* swallow during assert */ })
|
||||
let usersMeHits = 0
|
||||
server.use(
|
||||
http.post('/api/admin/auth/refresh', () => HttpResponse.json({ token: 'mid-flight-bearer' })),
|
||||
http.get('/api/admin/users/me', () => {
|
||||
usersMeHits += 1
|
||||
return new HttpResponse(null, { status: 401 })
|
||||
}),
|
||||
)
|
||||
|
||||
// Act
|
||||
renderWithProviders(<div data-testid="app">app</div>)
|
||||
await waitFor(() => expect(usersMeHits).toBeGreaterThanOrEqual(1))
|
||||
await waitFor(() => expect(getToken()).toBeNull())
|
||||
|
||||
// Assert — bearer cleared, error logged with diagnostic shape.
|
||||
expect(getToken()).toBeNull()
|
||||
expect(errorSpy).toHaveBeenCalled()
|
||||
const loggedAtLeastOnceWithRefreshOkUserFailed = errorSpy.mock.calls.some(args =>
|
||||
typeof args[0] === 'string' && args[0].includes('/users/me failed'),
|
||||
)
|
||||
expect(loggedAtLeastOnceWithRefreshOkUserFailed).toBe(true)
|
||||
errorSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FT-P-01 (row 02) — bootstrap refresh', () => {
|
||||
it("AuthProvider mount sends POST /api/admin/auth/refresh with credentials:'include'", async () => {
|
||||
// Arrange — AZ-510 consolidated the bootstrap onto the same wire shape
|
||||
// as the 401-retry: POST refresh with credentials:'include', then a
|
||||
// chained GET /users/me for the user payload. This test is the
|
||||
// regression guard for the credentials:'include' contract and the
|
||||
// wire-method (POST vs the previously-broken GET).
|
||||
let bootstrapMethod: string | null = null
|
||||
let bootstrapCredentials: RequestCredentials | null = null
|
||||
let usersMeHits = 0
|
||||
server.use(
|
||||
http.post('/api/admin/auth/refresh', ({ request }) => {
|
||||
bootstrapMethod = request.method
|
||||
bootstrapCredentials = request.credentials
|
||||
return HttpResponse.json({ token: 'bootstrap-bearer' })
|
||||
}),
|
||||
http.get('/api/admin/users/me', () => {
|
||||
usersMeHits += 1
|
||||
return HttpResponse.json({
|
||||
id: 'user-alice', email: 'op_alice@test.local', name: 'Alice', role: 'op', permissions: [],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
// Act
|
||||
renderWithProviders(<div data-testid="app-root">app</div>)
|
||||
await waitFor(() => expect(bootstrapCredentials).not.toBeNull())
|
||||
await waitFor(() => expect(usersMeHits).toBe(1))
|
||||
|
||||
// Assert — POST + credentials:'include' + chained /users/me.
|
||||
expect(bootstrapMethod).toBe('POST')
|
||||
expect(bootstrapCredentials).toBe('include')
|
||||
expect(usersMeHits).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('FT-P-03 (row 11) — refresh transparency: children stay mounted, re-render delta ≤ 1', () => {
|
||||
it('mid-session refresh does not unmount the protected child; re-render delta ≤ 1', async () => {
|
||||
// Arrange — a stable child component records its render counter.
|
||||
const renderTimes: number[] = []
|
||||
function StableChild() {
|
||||
const ref = useRef(0)
|
||||
ref.current += 1
|
||||
renderTimes.push(ref.current)
|
||||
return <div data-testid="stable-child">child #{ref.current}</div>
|
||||
}
|
||||
// Bootstrap (AZ-510 wire shape): POST refresh -> { token }, chained GET
|
||||
// /users/me -> user. Await bootstrap settlement BEFORE re-overriding
|
||||
// /users/me below — otherwise the 401-retry handler would intercept
|
||||
// bootstrap's chained call and the test would fight itself.
|
||||
server.use(
|
||||
http.post('/api/admin/auth/refresh', () =>
|
||||
HttpResponse.json({ token: 'bootstrap-bearer' }),
|
||||
),
|
||||
http.get('/api/admin/users/me', () =>
|
||||
HttpResponse.json({ id: 'user-alice', email: 'op_alice@test.local', name: 'Alice', role: 'op', permissions: [] }),
|
||||
),
|
||||
)
|
||||
|
||||
renderWithProviders(<StableChild />)
|
||||
await screen.findByTestId('stable-child')
|
||||
await waitFor(() => expect(getToken()).toBe('bootstrap-bearer'))
|
||||
const renderCountAfterBootstrap = renderTimes.length
|
||||
|
||||
// Force a 401-retry cycle on a downstream authed call. New /users/me
|
||||
// handler returns 401 once, then 200 — exercises api/client.ts:73.
|
||||
let firstHit = true
|
||||
let refreshHits = 0
|
||||
server.use(
|
||||
http.get('/api/admin/users/me', () => {
|
||||
if (firstHit) {
|
||||
firstHit = false
|
||||
return new HttpResponse(null, { status: 401 })
|
||||
}
|
||||
return HttpResponse.json({ id: 'user-alice', email: 'op_alice@test.local' })
|
||||
}),
|
||||
http.post('/api/admin/auth/refresh', () => {
|
||||
refreshHits += 1
|
||||
return HttpResponse.json({ token: 'rotated-bearer' })
|
||||
}),
|
||||
)
|
||||
|
||||
// Act
|
||||
await act(async () => {
|
||||
await api.get('/api/admin/users/me')
|
||||
})
|
||||
|
||||
// Assert — child stayed mounted (no unmount/remount); render delta ≤ 1.
|
||||
expect(screen.getByTestId('stable-child')).toBeInTheDocument()
|
||||
expect(refreshHits).toBe(1)
|
||||
const reRenderDelta = renderTimes.length - renderCountAfterBootstrap
|
||||
expect(reRenderDelta).toBeLessThanOrEqual(1) // row 11 — exact bound
|
||||
})
|
||||
})
|
||||
|
||||
describe('NFT-SEC-01 (row 04) — bearer never in localStorage / sessionStorage', () => {
|
||||
it('over the entire test lifetime: no setItem call, no key/value contains the bearer', async () => {
|
||||
// Arrange — full bootstrap + refresh + downstream-authed call lifecycle.
|
||||
// AZ-510 wire shape: bootstrap = POST refresh -> { token } + chained GET
|
||||
// /users/me. The /users/me handler returns 200 the first time (bootstrap
|
||||
// chain), 401 the second time (forces 401-retry), then 200 again (post-
|
||||
// retry replay).
|
||||
const BEARER = 'leak-trap-bearer-' + Date.now()
|
||||
let refreshCallCount = 0
|
||||
let usersMeCallCount = 0
|
||||
server.use(
|
||||
http.post('/api/admin/auth/refresh', () => {
|
||||
refreshCallCount += 1
|
||||
// Call 1 = bootstrap; subsequent calls = 401-retry rotation. Both
|
||||
// are credential-only (no Authorization header), so order is the
|
||||
// only discriminator.
|
||||
return HttpResponse.json({ token: refreshCallCount === 1 ? BEARER : BEARER + '-rotated' })
|
||||
}),
|
||||
http.get('/api/admin/users/me', () => {
|
||||
usersMeCallCount += 1
|
||||
// Bootstrap chain (call 1) -> success; downstream test call (call 2)
|
||||
// -> 401 forces a refresh; post-refresh replay (call 3) -> success.
|
||||
if (usersMeCallCount === 2) {
|
||||
return new HttpResponse(null, { status: 401 })
|
||||
}
|
||||
return HttpResponse.json({ id: 'user-alice', email: 'op_alice@test.local', name: 'Alice', role: 'op', permissions: [] })
|
||||
}),
|
||||
)
|
||||
|
||||
// Act — boot, then drive a refresh+retry cycle, then settle.
|
||||
renderWithProviders(<div data-testid="app">app</div>)
|
||||
await waitFor(() => expect(getToken()).toBe(BEARER))
|
||||
await act(async () => {
|
||||
await api.get('/api/admin/users/me')
|
||||
})
|
||||
await waitFor(() => expect(getToken()).toBe(BEARER + '-rotated'))
|
||||
|
||||
// Assert — across the FULL test lifetime, no Storage.setItem call ever
|
||||
// referenced the bearer; no key in either store contains it (AC-3 says
|
||||
// "for the duration of the test", not just one snapshot).
|
||||
const writesContainingBearer = probe.writes.filter(
|
||||
(w) => w.value.includes(BEARER) || w.key.toLowerCase().includes('token') || w.key.toLowerCase().includes('bearer'),
|
||||
)
|
||||
expect(writesContainingBearer).toEqual([])
|
||||
|
||||
const finalSnapshot = snapshotAllStorageValues()
|
||||
const leaked = finalSnapshot.filter((e) => e.value.includes(BEARER))
|
||||
expect(leaked).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('NFT-SEC-02 (row 05) — refresh token not exposed via JS-readable document.cookie', () => {
|
||||
it('after bootstrap + refresh cycle, document.cookie carries no refresh token', async () => {
|
||||
// Arrange — drive a full auth lifecycle. If production code (or any
|
||||
// library it brings in) wrote a JS-readable cookie carrying token /
|
||||
// refresh material, it would surface in `document.cookie` here.
|
||||
// (HttpOnly cookies set by the real admin/ service are invisible to JS;
|
||||
// jsdom's MSW responses set no cookies at all unless the test does.)
|
||||
// AZ-510 wire shape: bootstrap = POST refresh + chained /users/me; the
|
||||
// explicit downstream /users/me call below succeeds without rotation
|
||||
// (the rotated-bearer assertion below is a defence-in-depth check —
|
||||
// the value never appears anywhere because no rotation is triggered).
|
||||
server.use(
|
||||
http.post('/api/admin/auth/refresh', () => HttpResponse.json({ token: 'bootstrap-bearer-XYZ' })),
|
||||
http.get('/api/admin/users/me', () =>
|
||||
HttpResponse.json({ id: 'user-alice', email: 'op_alice@test.local', name: 'Alice', role: 'op', permissions: [] }),
|
||||
),
|
||||
)
|
||||
|
||||
// Act — bootstrap + an authed call.
|
||||
renderWithProviders(<div data-testid="app">app</div>)
|
||||
await waitFor(() => expect(getToken()).toBe('bootstrap-bearer-XYZ'))
|
||||
await act(async () => {
|
||||
await api.get('/api/admin/users/me')
|
||||
})
|
||||
|
||||
// Assert — JS-visible cookie jar carries neither bearer value nor any
|
||||
// refresh-prefixed cookie name (case-insensitive).
|
||||
const visibleCookies = document.cookie
|
||||
expect(visibleCookies, 'bearer must not appear in JS-readable cookies').not.toContain('bootstrap-bearer-XYZ')
|
||||
expect(visibleCookies, 'rotated bearer must not appear in JS-readable cookies').not.toContain('rotated-bearer-ABC')
|
||||
expect(visibleCookies, 'no refresh-named cookie should be JS-visible').not.toMatch(/refresh/i)
|
||||
|
||||
// Defence-in-depth: production code did not write to document.cookie
|
||||
// during the cycle (any setter call would have surfaced here).
|
||||
expect(probe.cookieWrites).toEqual([])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Type-only import guard — colocated test file does not import private
|
||||
// AuthContext internals (only the public AuthProvider mount path, surfaced
|
||||
// indirectly through renderWithProviders).
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
type _PublicSurface = typeof setToken extends (t: string | null) => void ? true : never
|
||||
+77
-10
@@ -1,5 +1,5 @@
|
||||
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'
|
||||
import { api, setToken } from '../api/client'
|
||||
import { api, endpoints, getApiBase, setToken } from '../api'
|
||||
import type { AuthUser } from '../types'
|
||||
|
||||
interface AuthState {
|
||||
@@ -16,34 +16,101 @@ export function useAuth() {
|
||||
return useContext(AuthContext)
|
||||
}
|
||||
|
||||
// React 18+ StrictMode double-invokes effects in dev (mount → cleanup → mount),
|
||||
// and the backend rotates the refresh cookie on every successful POST. Two
|
||||
// concurrent bootstraps would race the rotation and leave the second one with
|
||||
// a stale cookie. The module-scoped in-flight promise lets the second mount
|
||||
// await the first's network round-trip instead of duplicating it. Risk 4 in
|
||||
// AZ-510 spec.
|
||||
let bootstrapInflight: Promise<AuthUser | null> | null = null
|
||||
|
||||
/**
|
||||
* Test-only hook to clear the module-scoped in-flight bootstrap promise
|
||||
* between Vitest tests. Production never imports this — it exists because
|
||||
* Vitest does not reset module state between tests, so a test that mocks the
|
||||
* bootstrap to never-resolve would otherwise leak a permanently-pending
|
||||
* promise that subsequent tests would await forever. Wired into
|
||||
* `tests/setup.ts` afterEach. Safe-no-op when nothing is in flight.
|
||||
*/
|
||||
export function __resetBootstrapInflightForTests(): void {
|
||||
bootstrapInflight = null
|
||||
}
|
||||
|
||||
async function runBootstrap(): Promise<AuthUser | null> {
|
||||
// POST refresh with credentials — the whole point of the consolidation. Goes
|
||||
// through fetch() directly (not api.post) because api.post does not thread
|
||||
// credentials:'include'; widening api.post would change CORS posture for
|
||||
// every authenticated callsite. Same pattern lives in api/client.ts:88 for
|
||||
// the 401-retry refresh path.
|
||||
const refreshRes = await fetch(getApiBase() + endpoints.admin.authRefresh(), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!refreshRes.ok) return null
|
||||
const refreshData = (await refreshRes.json()) as { token: string }
|
||||
setToken(refreshData.token)
|
||||
try {
|
||||
return await api.get<AuthUser>(endpoints.admin.usersMe())
|
||||
} catch (err) {
|
||||
// Refresh succeeded but /users/me failed — clear the bearer so an in-flight
|
||||
// re-render does not see (user: null) alongside an active accessToken
|
||||
// (Constraint #4 in spec).
|
||||
console.error('[AuthContext] Refresh succeeded but /users/me failed:', err)
|
||||
setToken(null)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [user, setUser] = useState<AuthUser | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<{ user: AuthUser; token: string }>('/api/admin/auth/refresh')
|
||||
.then(data => {
|
||||
setToken(data.token)
|
||||
setUser(data.user)
|
||||
let cancelled = false
|
||||
const inflight =
|
||||
bootstrapInflight ??
|
||||
(bootstrapInflight = runBootstrap().finally(() => {
|
||||
bootstrapInflight = null
|
||||
}))
|
||||
inflight
|
||||
.then(result => {
|
||||
if (cancelled) return
|
||||
setUser(result)
|
||||
setLoading(false)
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false))
|
||||
.catch(err => {
|
||||
// Network error on the POST refresh itself (the /users/me failure path
|
||||
// is handled inside runBootstrap and resolves to null). Reliability NFR
|
||||
// requires loading to flip to false on every failure path.
|
||||
console.error('[AuthContext] Bootstrap failed:', err)
|
||||
if (cancelled) return
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
const login = useCallback(async (email: string, password: string) => {
|
||||
const data = await api.post<{ token: string; user: AuthUser }>('/api/admin/auth/login', { email, password })
|
||||
const data = await api.post<{ token: string; user: AuthUser }>(endpoints.admin.authLogin(), { email, password })
|
||||
setToken(data.token)
|
||||
setUser(data.user)
|
||||
}, [])
|
||||
|
||||
const logout = useCallback(async () => {
|
||||
try { await api.post('/api/admin/auth/logout') } catch {}
|
||||
try { await api.post(endpoints.admin.authLogout()) } catch {}
|
||||
setToken(null)
|
||||
setUser(null)
|
||||
}, [])
|
||||
|
||||
const hasPermission = useCallback((perm: string) => {
|
||||
return user?.permissions.includes(perm) ?? false
|
||||
// `permissions` is required by the AuthUser type but the runtime payload
|
||||
// from `/users/me` may omit it (older backend builds, or test fixtures
|
||||
// returning the bare User shape). Treat missing as "no permissions" rather
|
||||
// than crashing the React tree.
|
||||
return user?.permissions?.includes(perm) ?? false
|
||||
}, [user])
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,403 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http, HttpResponse } from 'msw'
|
||||
import { Routes, Route } from 'react-router-dom'
|
||||
import { server } from '../../tests/msw/server'
|
||||
import { jsonResponse } from '../../tests/msw/helpers'
|
||||
import { renderWithProviders, screen, waitFor } from '../../tests/helpers/render'
|
||||
import ProtectedRoute from './ProtectedRoute'
|
||||
import { clearBearer } from '../../tests/helpers/auth'
|
||||
import { opAlice, opBob, adminCarol, integratorDave, seedPermissions } from '../../tests/fixtures/seed_users'
|
||||
|
||||
// AZ-457 — <ProtectedRoute> behavior at the React boundary.
|
||||
// FT-N-04 / row 09 — unauthenticated /admin → redirect to /login
|
||||
// NFT-RES-08 — refresh cookie expired → redirect to /login
|
||||
// (apiClient half lives in src/api/client.test.ts; this
|
||||
// file asserts the React-router-level redirect path)
|
||||
//
|
||||
// AZ-467 — Spinner a11y, 10s timeout fallback, and RBAC route gating.
|
||||
// FT-P-32 / NFT-SEC-05 — spinner role=status + aria-live=polite + label
|
||||
// FT-P-33 / NFT-RES-04 — 10s timeout fallback (Vitest fake-timers)
|
||||
// FT-N-03 / NFT-SEC-05 — Operator → /admin redirects to /flights
|
||||
// FT-N-05 / NFT-SEC-06 — integrator-dave → /settings redirects (no SETTINGS perm)
|
||||
//
|
||||
// Production status (today): <ProtectedRoute> renders a plain spinner div
|
||||
// without any aria-* attributes, has no timeout fallback, and does NOT check
|
||||
// route-level permissions (it only gates on `user != null`). Those four ACs
|
||||
// therefore fail today; the spinner a11y test uses `it.fails()` to track the
|
||||
// drift, and the timeout / RBAC tests are `it.skip` (QUARANTINE) because the
|
||||
// behavior is entirely absent.
|
||||
//
|
||||
// Black-box discipline: we import only the public ProtectedRoute component
|
||||
// and react-router primitives; no internal state of <AuthContext> is read.
|
||||
// Assertions are observable on the rendered DOM — sentinel components let us
|
||||
// confirm which route the router settled on.
|
||||
|
||||
function LoginSentinel() {
|
||||
return <div data-testid="login-route">login-route</div>
|
||||
}
|
||||
|
||||
function AdminSentinel() {
|
||||
return <div data-testid="admin-route">admin-route</div>
|
||||
}
|
||||
|
||||
function FlightsSentinel() {
|
||||
return <div data-testid="flights-route">flights-route</div>
|
||||
}
|
||||
|
||||
function SettingsSentinel() {
|
||||
return <div data-testid="settings-route">settings-route</div>
|
||||
}
|
||||
|
||||
function withUser(user: typeof opAlice) {
|
||||
// AZ-510 wire shape: bootstrap = POST refresh -> { token } + chained GET
|
||||
// /users/me -> user. The previous shape (GET refresh returning { user, token })
|
||||
// was the broken bootstrap path the consolidation removed.
|
||||
server.use(
|
||||
http.post('/api/admin/auth/refresh', () => jsonResponse({ token: 'test-bearer-default' })),
|
||||
http.get('/api/admin/users/me', () =>
|
||||
jsonResponse({ ...user, permissions: seedPermissions[user.id] ?? [] }),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
describe('AZ-457 / src/auth/ProtectedRoute.tsx — redirect to /login', () => {
|
||||
afterEach(() => {
|
||||
clearBearer()
|
||||
})
|
||||
|
||||
describe('FT-N-04 (row 09) — unauthenticated user → /login', () => {
|
||||
it('navigating to /admin without a session redirects to /login', async () => {
|
||||
// Arrange — bootstrap refresh returns 401 (no session), AuthProvider's
|
||||
// catch arm leaves user=null and loading=false.
|
||||
server.use(
|
||||
http.post('/api/admin/auth/refresh', () => new HttpResponse(null, { status: 401 })),
|
||||
)
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AdminSentinel />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<LoginSentinel />} />
|
||||
</Routes>,
|
||||
{ initialEntries: ['/admin'] },
|
||||
)
|
||||
|
||||
// Assert — the /login sentinel renders, the /admin sentinel does not.
|
||||
await waitFor(() => expect(screen.getByTestId('login-route')).toBeInTheDocument())
|
||||
expect(screen.queryByTestId('admin-route')).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the loading spinner before bootstrap settles, then redirects', async () => {
|
||||
// Arrange — keep the bootstrap in flight long enough to capture the
|
||||
// spinner; resolve afterward to settle the redirect.
|
||||
let resolver!: () => void
|
||||
const gate = new Promise<void>((r) => {
|
||||
resolver = r
|
||||
})
|
||||
server.use(
|
||||
http.post('/api/admin/auth/refresh', async () => {
|
||||
await gate
|
||||
return new HttpResponse(null, { status: 401 })
|
||||
}),
|
||||
)
|
||||
|
||||
// Act
|
||||
const { container } = renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<AdminSentinel />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<LoginSentinel />} />
|
||||
</Routes>,
|
||||
{ initialEntries: ['/admin'] },
|
||||
)
|
||||
|
||||
// Assert spinner is visible while loading.
|
||||
const spinner = container.querySelector('.animate-spin')
|
||||
expect(spinner).not.toBeNull()
|
||||
expect(screen.queryByTestId('admin-route')).toBeNull()
|
||||
expect(screen.queryByTestId('login-route')).toBeNull()
|
||||
|
||||
// Resolve the gate; the route should settle on /login.
|
||||
resolver()
|
||||
await waitFor(() => expect(screen.getByTestId('login-route')).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
describe('NFT-RES-08 — refresh cookie expired → redirect (React-router half)', () => {
|
||||
it('failed bootstrap refresh routes the user to /login', async () => {
|
||||
// Arrange — expired-cookie 401 + no user in context.
|
||||
server.use(
|
||||
http.post('/api/admin/auth/refresh', () => new HttpResponse(null, { status: 401 })),
|
||||
)
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/flights"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<FlightsSentinel />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/login" element={<LoginSentinel />} />
|
||||
</Routes>,
|
||||
{ initialEntries: ['/flights'] },
|
||||
)
|
||||
|
||||
// Assert
|
||||
await waitFor(() => expect(screen.getByTestId('login-route')).toBeInTheDocument())
|
||||
expect(screen.queryByTestId('flights-route')).toBeNull()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('AZ-467 / src/auth/ProtectedRoute.tsx — spinner, timeout, RBAC', () => {
|
||||
beforeEach(() => {
|
||||
// Each test wires its own auth response; nothing global needed.
|
||||
})
|
||||
afterEach(() => {
|
||||
clearBearer()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('FT-P-32 / NFT-SEC-05 — spinner a11y while bootstrap is loading', () => {
|
||||
it.fails(
|
||||
'spinner element carries role="status" + aria-live="polite" + an accessible name (drift: aria attributes currently missing)',
|
||||
async () => {
|
||||
// Arrange — keep bootstrap pending forever so the spinner stays mounted.
|
||||
server.use(
|
||||
http.post('/api/admin/auth/refresh', async () => {
|
||||
await new Promise<void>(() => { /* never resolves */ })
|
||||
return new HttpResponse(null, { status: 200 })
|
||||
}),
|
||||
)
|
||||
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/flights"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<FlightsSentinel />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>,
|
||||
{ initialEntries: ['/flights'] },
|
||||
)
|
||||
|
||||
// Assert AC-1: the loading element advertises its status role and a
|
||||
// localized accessible name (i18n key TBD; for the drift assertion we
|
||||
// accept any non-empty accessible name).
|
||||
const status = await screen.findByRole('status')
|
||||
expect(status).toHaveAttribute('aria-live', 'polite')
|
||||
const name = status.getAttribute('aria-label') ?? status.textContent ?? ''
|
||||
expect(name.trim().length).toBeGreaterThan(0)
|
||||
},
|
||||
)
|
||||
|
||||
it('control — spinner renders today as a bare animate-spin div with no aria role (drift seen)', async () => {
|
||||
server.use(
|
||||
http.post('/api/admin/auth/refresh', async () => {
|
||||
await new Promise<void>(() => { /* never resolves */ })
|
||||
return new HttpResponse(null, { status: 200 })
|
||||
}),
|
||||
)
|
||||
const { container } = renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/flights"
|
||||
element={<ProtectedRoute><FlightsSentinel /></ProtectedRoute>}
|
||||
/>
|
||||
</Routes>,
|
||||
{ initialEntries: ['/flights'] },
|
||||
)
|
||||
|
||||
// Assert AC-1 evidence: the spinner exists, but is NOT a status role today.
|
||||
const spinner = container.querySelector('.animate-spin')
|
||||
expect(spinner).not.toBeNull()
|
||||
expect(screen.queryByRole('status')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FT-P-33 / NFT-RES-04 — 10s loading timeout fallback', () => {
|
||||
it.skip(
|
||||
'QUARANTINE (no production behavior): after 10s the spinner is replaced with a fallback that offers a retry affordance',
|
||||
async () => {
|
||||
// When ProtectedRoute gains a timeout (`useEffect` + setTimeout, or a
|
||||
// useTimeout hook) and a fallback render path, this test:
|
||||
// 1. Mocks bootstrap to never resolve.
|
||||
// 2. Renders the ProtectedRoute tree.
|
||||
// 3. Advances Vitest fake-timers by 10_000 ms.
|
||||
// 4. Asserts the fallback element is present with a retry affordance
|
||||
// (a button / link whose accessible name matches /retry|reload/i).
|
||||
// The test is skipped today because no timeout / fallback path exists
|
||||
// in src/auth/ProtectedRoute.tsx — asserting absent UI would produce
|
||||
// noise. Once the production path lands the assertion shape is below.
|
||||
vi.useFakeTimers()
|
||||
server.use(
|
||||
http.post('/api/admin/auth/refresh', async () => {
|
||||
await new Promise<void>(() => { /* never */ })
|
||||
return new HttpResponse(null, { status: 200 })
|
||||
}),
|
||||
)
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/flights"
|
||||
element={<ProtectedRoute><FlightsSentinel /></ProtectedRoute>}
|
||||
/>
|
||||
</Routes>,
|
||||
{ initialEntries: ['/flights'] },
|
||||
)
|
||||
vi.advanceTimersByTime(10_000)
|
||||
const retry = await screen.findByRole('button', { name: /retry|reload/i })
|
||||
expect(retry).toBeInTheDocument()
|
||||
},
|
||||
)
|
||||
|
||||
it('control — bootstrap stuck at >10s today shows ONLY the spinner; no fallback (drift seen)', async () => {
|
||||
vi.useFakeTimers()
|
||||
server.use(
|
||||
http.post('/api/admin/auth/refresh', async () => {
|
||||
await new Promise<void>(() => { /* never */ })
|
||||
return new HttpResponse(null, { status: 200 })
|
||||
}),
|
||||
)
|
||||
const { container } = renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/flights"
|
||||
element={<ProtectedRoute><FlightsSentinel /></ProtectedRoute>}
|
||||
/>
|
||||
</Routes>,
|
||||
{ initialEntries: ['/flights'] },
|
||||
)
|
||||
vi.advanceTimersByTime(10_000)
|
||||
|
||||
// QUARANTINE evidence: still showing the spinner; no retry surface.
|
||||
expect(container.querySelector('.animate-spin')).not.toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /retry|reload/i })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('FT-N-03 / NFT-SEC-05 — Operator → /admin redirects to /flights', () => {
|
||||
it.skip(
|
||||
'QUARANTINE (no production behavior): an authenticated Operator hitting /admin is redirected to /flights',
|
||||
async () => {
|
||||
// When ProtectedRoute gains a `requirePermission` prop (or wrapper) and
|
||||
// the /admin route opts in, this test:
|
||||
// 1. Boots auth as op_alice (Operator) with seedPermissions['user-alice']
|
||||
// (which intentionally lacks 'ADMIN_WRITE').
|
||||
// 2. Navigates to /admin.
|
||||
// 3. Asserts the router settled on /flights, not /admin or /login.
|
||||
withUser(opAlice)
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={<ProtectedRoute><AdminSentinel /></ProtectedRoute>}
|
||||
/>
|
||||
<Route path="/flights" element={<FlightsSentinel />} />
|
||||
<Route path="/login" element={<LoginSentinel />} />
|
||||
</Routes>,
|
||||
{ initialEntries: ['/admin'] },
|
||||
)
|
||||
await waitFor(() => expect(screen.getByTestId('flights-route')).toBeInTheDocument())
|
||||
expect(screen.queryByTestId('admin-route')).toBeNull()
|
||||
},
|
||||
)
|
||||
|
||||
it('control — an authenticated Operator reaches /admin today (no RBAC gate; drift seen)', async () => {
|
||||
withUser(opBob) // op_bob lacks 'ADMIN_WRITE' and 'SETTINGS'
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={<ProtectedRoute><AdminSentinel /></ProtectedRoute>}
|
||||
/>
|
||||
<Route path="/flights" element={<FlightsSentinel />} />
|
||||
<Route path="/login" element={<LoginSentinel />} />
|
||||
</Routes>,
|
||||
{ initialEntries: ['/admin'] },
|
||||
)
|
||||
|
||||
// Today the admin sentinel renders — ProtectedRoute does not check
|
||||
// permissions, only `user != null`.
|
||||
await waitFor(() => expect(screen.getByTestId('admin-route')).toBeInTheDocument())
|
||||
expect(screen.queryByTestId('flights-route')).toBeNull()
|
||||
})
|
||||
|
||||
it('Admin reaches /admin normally (positive control — same path, role permitted)', async () => {
|
||||
withUser(adminCarol)
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/admin"
|
||||
element={<ProtectedRoute><AdminSentinel /></ProtectedRoute>}
|
||||
/>
|
||||
<Route path="/flights" element={<FlightsSentinel />} />
|
||||
</Routes>,
|
||||
{ initialEntries: ['/admin'] },
|
||||
)
|
||||
await waitFor(() => expect(screen.getByTestId('admin-route')).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
|
||||
describe('FT-N-05 / NFT-SEC-06 — integrator-dave → /settings redirects', () => {
|
||||
it.skip(
|
||||
'QUARANTINE (no production behavior): an authenticated user without SETTINGS is redirected away from /settings',
|
||||
async () => {
|
||||
// When ProtectedRoute gains permission gating, this test:
|
||||
// 1. Boots auth as integrator_dave (whose seedPermissions lacks SETTINGS).
|
||||
// 2. Navigates to /settings.
|
||||
// 3. Asserts the router settled on /flights (or wherever policy says).
|
||||
withUser(integratorDave)
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={<ProtectedRoute><SettingsSentinel /></ProtectedRoute>}
|
||||
/>
|
||||
<Route path="/flights" element={<FlightsSentinel />} />
|
||||
<Route path="/login" element={<LoginSentinel />} />
|
||||
</Routes>,
|
||||
{ initialEntries: ['/settings'] },
|
||||
)
|
||||
await waitFor(() => expect(screen.getByTestId('flights-route')).toBeInTheDocument())
|
||||
expect(screen.queryByTestId('settings-route')).toBeNull()
|
||||
},
|
||||
)
|
||||
|
||||
it('control — integrator-dave reaches /settings today (no RBAC gate; drift seen)', async () => {
|
||||
withUser(integratorDave)
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/settings"
|
||||
element={<ProtectedRoute><SettingsSentinel /></ProtectedRoute>}
|
||||
/>
|
||||
<Route path="/flights" element={<FlightsSentinel />} />
|
||||
</Routes>,
|
||||
{ initialEntries: ['/settings'] },
|
||||
)
|
||||
await waitFor(() => expect(screen.getByTestId('settings-route')).toBeInTheDocument())
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,6 @@
|
||||
export { AuthProvider, useAuth } from './AuthContext'
|
||||
// Test-only helper — see AuthContext.tsx jsdoc. Production callers MUST NOT
|
||||
// import this (the underscore prefix flags the intent and ESLint
|
||||
// `no-restricted-syntax` could be added later if abuse appears).
|
||||
export { __resetBootstrapInflightForTests } from './AuthContext'
|
||||
export { default as ProtectedRoute } from './ProtectedRoute'
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
getClassColor,
|
||||
getPhotoModeSuffix,
|
||||
getClassNameFallback,
|
||||
FALLBACK_CLASS_NAMES,
|
||||
} from './classColors'
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { renderWithProviders, screen, fireEvent, userEvent } from '../../tests/helpers/render'
|
||||
import ConfirmDialog from './ConfirmDialog'
|
||||
|
||||
// AZ-466 — Destructive UX policy (ConfirmDialog half)
|
||||
//
|
||||
// Scope of this file (per AZ-466 ACs that target the dialog itself):
|
||||
// AC-3 (FT-P-28): `role="dialog"`, `aria-modal="true"`, `aria-labelledby`,
|
||||
// `aria-describedby` linkage.
|
||||
// AC-3 (FT-P-29): focus trap — Tab cycles inside the dialog.
|
||||
// AC-2 (FT-N-08): Escape on `<ConfirmDialog>` cancels — `onCancel` is invoked.
|
||||
//
|
||||
// Production drift (`src/components/ConfirmDialog.tsx`):
|
||||
// The dialog renders a plain `<div>` shell with NO `role="dialog"`,
|
||||
// `aria-modal`, `aria-labelledby`, or `aria-describedby` linkage. AC-3
|
||||
// attributes are recorded as `it.fails()`. Focus trap is absent — Tab
|
||||
// does not wrap inside the dialog. AC-3 focus trap is `it.skip` QUARANTINE
|
||||
// until production lands a focus trap. Escape close (FT-N-08) IS wired
|
||||
// (line 22-27 of ConfirmDialog.tsx) and PASSES today.
|
||||
|
||||
describe('AZ-466 — ConfirmDialog (component-level a11y / Escape)', () => {
|
||||
describe('AC-3 (FT-P-28) — modal a11y attributes', () => {
|
||||
it.fails('exposes role="dialog" + aria-modal="true" on the container', () => {
|
||||
// Arrange
|
||||
const noop = () => {}
|
||||
renderWithProviders(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Delete class?"
|
||||
message="This cannot be undone."
|
||||
onConfirm={noop}
|
||||
onCancel={noop}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Assert — the dialog's container element has the modal a11y attrs.
|
||||
// Drift: production renders a plain <div> with no role / aria attrs.
|
||||
const dialog = screen.getByRole('dialog')
|
||||
expect(dialog).toHaveAttribute('aria-modal', 'true')
|
||||
})
|
||||
|
||||
it.fails('links aria-labelledby and aria-describedby to title + message', () => {
|
||||
const noop = () => {}
|
||||
renderWithProviders(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Delete class?"
|
||||
message="This cannot be undone."
|
||||
onConfirm={noop}
|
||||
onCancel={noop}
|
||||
/>,
|
||||
)
|
||||
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const labelId = dialog.getAttribute('aria-labelledby')
|
||||
const describeId = dialog.getAttribute('aria-describedby')
|
||||
expect(labelId).toBeTruthy()
|
||||
expect(describeId).toBeTruthy()
|
||||
// The referenced ids must point to the title and message nodes.
|
||||
const titleEl = document.getElementById(labelId!)
|
||||
const messageEl = document.getElementById(describeId!)
|
||||
expect(titleEl).toHaveTextContent('Delete class?')
|
||||
expect(messageEl).toHaveTextContent('This cannot be undone.')
|
||||
})
|
||||
|
||||
it('control: the dialog DOM is currently a non-semantic <div> shell', () => {
|
||||
// Pin the current (drift) shape so a regression that, e.g., flips the
|
||||
// outer node to a <span> is caught even before AC-3 is fixed.
|
||||
const noop = () => {}
|
||||
const { container } = renderWithProviders(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Delete class?"
|
||||
onConfirm={noop}
|
||||
onCancel={noop}
|
||||
/>,
|
||||
)
|
||||
const outerDiv = container.querySelector('div.fixed.inset-0')
|
||||
expect(outerDiv).not.toBeNull()
|
||||
expect(outerDiv?.getAttribute('role')).toBeNull()
|
||||
expect(outerDiv?.getAttribute('aria-modal')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-3 (FT-P-29) — focus trap', () => {
|
||||
it.skip(
|
||||
'QUARANTINE — Tab from the last button cycles back to the first focusable element inside the dialog',
|
||||
async () => {
|
||||
// Production has no focus trap. The cancel button auto-focuses on
|
||||
// open (`useEffect` on line 16-18 of ConfirmDialog.tsx) but Tab can
|
||||
// escape the dialog. When a focus trap is added (typically via
|
||||
// `react-focus-lock` or a manual keydown handler), this test should
|
||||
// assert that Tab on the last focusable element returns focus to
|
||||
// the first, and Shift+Tab on the first returns focus to the last.
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('AC-2 (FT-N-08) — Escape cancel', () => {
|
||||
it('invokes onCancel when Escape is pressed while the dialog is open', () => {
|
||||
// Arrange
|
||||
let cancelCalls = 0
|
||||
let confirmCalls = 0
|
||||
renderWithProviders(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Delete?"
|
||||
onConfirm={() => { confirmCalls += 1 }}
|
||||
onCancel={() => { cancelCalls += 1 }}
|
||||
/>,
|
||||
)
|
||||
|
||||
// Act — fire Escape on window (production attaches a window-level keydown listener).
|
||||
fireEvent.keyDown(window, { key: 'Escape', code: 'Escape' })
|
||||
|
||||
// Assert
|
||||
expect(cancelCalls).toBe(1)
|
||||
expect(confirmCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('does NOT call onCancel when Escape is pressed while the dialog is closed', () => {
|
||||
let cancelCalls = 0
|
||||
renderWithProviders(
|
||||
<ConfirmDialog
|
||||
open={false}
|
||||
title="Closed"
|
||||
onConfirm={() => {}}
|
||||
onCancel={() => { cancelCalls += 1 }}
|
||||
/>,
|
||||
)
|
||||
|
||||
fireEvent.keyDown(window, { key: 'Escape', code: 'Escape' })
|
||||
|
||||
expect(cancelCalls).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AC-1 / AC-2 — happy + cancel paths invoked via the dialog buttons', () => {
|
||||
it('clicking Confirm invokes onConfirm exactly once and not onCancel', async () => {
|
||||
let confirmCalls = 0
|
||||
let cancelCalls = 0
|
||||
renderWithProviders(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Delete?"
|
||||
onConfirm={() => { confirmCalls += 1 }}
|
||||
onCancel={() => { cancelCalls += 1 }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const confirm = screen.getAllByRole('button').find(b => /confirm/i.test(b.textContent ?? ''))
|
||||
expect(confirm).toBeDefined()
|
||||
await userEvent.click(confirm!)
|
||||
|
||||
expect(confirmCalls).toBe(1)
|
||||
expect(cancelCalls).toBe(0)
|
||||
})
|
||||
|
||||
it('clicking Cancel invokes onCancel exactly once and not onConfirm', async () => {
|
||||
let confirmCalls = 0
|
||||
let cancelCalls = 0
|
||||
renderWithProviders(
|
||||
<ConfirmDialog
|
||||
open
|
||||
title="Delete?"
|
||||
onConfirm={() => { confirmCalls += 1 }}
|
||||
onCancel={() => { cancelCalls += 1 }}
|
||||
/>,
|
||||
)
|
||||
|
||||
const cancel = screen.getAllByRole('button').find(b => /cancel/i.test(b.textContent ?? ''))
|
||||
expect(cancel).toBeDefined()
|
||||
await userEvent.click(cancel!)
|
||||
|
||||
expect(cancelCalls).toBe(1)
|
||||
expect(confirmCalls).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -2,8 +2,12 @@ import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { MdOutlineWbSunny, MdOutlineNightlightRound } from 'react-icons/md'
|
||||
import { FaRegSnowflake } from 'react-icons/fa'
|
||||
import { api } from '../api/client'
|
||||
import { getClassColor, FALLBACK_CLASS_NAMES } from '../features/annotations/classColors'
|
||||
import { api, endpoints } from '../api'
|
||||
// classColors lives under 06_annotations until F3 moves it to its own home.
|
||||
// Importing through the 06_annotations barrel would create a cycle
|
||||
// (DetectionClasses -> 06_annotations barrel -> AnnotationsPage -> DetectionClasses).
|
||||
// STC-ARCH-01 exempts this single path as an F3-pending edge.
|
||||
import { getClassColor, FALLBACK_CLASS_NAMES } from '../class-colors'
|
||||
import type { DetectionClass } from '../types'
|
||||
|
||||
interface Props {
|
||||
@@ -29,7 +33,7 @@ export default function DetectionClasses({ selectedClassNum, onSelect, photoMode
|
||||
const [classes, setClasses] = useState<DetectionClass[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
api.get<DetectionClass[]>('/api/annotations/classes')
|
||||
api.get<DetectionClass[]>(endpoints.annotations.classes())
|
||||
.then(list => setClasses(list?.length ? list : FALLBACK_CLASSES))
|
||||
.catch(() => setClasses(FALLBACK_CLASSES))
|
||||
}, [])
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react'
|
||||
import { api } from '../api/client'
|
||||
import { api, endpoints } from '../api'
|
||||
import type { Flight, UserSettings } from '../types'
|
||||
|
||||
interface FlightState {
|
||||
@@ -21,17 +21,17 @@ export function FlightProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const refreshFlights = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<{ items: Flight[] }>('/api/flights?pageSize=1000')
|
||||
const data = await api.get<{ items: Flight[] }>(endpoints.flights.collection('pageSize=1000'))
|
||||
setFlights(data.items ?? [])
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
refreshFlights()
|
||||
api.get<UserSettings>('/api/annotations/settings/user')
|
||||
api.get<UserSettings>(endpoints.annotations.settingsUser())
|
||||
.then(settings => {
|
||||
if (settings?.selectedFlightId) {
|
||||
api.get<Flight>(`/api/flights/${settings.selectedFlightId}`)
|
||||
api.get<Flight>(endpoints.flights.flight(settings.selectedFlightId))
|
||||
.then(f => setSelectedFlight(f))
|
||||
.catch(() => {})
|
||||
}
|
||||
@@ -41,7 +41,7 @@ export function FlightProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const selectFlight = useCallback((f: Flight | null) => {
|
||||
setSelectedFlight(f)
|
||||
api.put('/api/annotations/settings/user', { selectedFlightId: f?.id ?? null }).catch(() => {})
|
||||
api.put(endpoints.annotations.settingsUser(), { selectedFlightId: f?.id ?? null }).catch(() => {})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { http } from 'msw'
|
||||
import { Route, Routes } from 'react-router-dom'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import type { ReactNode } from 'react'
|
||||
import { server } from '../../tests/msw/server'
|
||||
import { jsonResponse, paginate } from '../../tests/msw/helpers'
|
||||
import { renderWithProviders, screen, waitFor } from '../../tests/helpers/render'
|
||||
import { seedBearer, clearBearer } from '../../tests/helpers/auth'
|
||||
import { seedFlights } from '../../tests/fixtures/seed_flights'
|
||||
import { opAlice, seedPermissions } from '../../tests/fixtures/seed_users'
|
||||
import { FlightProvider } from './FlightContext'
|
||||
import Header from './Header'
|
||||
|
||||
// AZ-468 — Header flight-dropdown a11y + Escape handler.
|
||||
// FT-P-30 closed-state a11y — aria-expanded=false, accessible trigger name
|
||||
// FT-P-31 open-state a11y — aria-expanded=true, role=listbox/menu,
|
||||
// aria-activedescendant points to a real id
|
||||
// FT-N-09 Escape close + detach — Escape closes the dropdown and the
|
||||
// document-level Escape handler is removed
|
||||
// (no leakage into other components).
|
||||
//
|
||||
// Production status (today): src/components/Header.tsx renders a plain
|
||||
// <button> trigger and a <div> menu without any aria-* attributes, and the
|
||||
// dropdown has NO Escape key handler (only a `mousedown` listener for
|
||||
// click-outside). All three task ACs therefore fail today; FT-P-30/31 are
|
||||
// captured as documented DRIFT via `it.fails()` (flips green when production
|
||||
// gains the attributes); FT-N-09 is QUARANTINEd via `it.skip` because the
|
||||
// behavior is wholly absent — there is no addEventListener('keydown', ...) in
|
||||
// the dropdown to assert against.
|
||||
|
||||
function HeaderHarness({ children }: { children?: ReactNode }) {
|
||||
return <FlightProvider>{children}<Header /></FlightProvider>
|
||||
}
|
||||
|
||||
function mountHeader() {
|
||||
return renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/flights"
|
||||
element={<HeaderHarness><div data-testid="content" /></HeaderHarness>}
|
||||
/>
|
||||
<Route path="/login" element={<div data-testid="login-route" />} />
|
||||
</Routes>,
|
||||
{ initialEntries: ['/flights'] },
|
||||
)
|
||||
}
|
||||
|
||||
function wireAuthAndFlights() {
|
||||
server.use(
|
||||
// AZ-510 — bootstrap = POST refresh -> { token } + chained GET /users/me.
|
||||
http.post('/api/admin/auth/refresh', () => jsonResponse({ token: 'test-bearer-default' })),
|
||||
http.get('/api/admin/users/me', () =>
|
||||
jsonResponse({ ...opAlice, permissions: seedPermissions[opAlice.id] ?? [] }),
|
||||
),
|
||||
http.get('/api/flights', ({ request }) => {
|
||||
const url = new URL(request.url)
|
||||
const pageSize = Number(url.searchParams.get('pageSize') ?? '50')
|
||||
return jsonResponse(paginate(seedFlights, 1, pageSize))
|
||||
}),
|
||||
http.get('/api/annotations/settings/user', () =>
|
||||
jsonResponse({
|
||||
id: 'us-1', userId: opAlice.id, selectedFlightId: null,
|
||||
annotationsLeftPanelWidth: null, annotationsRightPanelWidth: null,
|
||||
datasetLeftPanelWidth: null, datasetRightPanelWidth: null,
|
||||
}),
|
||||
),
|
||||
http.put('/api/annotations/settings/user', async ({ request }) => {
|
||||
const body = await request.json()
|
||||
return jsonResponse(body)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
describe('AZ-468 / src/components/Header.tsx — flight dropdown', () => {
|
||||
beforeEach(() => {
|
||||
seedBearer()
|
||||
wireAuthAndFlights()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearBearer()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('FT-P-30 — closed-state a11y', () => {
|
||||
it.fails(
|
||||
'trigger advertises aria-expanded=false when the menu is closed (drift: attribute currently missing)',
|
||||
async () => {
|
||||
mountHeader()
|
||||
|
||||
// Wait for the flights list to have been fetched so the trigger is hydrated.
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /select flight/i })).toBeInTheDocument(),
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: /select flight/i })
|
||||
|
||||
// AC-1 contract: aria-expanded=false when closed; no aria-activedescendant.
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(trigger).not.toHaveAttribute('aria-activedescendant')
|
||||
},
|
||||
)
|
||||
|
||||
it('control — closed trigger today lacks aria-expanded entirely (drift seen)', async () => {
|
||||
mountHeader()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /select flight/i })).toBeInTheDocument(),
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: /select flight/i })
|
||||
expect(trigger).not.toHaveAttribute('aria-expanded')
|
||||
})
|
||||
})
|
||||
|
||||
describe('FT-P-31 — open-state a11y', () => {
|
||||
it.fails(
|
||||
'opened dropdown advertises aria-expanded=true and listbox/menu role with a real aria-activedescendant (drift: attributes missing today)',
|
||||
async () => {
|
||||
const user = userEvent.setup()
|
||||
mountHeader()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /select flight/i })).toBeInTheDocument(),
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: /select flight/i })
|
||||
|
||||
await user.click(trigger)
|
||||
|
||||
// AC-2 contract.
|
||||
expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
||||
const listbox = screen.getByRole('listbox')
|
||||
expect(listbox).toBeInTheDocument()
|
||||
const optionId = trigger.getAttribute('aria-activedescendant')
|
||||
expect(optionId).toBeTruthy()
|
||||
expect(document.getElementById(optionId as string)).not.toBeNull()
|
||||
},
|
||||
)
|
||||
|
||||
it('control — opened dropdown today exposes options but with no role and no aria wiring (drift seen)', async () => {
|
||||
const user = userEvent.setup()
|
||||
mountHeader()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /select flight/i })).toBeInTheDocument(),
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: /select flight/i })
|
||||
await user.click(trigger)
|
||||
|
||||
// The filter input renders on open, so the panel is visibly open.
|
||||
expect(screen.getByPlaceholderText(/filter/i)).toBeInTheDocument()
|
||||
// But none of the listbox roles or aria-activedescendant wiring exists yet.
|
||||
expect(screen.queryByRole('listbox')).toBeNull()
|
||||
expect(trigger).not.toHaveAttribute('aria-activedescendant')
|
||||
})
|
||||
})
|
||||
|
||||
describe('FT-N-09 — Escape close + document-level handler detached', () => {
|
||||
it.skip(
|
||||
'QUARANTINE (no production behavior): Escape closes the dropdown and the document keydown handler is removed',
|
||||
async () => {
|
||||
// When the production code lands a document-level keydown listener that
|
||||
// handles Escape, this test asserts:
|
||||
// 1. Pressing Escape closes the dropdown (filter input gone)
|
||||
// 2. The document.addEventListener('keydown', ...) call made when the
|
||||
// dropdown opened is paired with a removeEventListener('keydown', ...)
|
||||
// with the SAME handler reference when the dropdown closes (verified
|
||||
// via spies on document.addEventListener/removeEventListener).
|
||||
// The test below is a sketch of the assertion shape — left skipped because
|
||||
// Header has no keydown listener today and asserting against absent code
|
||||
// would produce noise, not signal.
|
||||
const addSpy = vi.spyOn(document, 'addEventListener')
|
||||
const removeSpy = vi.spyOn(document, 'removeEventListener')
|
||||
const user = userEvent.setup()
|
||||
mountHeader()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /select flight/i })).toBeInTheDocument(),
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: /select flight/i })
|
||||
|
||||
await user.click(trigger)
|
||||
const keydownAdds = addSpy.mock.calls.filter(([type]) => type === 'keydown')
|
||||
expect(keydownAdds.length).toBeGreaterThanOrEqual(1)
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
expect(screen.queryByPlaceholderText(/filter/i)).toBeNull()
|
||||
|
||||
const keydownRemoves = removeSpy.mock.calls.filter(([type, fn]) =>
|
||||
type === 'keydown' && fn === keydownAdds[0]?.[1],
|
||||
)
|
||||
expect(keydownRemoves.length).toBeGreaterThanOrEqual(1)
|
||||
},
|
||||
)
|
||||
|
||||
it('control — Escape today is a no-op; the dropdown stays open (drift seen)', async () => {
|
||||
const user = userEvent.setup()
|
||||
mountHeader()
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole('button', { name: /select flight/i })).toBeInTheDocument(),
|
||||
)
|
||||
const trigger = screen.getByRole('button', { name: /select flight/i })
|
||||
await user.click(trigger)
|
||||
expect(screen.getByPlaceholderText(/filter/i)).toBeInTheDocument()
|
||||
|
||||
await user.keyboard('{Escape}')
|
||||
|
||||
// QUARANTINE evidence: the filter input is still present — Escape did
|
||||
// nothing because the Header has no keydown handler today.
|
||||
expect(screen.getByPlaceholderText(/filter/i)).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NavLink, useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { useAuth } from '../auth'
|
||||
import { useFlight } from './FlightContext'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import HelpModal from './HelpModal'
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
export { default as Header } from './Header'
|
||||
export { default as HelpModal } from './HelpModal'
|
||||
export { default as ConfirmDialog } from './ConfirmDialog'
|
||||
export { default as DetectionClasses } from './DetectionClasses'
|
||||
export { FlightProvider, useFlight } from './FlightContext'
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useState, useEffect, type KeyboardEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api } from '../../api/client'
|
||||
import ConfirmDialog from '../../components/ConfirmDialog'
|
||||
import { api, endpoints } from '../../api'
|
||||
import { ConfirmDialog } from '../../components'
|
||||
import type { DetectionClass, Aircraft, User } from '../../types'
|
||||
|
||||
type EditForm = { name: string; shortName: string; color: string; maxSizeM: number }
|
||||
type EditErrorKind = 'nameRequired' | 'maxSizeMustBePositive' | 'updateFailed'
|
||||
|
||||
export default function AdminPage() {
|
||||
const { t } = useTranslation()
|
||||
const [classes, setClasses] = useState<DetectionClass[]>([])
|
||||
@@ -12,43 +15,87 @@ export default function AdminPage() {
|
||||
const [newClass, setNewClass] = useState({ name: '', shortName: '', color: '#FF0000', maxSizeM: 7 })
|
||||
const [newUser, setNewUser] = useState({ name: '', email: '', password: '', role: 'Annotator' })
|
||||
const [deactivateId, setDeactivateId] = useState<string | null>(null)
|
||||
// AZ-512 — inline edit state. Single `editingId` (not per-row) so opening
|
||||
// one row's editor implicitly closes any other (Risk 3 mitigation).
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [editForm, setEditForm] = useState<EditForm>({ name: '', shortName: '', color: '#FF0000', maxSizeM: 0 })
|
||||
const [editError, setEditError] = useState<EditErrorKind | null>(null)
|
||||
const [editSaving, setEditSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<DetectionClass[]>('/api/annotations/classes').then(setClasses).catch(() => {})
|
||||
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
|
||||
api.get<User[]>('/api/admin/users').then(setUsers).catch(() => {})
|
||||
api.get<DetectionClass[]>(endpoints.annotations.classes()).then(setClasses).catch(() => {})
|
||||
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
||||
api.get<User[]>(endpoints.admin.users()).then(setUsers).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const handleAddClass = async () => {
|
||||
if (!newClass.name) return
|
||||
await api.post('/api/admin/classes', newClass)
|
||||
const updated = await api.get<DetectionClass[]>('/api/annotations/classes')
|
||||
await api.post(endpoints.admin.classes(), newClass)
|
||||
const updated = await api.get<DetectionClass[]>(endpoints.annotations.classes())
|
||||
setClasses(updated)
|
||||
setNewClass({ name: '', shortName: '', color: '#FF0000', maxSizeM: 7 })
|
||||
}
|
||||
|
||||
const handleDeleteClass = async (id: number) => {
|
||||
await api.delete(`/api/admin/classes/${id}`)
|
||||
await api.delete(endpoints.admin.class(id))
|
||||
setClasses(prev => prev.filter(c => c.id !== id))
|
||||
}
|
||||
|
||||
const handleStartEdit = (c: DetectionClass) => {
|
||||
setEditingId(c.id)
|
||||
setEditForm({ name: c.name, shortName: c.shortName, color: c.color, maxSizeM: c.maxSizeM })
|
||||
setEditError(null)
|
||||
setEditSaving(false)
|
||||
}
|
||||
|
||||
const handleCancelEdit = () => {
|
||||
setEditingId(null)
|
||||
setEditError(null)
|
||||
setEditSaving(false)
|
||||
}
|
||||
|
||||
const handleUpdateClass = async () => {
|
||||
if (editingId === null || editSaving) return
|
||||
if (!editForm.name.trim()) { setEditError('nameRequired'); return }
|
||||
if (!(editForm.maxSizeM > 0)) { setEditError('maxSizeMustBePositive'); return }
|
||||
setEditError(null)
|
||||
setEditSaving(true)
|
||||
try {
|
||||
// Risk 2 mitigation — always send the complete form so backend PATCH
|
||||
// semantics (full-replace vs partial-merge) don't matter.
|
||||
await api.patch(endpoints.admin.class(editingId), editForm)
|
||||
const updated = await api.get<DetectionClass[]>(endpoints.annotations.classes())
|
||||
setClasses(updated)
|
||||
setEditingId(null)
|
||||
} catch {
|
||||
setEditError('updateFailed')
|
||||
} finally {
|
||||
setEditSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditKeyDown = (e: KeyboardEvent<HTMLElement>) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); void handleUpdateClass() }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); handleCancelEdit() }
|
||||
}
|
||||
|
||||
const handleAddUser = async () => {
|
||||
if (!newUser.email || !newUser.password) return
|
||||
await api.post('/api/admin/users', newUser)
|
||||
const updated = await api.get<User[]>('/api/admin/users')
|
||||
await api.post(endpoints.admin.users(), newUser)
|
||||
const updated = await api.get<User[]>(endpoints.admin.users())
|
||||
setUsers(updated)
|
||||
setNewUser({ name: '', email: '', password: '', role: 'Annotator' })
|
||||
}
|
||||
|
||||
const handleDeactivate = async () => {
|
||||
if (!deactivateId) return
|
||||
await api.patch(`/api/admin/users/${deactivateId}`, { isActive: false })
|
||||
await api.patch(endpoints.admin.user(deactivateId), { isActive: false })
|
||||
setUsers(prev => prev.map(u => u.id === deactivateId ? { ...u, isActive: false } : u))
|
||||
setDeactivateId(null)
|
||||
}
|
||||
|
||||
const handleToggleDefault = async (a: Aircraft) => {
|
||||
await api.patch(`/api/flights/aircrafts/${a.id}`, { isDefault: !a.isDefault })
|
||||
await api.patch(endpoints.flights.aircraft(a.id), { isDefault: !a.isDefault })
|
||||
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
|
||||
}
|
||||
|
||||
@@ -56,7 +103,7 @@ export default function AdminPage() {
|
||||
<div className="flex h-full overflow-y-auto p-4 gap-4">
|
||||
{/* Detection classes */}
|
||||
<div className="w-[340px] shrink-0">
|
||||
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.classes')}</h2>
|
||||
<h2 className="text-sm font-semibold text-white mb-2">{t('admin.classes.title')}</h2>
|
||||
<div className="bg-az-panel border border-az-border rounded overflow-hidden">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
@@ -68,12 +115,75 @@ export default function AdminPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{classes.map(c => (
|
||||
{classes.map(c => c.id === editingId ? (
|
||||
<tr key={c.id} className="border-b border-az-border text-az-text bg-az-bg/40" data-editing-row={c.id}>
|
||||
<td className="px-2 py-1 align-top">{c.id}</td>
|
||||
<td colSpan={3} className="px-2 py-1">
|
||||
<div className="flex flex-wrap gap-1 items-center" onKeyDown={handleEditKeyDown}>
|
||||
<input
|
||||
autoFocus
|
||||
data-field="name"
|
||||
value={editForm.name}
|
||||
onChange={e => setEditForm(p => ({ ...p, name: e.target.value }))}
|
||||
className="flex-1 min-w-[80px] bg-az-bg border border-az-border rounded px-1 py-0.5 text-az-text"
|
||||
/>
|
||||
<input
|
||||
data-field="shortName"
|
||||
value={editForm.shortName}
|
||||
onChange={e => setEditForm(p => ({ ...p, shortName: e.target.value }))}
|
||||
className="w-12 bg-az-bg border border-az-border rounded px-1 py-0.5 text-az-text"
|
||||
/>
|
||||
<input
|
||||
type="color"
|
||||
data-field="color"
|
||||
value={editForm.color}
|
||||
onChange={e => setEditForm(p => ({ ...p, color: e.target.value }))}
|
||||
className="w-7 h-6 border-0 bg-transparent cursor-pointer"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
data-field="maxSizeM"
|
||||
value={editForm.maxSizeM}
|
||||
onChange={e => setEditForm(p => ({ ...p, maxSizeM: Number(e.target.value) }))}
|
||||
className="w-14 bg-az-bg border border-az-border rounded px-1 py-0.5 text-az-text"
|
||||
/>
|
||||
<button
|
||||
onClick={() => void handleUpdateClass()}
|
||||
disabled={editSaving}
|
||||
className="bg-az-orange text-white px-2 py-0.5 rounded disabled:opacity-50"
|
||||
>
|
||||
{t('admin.classes.save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCancelEdit}
|
||||
disabled={editSaving}
|
||||
className="bg-az-bg border border-az-border text-az-text px-2 py-0.5 rounded disabled:opacity-50"
|
||||
>
|
||||
{t('admin.classes.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
{editError && (
|
||||
<div role="alert" className="mt-1 text-az-red">
|
||||
{t(`admin.classes.${editError}`)}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
<tr key={c.id} className="border-b border-az-border text-az-text">
|
||||
<td className="px-2 py-1">{c.id}</td>
|
||||
<td className="px-2 py-1">{c.name}</td>
|
||||
<td className="px-2 py-1 text-center"><span className="inline-block w-3 h-3 rounded-full" style={{ backgroundColor: c.color }} /></td>
|
||||
<td className="px-2 py-1"><button onClick={() => handleDeleteClass(c.id)} className="text-az-muted hover:text-az-red">×</button></td>
|
||||
<td className="px-2 py-1 text-right whitespace-nowrap">
|
||||
<button
|
||||
onClick={() => handleStartEdit(c)}
|
||||
aria-label={t('admin.classes.edit')}
|
||||
className="text-az-muted hover:text-az-orange mr-1"
|
||||
>
|
||||
{'\u270E'}
|
||||
</button>
|
||||
<button onClick={() => handleDeleteClass(c.id)} className="text-az-muted hover:text-az-red">×</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as AdminPage } from './AdminPage'
|
||||
@@ -1,15 +1,14 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react'
|
||||
import { useResizablePanel } from '../../hooks/useResizablePanel'
|
||||
import { api } from '../../api/client'
|
||||
import { useResizablePanel } from '../../hooks'
|
||||
import { api, endpoints } from '../../api'
|
||||
import MediaList from './MediaList'
|
||||
import VideoPlayer, { type VideoPlayerHandle } from './VideoPlayer'
|
||||
import CanvasEditor, { type CanvasEditorHandle } from './CanvasEditor'
|
||||
import AnnotationsSidebar from './AnnotationsSidebar'
|
||||
import DetectionClasses from '../../components/DetectionClasses'
|
||||
import { DetectionClasses, useFlight } from '../../components'
|
||||
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
|
||||
import { useFlight } from '../../components/FlightContext'
|
||||
import { AnnotationSource, AnnotationStatus, MediaType } from '../../types'
|
||||
import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from './classColors'
|
||||
import { getClassColor, getClassNameFallback, getPhotoModeSuffix } from '../../class-colors'
|
||||
import { captureThumbnails } from './thumbnail'
|
||||
import type { Media, AnnotationListItem, Detection } from '../../types'
|
||||
|
||||
@@ -65,9 +64,9 @@ export default function AnnotationsPage() {
|
||||
|
||||
if (!selectedMedia.path.startsWith('blob:')) {
|
||||
try {
|
||||
await api.post('/api/annotations/annotations', body)
|
||||
await api.post(endpoints.annotations.annotations(), body)
|
||||
const res = await api.get<{ items: AnnotationListItem[] }>(
|
||||
`/api/annotations/annotations?mediaId=${selectedMedia.id}&pageSize=1000`,
|
||||
endpoints.annotations.annotationsByMedia(selectedMedia.id),
|
||||
)
|
||||
setAnnotations(res.items)
|
||||
pushToStore(`saved-${crypto.randomUUID()}`)
|
||||
@@ -127,7 +126,7 @@ export default function AnnotationsPage() {
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.src = selectedMedia.path.startsWith('blob:')
|
||||
? selectedMedia.path
|
||||
: `/api/annotations/media/${selectedMedia.id}/file`
|
||||
: endpoints.annotations.mediaFile(selectedMedia.id)
|
||||
await new Promise(res => { img.onload = res; img.onerror = res })
|
||||
w = img.naturalWidth
|
||||
h = img.naturalHeight
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FaDownload } from 'react-icons/fa'
|
||||
import { api } from '../../api/client'
|
||||
import { createSSE } from '../../api/sse'
|
||||
import { getClassColor } from './classColors'
|
||||
import { api, createSSE, endpoints } from '../../api'
|
||||
import { getClassColor } from '../../class-colors'
|
||||
import type { Media, AnnotationListItem, PaginatedResponse } from '../../types'
|
||||
|
||||
interface Props {
|
||||
@@ -22,10 +21,10 @@ export default function AnnotationsSidebar({ media, annotations, selectedAnnotat
|
||||
|
||||
useEffect(() => {
|
||||
if (!media) return
|
||||
return createSSE<{ annotationId: string; mediaId: string; status: number }>('/api/annotations/annotations/events', (event) => {
|
||||
return createSSE<{ annotationId: string; mediaId: string; status: number }>(endpoints.annotations.annotationEvents(), (event) => {
|
||||
if (event.mediaId === media.id) {
|
||||
api.get<PaginatedResponse<AnnotationListItem>>(
|
||||
`/api/annotations/annotations?mediaId=${media.id}&pageSize=1000`
|
||||
endpoints.annotations.annotationsByMedia(media.id),
|
||||
).then(res => onAnnotationsUpdate(res.items)).catch(() => {})
|
||||
}
|
||||
})
|
||||
@@ -36,7 +35,7 @@ export default function AnnotationsSidebar({ media, annotations, selectedAnnotat
|
||||
setDetecting(true)
|
||||
setDetectLog(['Starting AI detection...'])
|
||||
try {
|
||||
await api.post(`/api/detect/${media.id}`)
|
||||
await api.post(endpoints.detect.media(media.id))
|
||||
setDetectLog(prev => [...prev, 'Detection complete.'])
|
||||
} catch (e: any) {
|
||||
setDetectLog(prev => [...prev, `Error: ${e.message}`])
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useRef, useEffect, useState, useCallback, forwardRef, useImperativeHandle } from 'react'
|
||||
import { endpoints } from '../../api'
|
||||
import { MediaType } from '../../types'
|
||||
import type { Media, AnnotationListItem, Detection, Affiliation, CombatReadiness } from '../../types'
|
||||
import { getClassColor, getPhotoModeSuffix, getClassNameFallback } from './classColors'
|
||||
import { getClassColor, getPhotoModeSuffix, getClassNameFallback } from '../../class-colors'
|
||||
|
||||
interface Props {
|
||||
media: Media
|
||||
@@ -77,11 +78,11 @@ const CanvasEditor = forwardRef<CanvasEditorHandle, Props>(function CanvasEditor
|
||||
img.crossOrigin = 'anonymous'
|
||||
const isLocalPath = media.path.startsWith('blob:') || media.path.startsWith('data:')
|
||||
if (annotation && !isLocalPath) {
|
||||
img.src = `/api/annotations/annotations/${annotation.id}/image`
|
||||
img.src = endpoints.annotations.annotationImage(annotation.id)
|
||||
} else if (isLocalPath) {
|
||||
img.src = media.path
|
||||
} else {
|
||||
img.src = `/api/annotations/media/${media.id}/file`
|
||||
img.src = endpoints.annotations.mediaFile(media.id)
|
||||
}
|
||||
img.onload = () => {
|
||||
imgRef.current = img
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useDropzone } from 'react-dropzone'
|
||||
import { useFlight } from '../../components/FlightContext'
|
||||
import { api } from '../../api/client'
|
||||
import { useDebounce } from '../../hooks/useDebounce'
|
||||
import ConfirmDialog from '../../components/ConfirmDialog'
|
||||
import { useFlight, ConfirmDialog } from '../../components'
|
||||
import { api, endpoints } from '../../api'
|
||||
import { useDebounce } from '../../hooks'
|
||||
import { MediaType } from '../../types'
|
||||
import type { Media, PaginatedResponse, AnnotationListItem } from '../../types'
|
||||
|
||||
@@ -28,7 +27,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
||||
if (selectedFlight) params.set('flightId', selectedFlight.id)
|
||||
if (debouncedFilter) params.set('name', debouncedFilter)
|
||||
try {
|
||||
const res = await api.get<PaginatedResponse<Media>>(`/api/annotations/media?${params}`)
|
||||
const res = await api.get<PaginatedResponse<Media>>(endpoints.annotations.media(params.toString()))
|
||||
setMedia(prev => {
|
||||
// Keep local-only (blob URL) entries, merge with backend entries
|
||||
const local = prev.filter(m => m.path.startsWith('blob:'))
|
||||
@@ -56,7 +55,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
||||
}
|
||||
try {
|
||||
const res = await api.get<PaginatedResponse<AnnotationListItem>>(
|
||||
`/api/annotations/annotations?mediaId=${m.id}&pageSize=1000`
|
||||
endpoints.annotations.annotationsByMedia(m.id),
|
||||
)
|
||||
onAnnotationsLoaded(res.items)
|
||||
} catch {
|
||||
@@ -73,7 +72,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
||||
setDeleteId(null)
|
||||
return
|
||||
}
|
||||
try { await api.delete(`/api/annotations/media/${deleteId}`) } catch {}
|
||||
try { await api.delete(endpoints.annotations.mediaItem(deleteId)) } catch {}
|
||||
setDeleteId(null)
|
||||
fetchMedia()
|
||||
}
|
||||
@@ -88,7 +87,7 @@ export default function MediaList({ selectedMedia, onSelect, onAnnotationsLoaded
|
||||
const form = new FormData()
|
||||
form.append('waypointId', '')
|
||||
for (const file of arr) form.append('files', file)
|
||||
await api.upload('/api/annotations/media/batch', form)
|
||||
await api.upload(endpoints.annotations.mediaBatch(), form)
|
||||
fetchMedia()
|
||||
return
|
||||
} catch {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useRef, useState, useCallback, useEffect, forwardRef, useImperativeHandle } from 'react'
|
||||
import { FaPlay, FaPause, FaStop, FaStepBackward, FaStepForward, FaVolumeMute, FaVolumeUp } from 'react-icons/fa'
|
||||
import { endpoints } from '../../api'
|
||||
import type { Media } from '../../types'
|
||||
|
||||
interface Props {
|
||||
@@ -38,7 +39,7 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, Props>(function VideoPlayer({
|
||||
|
||||
const videoUrl = media.path.startsWith('blob:')
|
||||
? media.path
|
||||
: `/api/annotations/media/${media.id}/file`
|
||||
: endpoints.annotations.mediaFile(media.id)
|
||||
|
||||
const stepFrames = useCallback((count: number) => {
|
||||
const video = videoRef.current
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export { default as AnnotationsPage } from './AnnotationsPage'
|
||||
// CanvasEditor remains in the Public API while F2 (cross-feature edge to
|
||||
// 07_dataset) is open. Closing F2 will remove this re-export.
|
||||
export { default as CanvasEditor } from './CanvasEditor'
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MediaType } from '../../types'
|
||||
import type { Detection, Media } from '../../types'
|
||||
import { getClassColor } from './classColors'
|
||||
import { getClassColor } from '../../class-colors'
|
||||
|
||||
const THUMB_MAX = 240
|
||||
const CROP_PAD = 0.15
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FaPen } from 'react-icons/fa'
|
||||
import { api } from '../../api/client'
|
||||
import { useDebounce } from '../../hooks/useDebounce'
|
||||
import { useResizablePanel } from '../../hooks/useResizablePanel'
|
||||
import { useFlight } from '../../components/FlightContext'
|
||||
import { api, endpoints } from '../../api'
|
||||
import { useDebounce, useResizablePanel } from '../../hooks'
|
||||
import { useFlight, DetectionClasses } from '../../components'
|
||||
import { useSavedAnnotations } from '../../components/SavedAnnotationsContext'
|
||||
import DetectionClasses from '../../components/DetectionClasses'
|
||||
import CanvasEditor from '../annotations/CanvasEditor'
|
||||
import { recaptureThumbnails } from '../annotations/thumbnail'
|
||||
import type { SavedDetection } from '../../components/SavedAnnotationsContext'
|
||||
@@ -64,7 +62,7 @@ export default function DatasetPage() {
|
||||
if (objectsOnly) params.set('hasDetections', 'true')
|
||||
if (debouncedSearch) params.set('name', debouncedSearch)
|
||||
try {
|
||||
const res = await api.get<PaginatedResponse<DatasetItem>>(`/api/annotations/dataset?${params}`)
|
||||
const res = await api.get<PaginatedResponse<DatasetItem>>(endpoints.annotations.dataset(params.toString()))
|
||||
setItems(res.items)
|
||||
setTotalCount(res.totalCount)
|
||||
} catch {}
|
||||
@@ -103,7 +101,7 @@ export default function DatasetPage() {
|
||||
imageName: item.imageName,
|
||||
status: item.status,
|
||||
createdDate: item.createdDate,
|
||||
thumbnailUrl: `/api/annotations/annotations/${item.annotationId}/thumbnail`,
|
||||
thumbnailUrl: endpoints.annotations.annotationThumbnail(item.annotationId),
|
||||
isSeed: item.isSeed,
|
||||
isLocal: false,
|
||||
}))
|
||||
@@ -138,7 +136,7 @@ export default function DatasetPage() {
|
||||
setEditorFullFrame('')
|
||||
setEditorLocalGroupId(null)
|
||||
try {
|
||||
const ann = await api.get<AnnotationListItem>(`/api/annotations/dataset/${card.annotationId}`)
|
||||
const ann = await api.get<AnnotationListItem>(endpoints.annotations.datasetItem(card.annotationId))
|
||||
setEditorAnnotation(ann)
|
||||
setEditorDetections(ann.detections)
|
||||
setTab('editor')
|
||||
@@ -188,7 +186,7 @@ export default function DatasetPage() {
|
||||
|
||||
if (backendIds.length > 0) {
|
||||
try {
|
||||
await api.post('/api/annotations/dataset/bulk-status', {
|
||||
await api.post(endpoints.annotations.datasetBulkStatus(), {
|
||||
annotationIds: backendIds,
|
||||
status: AnnotationStatus.Validated,
|
||||
})
|
||||
@@ -203,7 +201,7 @@ export default function DatasetPage() {
|
||||
|
||||
const loadDistribution = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get<ClassDistributionItem[]>('/api/annotations/dataset/class-distribution')
|
||||
const data = await api.get<ClassDistributionItem[]>(endpoints.annotations.datasetClassDistribution())
|
||||
setDistribution(data)
|
||||
} catch {}
|
||||
}, [])
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as DatasetPage } from './DatasetPage'
|
||||
@@ -8,7 +8,7 @@ import DrawControl from './DrawControl'
|
||||
import MapPoint from './MapPoint'
|
||||
import MiniMap from './MiniMap'
|
||||
import { defaultIcon } from './mapIcons'
|
||||
import { TILE_URLS } from './types'
|
||||
import { getTileUrl } from './types'
|
||||
import type { FlightPoint, CalculatedPointInfo, MapRectangle, ActionMode, MovingPointInfo } from './types'
|
||||
|
||||
interface MapEventsProps {
|
||||
@@ -86,7 +86,6 @@ export default function FlightMap({
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [mapType, setMapType] = useState<'classic' | 'satellite'>('satellite')
|
||||
const [movingPoint, setMovingPoint] = useState<MovingPointInfo | null>(null)
|
||||
const [draggablePoints, setDraggablePoints] = useState(points)
|
||||
const polylineClickRef = useRef(false)
|
||||
@@ -123,13 +122,14 @@ export default function FlightMap({
|
||||
<MapContainer center={currentPosition} zoom={15} className="h-full w-full">
|
||||
<ClickHandler />
|
||||
<TileLayer
|
||||
url={mapType === 'classic' ? TILE_URLS.classic : TILE_URLS.satellite}
|
||||
attribution={mapType === 'classic' ? '© <a href="https://www.openstreetmap.org/copyright">OSM</a>' : 'Satellite'}
|
||||
url={getTileUrl()}
|
||||
crossOrigin="use-credentials"
|
||||
attribution="Satellite"
|
||||
/>
|
||||
<MapEvents points={draggablePoints} handlePolylineClick={handlePolylineClick} containerRef={containerRef} onMapMove={onMapMove} />
|
||||
<SetView center={currentPosition} />
|
||||
|
||||
{movingPoint && <MiniMap pointPosition={movingPoint} mapType={mapType} />}
|
||||
{movingPoint && <MiniMap pointPosition={movingPoint} />}
|
||||
|
||||
{draggablePoints.map((point, index) => (
|
||||
<MapPoint key={point.id}
|
||||
@@ -171,13 +171,6 @@ export default function FlightMap({
|
||||
Click and drag on the map to draw a {actionMode === 'workArea' ? 'work area' : 'no-go zone'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button onClick={() => setMapType(m => m === 'classic' ? 'satellite' : 'classic')}
|
||||
className={`absolute top-2 right-2 z-[400] px-2 py-1 text-xs rounded border ${
|
||||
mapType === 'satellite' ? 'bg-az-panel border-az-orange text-white' : 'bg-az-panel border-az-border text-az-text'
|
||||
}`}>
|
||||
{t('flights.planner.satellite')}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import L from 'leaflet'
|
||||
import { useFlight } from '../../components/FlightContext'
|
||||
import { api } from '../../api/client'
|
||||
import { createSSE } from '../../api/sse'
|
||||
import ConfirmDialog from '../../components/ConfirmDialog'
|
||||
import { useFlight, ConfirmDialog } from '../../components'
|
||||
import { api, createSSE, endpoints } from '../../api'
|
||||
import FlightListSidebar from './FlightListSidebar'
|
||||
import FlightParamsPanel from './FlightParamsPanel'
|
||||
import FlightMap from './FlightMap'
|
||||
@@ -40,7 +38,7 @@ export default function FlightsPage() {
|
||||
const [jsonDialog, setJsonDialog] = useState({ open: false, text: '' })
|
||||
|
||||
useEffect(() => {
|
||||
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
|
||||
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
||||
setAircraft(getMockAircraftParams())
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(pos) => setCurrentPosition({ lat: pos.coords.latitude, lng: pos.coords.longitude }),
|
||||
@@ -50,7 +48,7 @@ export default function FlightsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFlight) { setPoints([]); return }
|
||||
api.get<Waypoint[]>(`/api/flights/${selectedFlight.id}/waypoints`)
|
||||
api.get<Waypoint[]>(endpoints.flights.flightWaypoints(selectedFlight.id))
|
||||
.then(wps => {
|
||||
setPoints(wps.sort((a, b) => a.order - b.order).map(wp => ({
|
||||
id: wp.id,
|
||||
@@ -64,7 +62,7 @@ export default function FlightsPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedFlight || mode !== 'gps') return
|
||||
return createSSE<{ lat: number; lon: number; satellites: number; status: string }>(`/api/flights/${selectedFlight.id}/live-gps`, (data) => setLiveGps(data))
|
||||
return createSSE<{ lat: number; lon: number; satellites: number; status: string }>(endpoints.flights.flightLiveGps(selectedFlight.id), (data) => setLiveGps(data))
|
||||
}, [selectedFlight, mode])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -73,12 +71,12 @@ export default function FlightsPage() {
|
||||
}, [points, aircraft, initialAltitude])
|
||||
|
||||
const handleCreateFlight = async (name: string) => {
|
||||
await api.post('/api/flights', { name })
|
||||
await api.post(endpoints.flights.collection(), { name })
|
||||
refreshFlights()
|
||||
}
|
||||
const handleDeleteFlight = async () => {
|
||||
if (!deleteId) return
|
||||
await api.delete(`/api/flights/${deleteId}`)
|
||||
await api.delete(endpoints.flights.flight(deleteId))
|
||||
if (selectedFlight?.id === deleteId) selectFlight(null)
|
||||
setDeleteId(null)
|
||||
refreshFlights()
|
||||
@@ -201,12 +199,12 @@ export default function FlightsPage() {
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedFlight) return
|
||||
const existing = await api.get<Waypoint[]>(`/api/flights/${selectedFlight.id}/waypoints`).catch(() => [] as Waypoint[])
|
||||
const existing = await api.get<Waypoint[]>(endpoints.flights.flightWaypoints(selectedFlight.id)).catch(() => [] as Waypoint[])
|
||||
for (const wp of existing) {
|
||||
await api.delete(`/api/flights/${selectedFlight.id}/waypoints/${wp.id}`).catch(() => {})
|
||||
await api.delete(endpoints.flights.flightWaypoint(selectedFlight.id, wp.id)).catch(() => {})
|
||||
}
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
await api.post(`/api/flights/${selectedFlight.id}/waypoints`, {
|
||||
await api.post(endpoints.flights.flightWaypoints(selectedFlight.id), {
|
||||
name: `Point ${i + 1}`,
|
||||
latitude: points[i].position.lat,
|
||||
longitude: points[i].position.lng,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { MapContainer, TileLayer, CircleMarker, useMap } from 'react-leaflet'
|
||||
import { useEffect } from 'react'
|
||||
import type L from 'leaflet'
|
||||
import { TILE_URLS } from './types'
|
||||
import { getTileUrl } from './types'
|
||||
import type { MovingPointInfo } from './types'
|
||||
|
||||
function UpdateCenter({ latlng }: { latlng: L.LatLng }) {
|
||||
@@ -12,10 +12,9 @@ function UpdateCenter({ latlng }: { latlng: L.LatLng }) {
|
||||
|
||||
interface Props {
|
||||
pointPosition: MovingPointInfo
|
||||
mapType: 'classic' | 'satellite'
|
||||
}
|
||||
|
||||
export default function MiniMap({ pointPosition, mapType }: Props) {
|
||||
export default function MiniMap({ pointPosition }: Props) {
|
||||
return (
|
||||
<div
|
||||
className="absolute w-[240px] h-[180px] border border-az-border rounded shadow-lg z-[1000] overflow-hidden pointer-events-none"
|
||||
@@ -23,7 +22,7 @@ export default function MiniMap({ pointPosition, mapType }: Props) {
|
||||
>
|
||||
<MapContainer center={pointPosition.latlng} zoom={18} zoomControl={false}
|
||||
className="w-full h-full" attributionControl={false}>
|
||||
<TileLayer url={mapType === 'classic' ? TILE_URLS.classic : TILE_URLS.satellite} />
|
||||
<TileLayer url={getTileUrl()} crossOrigin="use-credentials" />
|
||||
<CircleMarker center={pointPosition.latlng} radius={3} color="#fa5252" />
|
||||
<UpdateCenter latlng={pointPosition.latlng} />
|
||||
</MapContainer>
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
||||
import type L from 'leaflet'
|
||||
import { renderWithProviders, screen } from '../../../../tests/helpers/render'
|
||||
|
||||
// AZ-498 — self-hosted satellite tiles + drop classic/satellite toggle.
|
||||
//
|
||||
// Colocated under src/features/flights/__tests__/ per module-layout's "Tests"
|
||||
// guidance: keeps the cross-component import surface clean (these tests
|
||||
// reach into 05_flights internals — `./FlightMap`, `./MiniMap`, `./types` —
|
||||
// which is intra-component access). Tests/ is reserved for cross-cutting
|
||||
// black-box suites whose imports must go through public-API barrels.
|
||||
//
|
||||
// Covers the spec's fast-profile ACs:
|
||||
// AC-1 — env-resolved getTileUrl() returns the env var verbatim.
|
||||
// AC-2 — when the env var is unset, getTileUrl() returns the dev default
|
||||
// `http://localhost:5100/tiles/{z}/{x}/{y}` (cycle-2 assumption).
|
||||
// AC-3 — every <TileLayer> the SPA renders sets crossOrigin="use-credentials"
|
||||
// so the browser attaches the satellite-provider auth cookie.
|
||||
// AC-4 — the classic/satellite toggle, the `mapType` state, and the
|
||||
// `MiniMap.Props.mapType` prop are all gone.
|
||||
//
|
||||
// Notes:
|
||||
// - AC-5 is statically enforced by tsc on the new ImportMetaEnv shape +
|
||||
// the `.env.example` audit; no runtime test needed.
|
||||
// - AC-6, AC-7 are e2e/contract; AC-8 in the original spec misattributed
|
||||
// `tile_split_zoom*` (image-annotation surface) — see implementation
|
||||
// report. AC-9 is enforced by STC-ARCH-01 / STC-ARCH-02.
|
||||
|
||||
interface TileLayerProps {
|
||||
url?: string
|
||||
crossOrigin?: string
|
||||
attribution?: string
|
||||
}
|
||||
|
||||
interface MapContainerProps {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
vi.mock('react-leaflet', () => ({
|
||||
MapContainer: ({ children, className }: MapContainerProps) => (
|
||||
<div data-testid="map-container" className={className}>{children}</div>
|
||||
),
|
||||
TileLayer: (props: TileLayerProps) => (
|
||||
<img
|
||||
data-testid="tile-layer"
|
||||
data-tile-url={props.url ?? ''}
|
||||
data-cross-origin={props.crossOrigin ?? ''}
|
||||
data-attribution={props.attribution ?? ''}
|
||||
alt=""
|
||||
/>
|
||||
),
|
||||
Marker: () => null,
|
||||
Popup: () => null,
|
||||
Polyline: () => null,
|
||||
Rectangle: () => null,
|
||||
CircleMarker: () => null,
|
||||
useMap: () => ({
|
||||
on: () => undefined,
|
||||
off: () => undefined,
|
||||
setView: () => undefined,
|
||||
removeLayer: () => undefined,
|
||||
getCenter: () => ({ lat: 0, lng: 0 }),
|
||||
invalidateSize: () => undefined,
|
||||
}),
|
||||
useMapEvents: () => null,
|
||||
}))
|
||||
|
||||
// Leaflet itself is touched at import time by FlightMap (`L.polyline`,
|
||||
// `L.Symbol.arrowHead`). Mock the bits the component reaches for so the
|
||||
// import doesn't blow up under jsdom.
|
||||
vi.mock('leaflet', () => {
|
||||
const Lstub = {
|
||||
polyline: () => ({ addTo: () => Lstub.polyline(), on: () => undefined }),
|
||||
polylineDecorator: () => ({ addTo: () => undefined }),
|
||||
Symbol: { arrowHead: () => ({}) },
|
||||
Icon: { Default: class { mergeOptions() {} } },
|
||||
Marker: class {},
|
||||
Layer: class {},
|
||||
LatLngBounds: class {},
|
||||
}
|
||||
return { default: Lstub }
|
||||
})
|
||||
vi.mock('leaflet/dist/leaflet.css', () => ({}))
|
||||
vi.mock('leaflet-polylinedecorator', () => ({}))
|
||||
vi.mock('../DrawControl', () => ({ default: () => null }))
|
||||
vi.mock('../MapPoint', () => ({ default: () => null }))
|
||||
vi.mock('../mapIcons', () => ({ defaultIcon: {} }))
|
||||
|
||||
import FlightMap from '../FlightMap'
|
||||
import MiniMap from '../MiniMap'
|
||||
import { getTileUrl, DEFAULT_SATELLITE_TILE_URL } from '../types'
|
||||
|
||||
const stubLatLng = { lat: 0, lng: 0 } as unknown as L.LatLng
|
||||
const fixedPosition = { lat: 50, lng: 30 }
|
||||
|
||||
const baseFlightMapProps = {
|
||||
points: [],
|
||||
calculatedPointInfo: [],
|
||||
currentPosition: fixedPosition,
|
||||
rectangles: [],
|
||||
setRectangles: () => undefined,
|
||||
rectangleColor: 'red',
|
||||
actionMode: 'points' as const,
|
||||
onAddPoint: () => undefined,
|
||||
onUpdatePoint: () => undefined,
|
||||
onRemovePoint: () => undefined,
|
||||
onAltitudeChange: () => undefined,
|
||||
onMetaChange: () => undefined,
|
||||
onPolylineClick: () => undefined,
|
||||
onPositionChange: () => undefined,
|
||||
onMapMove: () => undefined,
|
||||
}
|
||||
|
||||
describe('AZ-498 — getTileUrl() env resolution', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('AC-1: returns the env-set VITE_SATELLITE_TILE_URL verbatim', () => {
|
||||
// Arrange
|
||||
vi.stubEnv('VITE_SATELLITE_TILE_URL', 'http://satellite-provider:5100/tiles/{z}/{x}/{y}')
|
||||
|
||||
// Assert
|
||||
expect(getTileUrl()).toBe('http://satellite-provider:5100/tiles/{z}/{x}/{y}')
|
||||
})
|
||||
|
||||
it('AC-2: returns the dev default when VITE_SATELLITE_TILE_URL is unset', () => {
|
||||
// Arrange
|
||||
vi.stubEnv('VITE_SATELLITE_TILE_URL', '')
|
||||
|
||||
// Assert
|
||||
expect(getTileUrl()).toBe(DEFAULT_SATELLITE_TILE_URL)
|
||||
expect(DEFAULT_SATELLITE_TILE_URL).toBe('http://localhost:5100/tiles/{z}/{x}/{y}')
|
||||
})
|
||||
|
||||
it('AC-2: strips trailing slashes off the env-set URL', () => {
|
||||
// Arrange
|
||||
vi.stubEnv('VITE_SATELLITE_TILE_URL', 'http://satellite-provider:5100/tiles/{z}/{x}/{y}/')
|
||||
|
||||
// Assert
|
||||
expect(getTileUrl()).toBe('http://satellite-provider:5100/tiles/{z}/{x}/{y}')
|
||||
})
|
||||
})
|
||||
|
||||
describe('AZ-498 — FlightMap satellite-only TileLayer', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('VITE_SATELLITE_TILE_URL', '')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('AC-3: <TileLayer> declares crossOrigin="use-credentials"', () => {
|
||||
// Act
|
||||
renderWithProviders(<FlightMap {...baseFlightMapProps} />, { withoutAuth: true })
|
||||
|
||||
// Assert
|
||||
const tile = screen.getByTestId('tile-layer')
|
||||
expect(tile.getAttribute('data-cross-origin')).toBe('use-credentials')
|
||||
})
|
||||
|
||||
it('AC-3: <TileLayer> renders the dev-default URL when env is unset', () => {
|
||||
// Act
|
||||
renderWithProviders(<FlightMap {...baseFlightMapProps} />, { withoutAuth: true })
|
||||
|
||||
// Assert
|
||||
const tile = screen.getByTestId('tile-layer')
|
||||
expect(tile.getAttribute('data-tile-url')).toBe(DEFAULT_SATELLITE_TILE_URL)
|
||||
})
|
||||
|
||||
it('AC-4: the classic/satellite toggle button is gone', () => {
|
||||
// Act
|
||||
renderWithProviders(<FlightMap {...baseFlightMapProps} />, { withoutAuth: true })
|
||||
|
||||
// Assert
|
||||
expect(screen.queryByRole('button', { name: /satellite|classic/i })).toBeNull()
|
||||
// Only one <TileLayer> is mounted (no per-mode branching).
|
||||
expect(screen.getAllByTestId('tile-layer')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AZ-498 — MiniMap satellite-only TileLayer', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('VITE_SATELLITE_TILE_URL', '')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs()
|
||||
})
|
||||
|
||||
it('AC-3: MiniMap <TileLayer> declares crossOrigin="use-credentials"', () => {
|
||||
// Act
|
||||
renderWithProviders(
|
||||
<MiniMap pointPosition={{ x: 0, y: 0, latlng: stubLatLng }} />,
|
||||
{ withoutAuth: true },
|
||||
)
|
||||
|
||||
// Assert
|
||||
const tile = screen.getByTestId('tile-layer')
|
||||
expect(tile.getAttribute('data-cross-origin')).toBe('use-credentials')
|
||||
})
|
||||
|
||||
it('AC-4: MiniMap mounts with only `pointPosition` prop (no `mapType`)', () => {
|
||||
// Act — explicitly omit mapType; if MiniMap still required it, TS would
|
||||
// error at compile time. The runtime render also confirms the component
|
||||
// mounts with just the position prop.
|
||||
renderWithProviders(
|
||||
<MiniMap pointPosition={{ x: 0, y: 0, latlng: stubLatLng }} />,
|
||||
{ withoutAuth: true },
|
||||
)
|
||||
|
||||
// Assert
|
||||
expect(screen.getByTestId('tile-layer')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -56,9 +56,18 @@ export function calculateDistance(
|
||||
return ascentVertical + horizontalDistance + descentVertical
|
||||
}
|
||||
|
||||
const DEFAULT_OWM_BASE_URL = 'https://api.openweathermap.org/data/2.5'
|
||||
|
||||
function getOwmBaseUrl(): string {
|
||||
const raw = import.meta.env.VITE_OWM_BASE_URL
|
||||
if (!raw) return DEFAULT_OWM_BASE_URL
|
||||
return raw.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
export async function getWeatherData(lat: number, lon: number) {
|
||||
const apiKey = '335799082893fad97fa36118b131f919'
|
||||
const url = `https://api.openweathermap.org/data/2.5/weather?lat=${lat}&lon=${lon}&appid=${apiKey}&units=metric`
|
||||
const apiKey = import.meta.env.VITE_OWM_API_KEY
|
||||
if (!apiKey) return null
|
||||
const url = `${getOwmBaseUrl()}/weather?lat=${lat}&lon=${lon}&appid=${apiKey}&units=metric`
|
||||
try {
|
||||
const res = await fetch(url)
|
||||
const data = await res.json()
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as FlightsPage } from './FlightsPage'
|
||||
@@ -1,4 +1,5 @@
|
||||
import L from 'leaflet'
|
||||
import markerIcon from 'leaflet/dist/images/marker-icon.png'
|
||||
|
||||
function pinIcon(color: string) {
|
||||
return L.divIcon({
|
||||
@@ -15,7 +16,7 @@ export const pointIconBlue = pinIcon('#228be6')
|
||||
export const pointIconRed = pinIcon('#fa5252')
|
||||
|
||||
export const defaultIcon = new L.Icon({
|
||||
iconUrl: 'https://unpkg.com/leaflet@1.7.1/dist/images/marker-icon.png',
|
||||
iconUrl: markerIcon,
|
||||
iconSize: [25, 41],
|
||||
iconAnchor: [12, 41],
|
||||
popupAnchor: [1, -34],
|
||||
|
||||
@@ -52,7 +52,18 @@ export const PURPOSES = [
|
||||
|
||||
export const COORDINATE_PRECISION = 8
|
||||
|
||||
export const TILE_URLS = {
|
||||
classic: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
satellite: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||
} as const
|
||||
// AZ-498 — single self-hosted satellite tile URL. The previous classic/satellite
|
||||
// pair (OSM + Esri) was retired so the SPA only consumes the suite's own
|
||||
// satellite-provider service. Production builds MUST set VITE_SATELLITE_TILE_URL
|
||||
// to the same-origin nginx path (e.g. `/tiles/{z}/{x}/{y}`); the dev default
|
||||
// targets the satellite-provider container on its conventional dev port.
|
||||
//
|
||||
// Read via a function (mirrors `getOwmBaseUrl` in flightPlanUtils.ts) so tests
|
||||
// can stub `import.meta.env` per-case without module-reload tricks.
|
||||
export const DEFAULT_SATELLITE_TILE_URL = 'http://localhost:5100/tiles/{z}/{x}/{y}'
|
||||
|
||||
export function getTileUrl(): string {
|
||||
const raw = import.meta.env.VITE_SATELLITE_TILE_URL
|
||||
if (!raw) return DEFAULT_SATELLITE_TILE_URL
|
||||
return raw.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../../auth/AuthContext'
|
||||
import { useAuth } from '../../auth'
|
||||
|
||||
type UnlockStep = 'idle' | 'authenticating' | 'downloadingKey' | 'decrypting' | 'startingServices' | 'ready'
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as LoginPage } from './LoginPage'
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { api } from '../../api/client'
|
||||
import { api, endpoints } from '../../api'
|
||||
import type { SystemSettings, DirectorySettings, Aircraft } from '../../types'
|
||||
|
||||
export default function SettingsPage() {
|
||||
@@ -11,27 +11,27 @@ export default function SettingsPage() {
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
api.get<SystemSettings>('/api/annotations/settings/system').then(setSystem).catch(() => {})
|
||||
api.get<DirectorySettings>('/api/annotations/settings/directories').then(setDirs).catch(() => {})
|
||||
api.get<Aircraft[]>('/api/flights/aircrafts').then(setAircrafts).catch(() => {})
|
||||
api.get<SystemSettings>(endpoints.annotations.settingsSystem()).then(setSystem).catch(() => {})
|
||||
api.get<DirectorySettings>(endpoints.annotations.settingsDirectories()).then(setDirs).catch(() => {})
|
||||
api.get<Aircraft[]>(endpoints.flights.aircrafts()).then(setAircrafts).catch(() => {})
|
||||
}, [])
|
||||
|
||||
const saveSystem = async () => {
|
||||
if (!system) return
|
||||
setSaving(true)
|
||||
await api.put('/api/annotations/settings/system', system)
|
||||
await api.put(endpoints.annotations.settingsSystem(), system)
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const saveDirs = async () => {
|
||||
if (!dirs) return
|
||||
setSaving(true)
|
||||
await api.put('/api/annotations/settings/directories', dirs)
|
||||
await api.put(endpoints.annotations.settingsDirectories(), dirs)
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const handleToggleDefault = async (a: Aircraft) => {
|
||||
await api.patch(`/api/flights/aircrafts/${a.id}`, { isDefault: !a.isDefault })
|
||||
await api.patch(endpoints.flights.aircraft(a.id), { isDefault: !a.isDefault })
|
||||
setAircrafts(prev => prev.map(x => x.id === a.id ? { ...x, isDefault: !x.isDefault } : x))
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default as SettingsPage } from './SettingsPage'
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useDebounce } from './useDebounce'
|
||||
export { useResizablePanel } from './useResizablePanel'
|
||||
+9
-2
@@ -77,7 +77,6 @@
|
||||
},
|
||||
"invalidJson": "Invalid JSON format",
|
||||
"editJsonHint": "Edit the JSON data as needed.",
|
||||
"satellite": "Satellite",
|
||||
"cameraFov": "Camera FOV / Length / Field",
|
||||
"cameraFovPlaceholder": "FOV parameters",
|
||||
"commAddr": "Communication Addr / Port",
|
||||
@@ -115,7 +114,15 @@
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin",
|
||||
"classes": "Detection Classes",
|
||||
"classes": {
|
||||
"title": "Detection Classes",
|
||||
"edit": "Edit",
|
||||
"save": "Save",
|
||||
"cancel": "Cancel",
|
||||
"nameRequired": "Name is required",
|
||||
"maxSizeMustBePositive": "Max size must be a positive number",
|
||||
"updateFailed": "Update failed. Please try again."
|
||||
},
|
||||
"aiSettings": "AI Recognition Settings",
|
||||
"gpsSettings": "GPS Device Settings",
|
||||
"aircrafts": "Default Aircrafts",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './i18n'
|
||||
+9
-2
@@ -77,7 +77,6 @@
|
||||
},
|
||||
"invalidJson": "Невірний JSON формат",
|
||||
"editJsonHint": "Відредагуйте JSON дані за потреби.",
|
||||
"satellite": "Супутник",
|
||||
"cameraFov": "Камера FOV / Фокус",
|
||||
"cameraFovPlaceholder": "Параметри FOV",
|
||||
"commAddr": "Адреса / Порт",
|
||||
@@ -115,7 +114,15 @@
|
||||
},
|
||||
"admin": {
|
||||
"title": "Адмін",
|
||||
"classes": "Класи детекцій",
|
||||
"classes": {
|
||||
"title": "Класи детекцій",
|
||||
"edit": "Редагувати",
|
||||
"save": "Зберегти",
|
||||
"cancel": "Скасувати",
|
||||
"nameRequired": "Назва обов'язкова",
|
||||
"maxSizeMustBePositive": "Максимальний розмір має бути додатнім числом",
|
||||
"updateFailed": "Не вдалося оновити. Спробуйте ще раз."
|
||||
},
|
||||
"aiSettings": "AI Налаштування",
|
||||
"gpsSettings": "GPS Пристрій",
|
||||
"aircrafts": "Літальні апарати",
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App'
|
||||
import './i18n/i18n'
|
||||
import './i18n'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
|
||||
Vendored
+11
@@ -2,3 +2,14 @@
|
||||
|
||||
declare module '*.css'
|
||||
declare module 'leaflet-polylinedecorator'
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_BASE_URL?: string
|
||||
readonly VITE_OWM_API_KEY?: string
|
||||
readonly VITE_OWM_BASE_URL?: string
|
||||
readonly VITE_SATELLITE_TILE_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user