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) {
}
- href="https://github.com/fivucsas/docs/blob/main/INTEGRATION_GUIDE.md"
+ href="https://docs.fivucsas.com/"
target="_blank"
rel="noopener noreferrer"
sx={{ flexShrink: 0, width: { xs: '100%', sm: 'auto' } }}
@@ -520,21 +509,6 @@ if (code) {
-
-
-
- {visibleSecrets[app.id] ? (
-
- ) : (
-
- )}
-
-
-
l.action === 'USER_LOGIN').length
+ // A login completes as USER_LOGIN (password-first) or MFA_COMPLETE (MFA / passkey /
+ // approve-login). The old USER_LOGIN-only filter read 0 for any MFA-completed login.
+ const loginCount = activityLogs.filter(
+ (l) => l.action === 'USER_LOGIN' || l.action === 'MFA_COMPLETE',
+ ).length
const totalEnrollments = enrolledMethods.length
// Delete enrollment handler
@@ -653,7 +658,7 @@ export default function MyProfilePage() {
}
label={t('myProfile.enrolledMethodsLabel')}
- value={`${enrolledMethods.length} / 9`}
+ value={`${enrolledMethods.length} / ${METHOD_CONFIGS.length}`}
color={enrolledMethods.length > 0 ? 'success' : 'warning'}
/>
diff --git a/src/verify-app/index.html b/src/verify-app/index.html
index e142b7ce..ce17ee82 100644
--- a/src/verify-app/index.html
+++ b/src/verify-app/index.html
@@ -7,6 +7,7 @@
+