diff --git a/src/config/sidebarPermissions.ts b/src/config/sidebarPermissions.ts index 59c5d40d..532c1b5a 100644 --- a/src/config/sidebarPermissions.ts +++ b/src/config/sidebarPermissions.ts @@ -77,7 +77,10 @@ export const SIDEBAR_ENTRIES: SidebarEntry[] = [ // Security { labelKey: 'nav.authFlows', path: '/auth-flows', group: 'security', visibleToRoles: ADMIN_ROLES }, - { labelKey: 'nav.authSessions', path: '/auth-sessions', group: 'security', visibleToRoles: ADMIN_ROLES }, + // 'Auth Sessions' (/auth-sessions) hidden from nav 2026-06-02: the auth_sessions + // table has no production writer (POST /auth/sessions is unreached on web/widget/ + // mobile), so the page can never populate. Route kept in App.tsx; re-add this entry + // if the stepped-auth-session engine is ever wired to a real flow. { labelKey: 'nav.devices', path: '/devices', group: 'security', visibleToRoles: ADMIN_ROLES }, // Audit logs list is tenant-scoped on the backend after #24, but the // full cross-tenant view only renders for platform owners. diff --git a/src/features/auth/components/LoginPage.tsx b/src/features/auth/components/LoginPage.tsx index dd697d81..2c52273c 100644 --- a/src/features/auth/components/LoginPage.tsx +++ b/src/features/auth/components/LoginPage.tsx @@ -967,31 +967,33 @@ export default function LoginPage() { } aria-label={t('auth.loginFormLabel')} > - {/* Step 2 (identifier-first): read-only identity chip + "Change", - so the password is the only input — mirrors verify.fivucsas. */} + {/* Step 2 (identifier-first): read-only identity display + a hidden + username input (a11y / password managers). NO per-step "change + email" affordance — the uniform shell-level "Not you? Start over" + is the single identity control across ALL factors (matches + verify.fivucsas). PR #145 had re-added a password-only "Change" + button here, which made the password step inconsistent with every + other first factor — removed 2026-06-02. */} {showEmailAsChip && ( - - - - - {getValues('email')} - - - + + + + {getValues('email')} + + {/* paired username field so the password field has an + associated identifier for a11y + password managers */} + )} diff --git a/src/features/auth/components/enrollment/sections/MethodCardsGrid.tsx b/src/features/auth/components/enrollment/sections/MethodCardsGrid.tsx index b998fdfe..5e7b3f79 100644 --- a/src/features/auth/components/enrollment/sections/MethodCardsGrid.tsx +++ b/src/features/auth/components/enrollment/sections/MethodCardsGrid.tsx @@ -179,7 +179,18 @@ export default function MethodCardsGrid({ {t(config.description)} diff --git a/src/features/auth/components/steps/PasswordStep.tsx b/src/features/auth/components/steps/PasswordStep.tsx index 952c115a..4273ad48 100644 --- a/src/features/auth/components/steps/PasswordStep.tsx +++ b/src/features/auth/components/steps/PasswordStep.tsx @@ -83,6 +83,7 @@ export default function PasswordStep({ onSubmit, loading, error, presetEmail }: // read-only, NO email box. The "change identity" / restart // affordance is the shell-level "Not you? Start over" header // link (Fix 1), so it is NOT repeated here per-step. + <> + {/* paired username field for a11y + password managers (email is shown + read-only above, not as an input, in identifier-first mode) */} + + ) : ( )} - {consents.length > 0 && ( + {consents.some((c) => c.tenantId !== currentTenantId) && ( <> {t('biometricConsent.grantedTenants')} - {consents.map((c) => ( + {/* current tenant already shown by the toggle above; list only OTHER orgs */} + {consents.filter((c) => c.tenantId !== currentTenantId).map((c) => ( - {c.tenantId} + {c.tenantName ?? c.tenantId} {c.method ? ` · ${c.method}` : ''} } diff --git a/src/features/biometric-consent/useBiometricConsents.ts b/src/features/biometric-consent/useBiometricConsents.ts index d105ce10..aa8cdc3a 100644 --- a/src/features/biometric-consent/useBiometricConsents.ts +++ b/src/features/biometric-consent/useBiometricConsents.ts @@ -11,6 +11,7 @@ import type { ILogger } from '@domain/interfaces/ILogger' export interface BiometricConsent { id: string tenantId: string + tenantName?: string | null method: string | null granted: boolean grantedAt?: string | null @@ -40,7 +41,9 @@ export function useBiometricConsents() { try { const httpClient = container.get(TYPES.HttpClient) const res = await httpClient.get(CONSENTS_URL) - setConsents(res.data ?? []) + // `?? []` only guards null/undefined; a malformed (non-array) body would + // still be stored and crash consumers (consents.some/.filter/.find). Pin to an array. + setConsents(Array.isArray(res.data) ? res.data : []) } catch (e) { const logger = container.get(TYPES.Logger) logger.error('Failed to load biometric consents', e) diff --git a/src/features/biometric-puzzles/puzzles/FacePuzzle.tsx b/src/features/biometric-puzzles/puzzles/FacePuzzle.tsx index 6983bbb7..0bbb98b0 100644 --- a/src/features/biometric-puzzles/puzzles/FacePuzzle.tsx +++ b/src/features/biometric-puzzles/puzzles/FacePuzzle.tsx @@ -317,12 +317,13 @@ function FacePuzzle({ onSuccess, onError, challengeType, i18nKey }: Props) { setDetected(result.detected) setProgress(result.progress) if (result.completed && !completedRef.current) { - // Liveness gate (fail-closed): the gesture was performed, - // but only accept it when the latest passive-liveness - // verdict is live. A null verdict (detector unavailable or - // not yet run) does NOT pass. Reset the puzzle so the user - // retries with a real face rather than a photo/replay. - if (lastLiveRef.current !== true) { + // Liveness gate: the active gesture itself already demonstrates + // liveness, so only HARD-reject when passive liveness POSITIVELY + // reports not-live (photo/replay). A null verdict (detector + // unavailable / not yet warmed) soft-passes — the old `!== true` + // rejected EVERY completion whenever no verdict was produced, + // which is the "blink completes then immediately resets" bug. + if (lastLiveRef.current === false) { puzzle.start([challengeType], 1) startTsRef.current = performance.now() setProgress(0) diff --git a/src/lib/ml/BlazeFaceDetector.ts b/src/lib/ml/BlazeFaceDetector.ts index 6c1a89c3..5e3a38fb 100644 --- a/src/lib/ml/BlazeFaceDetector.ts +++ b/src/lib/ml/BlazeFaceDetector.ts @@ -81,6 +81,12 @@ export class BlazeFaceDetector { const blazefaceModule = await import('@tensorflow-models/blazeface'); this.model = await blazefaceModule.load({ maxFaces: 5, + // The package default fetches from tfhub.dev, which is NOT in the + // verify.fivucsas.com CSP connect-src → the load is CSP-blocked (noisy + // console errors + loss of this secondary detector). This is the canonical + // TF.js BlazeFace graph mirror on storage.googleapis.com, which IS allowed + // by connect-src. (Primary detector is MediaPipe FaceLandmarker.) + modelUrl: 'https://storage.googleapis.com/learnjs-data/face_detector/model.json', }); this.ready = true; diff --git a/src/pages/DeveloperPortalPage.tsx b/src/pages/DeveloperPortalPage.tsx index dc40d52a..8dcd85a6 100644 --- a/src/pages/DeveloperPortalPage.tsx +++ b/src/pages/DeveloperPortalPage.tsx @@ -28,8 +28,6 @@ import { Add, ContentCopy, Delete, - Visibility, - VisibilityOff, Code, CheckCircle, Login, @@ -109,8 +107,6 @@ export default function DeveloperPortalPage() { // Credentials reveal dialog const [credentialsApp, setCredentialsApp] = useState(null) - // View secret (not functional for existing apps — secret is hashed server-side) - const [visibleSecrets] = useState>({}) // Delete confirm const [deleteTarget, setDeleteTarget] = useState(null) @@ -254,34 +250,27 @@ export default function DeveloperPortalPage() { } // --- Quick Start code snippets --- - const scriptSnippet = ` - + const scriptSnippet = ` + ` - const callbackSnippet = `// Handle the OAuth2 callback -const params = new URLSearchParams(window.location.search); -const code = params.get('code'); - -if (code) { - const response = await fetch('https://api.fivucsas.com/api/v1/oauth2/token', { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - grant_type: 'authorization_code', - code, - client_id: 'YOUR_CLIENT_ID', - client_secret: 'YOUR_CLIENT_SECRET', - redirect_uri: 'https://yourapp.com/callback', - }), - }); - const { access_token, id_token } = await response.json(); -}` + const callbackSnippet = `// On your callback page (e.g. https://yourapp.com/callback) +const auth = new FivucsasAuth({ clientId: 'YOUR_CLIENT_ID' }); + +// Validates state + exchanges the code via PKCE — no client secret in the browser. +const result = await auth.handleRedirectCallback(); + +// result.accessToken, result.idToken, result.email, result.displayName, result.completedMethods +console.log(result.email, result.accessToken);` return ( @@ -380,7 +369,7 @@ if (code) {