Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/config/sidebarPermissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
46 changes: 24 additions & 22 deletions src/features/auth/components/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 && (
<motion.div variants={itemVariants}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, mt: 1, mb: 0.5 }}>
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.75, overflow: 'hidden' }}>
<EmailOutlined sx={{ fontSize: 18, color: 'rgba(0,0,0,0.54)' }} />
<strong style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: '#1a1a2e' }}>
{getValues('email')}
</strong>
</Box>
<Button
size="small"
variant="text"
disabled={formBusy}
onClick={() => {
setPasswordRevealed(false)
setLoginError(null)
setTimeout(() => setFocus('email'), 0)
}}
sx={{ textTransform: 'none', minWidth: 0, color: 'primary.main', fontWeight: 600 }}
>
{t('auth.changeIdentity')}
</Button>
<Box sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.75, overflow: 'hidden', mt: 1, mb: 0.5 }}>
<EmailOutlined sx={{ fontSize: 18, color: 'rgba(0,0,0,0.54)' }} />
<strong style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', color: '#1a1a2e' }}>
{getValues('email')}
</strong>
</Box>
{/* paired username field so the password field has an
associated identifier for a11y + password managers */}
<input
type="text"
name="username"
autoComplete="username"
value={getValues('email')}
readOnly
aria-hidden="true"
tabIndex={-1}
style={{ position: 'absolute', width: 1, height: 1, opacity: 0, pointerEvents: 'none' }}
/>
</motion.div>
)}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,18 @@ export default function MethodCardsGrid({
<Typography
variant="body2"
color="text.secondary"
sx={{ mb: 2, minHeight: 40 }}
sx={{
mb: 2,
// Fixed 2-line height so EVERY card is the same
// height. `minHeight` let longer descriptions push
// some cards taller than others → the user-reported
// "different-sized boxes".
height: 40,
overflow: 'hidden',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{t(config.description)}
</Typography>
Expand Down
14 changes: 14 additions & 0 deletions src/features/auth/components/steps/PasswordStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
<>
<motion.div variants={itemVariants}>
<Typography
variant="body2"
Expand All @@ -94,6 +95,19 @@ export default function PasswordStep({ onSubmit, loading, error, presetEmail }:
</span>
</Typography>
</motion.div>
{/* paired username field for a11y + password managers (email is shown
read-only above, not as an input, in identifier-first mode) */}
<input
type="text"
name="username"
autoComplete="username"
value={presetEmail ?? ''}
readOnly
aria-hidden="true"
tabIndex={-1}
style={{ position: 'absolute', width: 1, height: 1, opacity: 0, pointerEvents: 'none' }}
/>
</>
) : (
<motion.div variants={itemVariants}>
<Controller
Expand Down
7 changes: 4 additions & 3 deletions src/features/biometric-consent/BiometricConsentSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ export default function BiometricConsentSection({
/>
)}

{consents.length > 0 && (
{consents.some((c) => c.tenantId !== currentTenantId) && (
<>
<Divider sx={{ my: 2 }} />
<Typography
Expand All @@ -110,7 +110,8 @@ export default function BiometricConsentSection({
>
{t('biometricConsent.grantedTenants')}
</Typography>
{consents.map((c) => (
{/* current tenant already shown by the toggle above; list only OTHER orgs */}
{consents.filter((c) => c.tenantId !== currentTenantId).map((c) => (
<FormControlLabel
key={c.id}
sx={{ display: 'flex' }}
Expand All @@ -125,7 +126,7 @@ export default function BiometricConsentSection({
}
label={
<Typography variant="body2">
{c.tenantId}
{c.tenantName ?? c.tenantId}
{c.method ? ` · ${c.method}` : ''}
</Typography>
}
Expand Down
5 changes: 4 additions & 1 deletion src/features/biometric-consent/useBiometricConsents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -40,7 +41,9 @@ export function useBiometricConsents() {
try {
const httpClient = container.get<IHttpClient>(TYPES.HttpClient)
const res = await httpClient.get<BiometricConsent[]>(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<ILogger>(TYPES.Logger)
logger.error('Failed to load biometric consents', e)
Expand Down
13 changes: 7 additions & 6 deletions src/features/biometric-puzzles/puzzles/FacePuzzle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions src/lib/ml/BlazeFaceDetector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
60 changes: 17 additions & 43 deletions src/pages/DeveloperPortalPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,6 @@ import {
Add,
ContentCopy,
Delete,
Visibility,
VisibilityOff,
Code,
CheckCircle,
Login,
Expand Down Expand Up @@ -109,8 +107,6 @@ export default function DeveloperPortalPage() {
// Credentials reveal dialog
const [credentialsApp, setCredentialsApp] = useState<OAuth2App | null>(null)

// View secret (not functional for existing apps — secret is hashed server-side)
const [visibleSecrets] = useState<Record<string, boolean>>({})

// Delete confirm
const [deleteTarget, setDeleteTarget] = useState<OAuth2App | null>(null)
Expand Down Expand Up @@ -254,34 +250,27 @@ export default function DeveloperPortalPage() {
}

// --- Quick Start code snippets ---
const scriptSnippet = `<!-- Auth widget SDK (replace with your build or CDN URL) -->
<script src="https://api.fivucsas.com/sdk/auth-widget.js"></script>
const scriptSnippet = `<!-- FIVUCSAS hosted-login SDK -->
<script src="https://verify.fivucsas.com/fivucsas-auth.js"></script>
<script>
FivucsasAuth.init({
clientId: 'YOUR_CLIENT_ID',
const auth = new FivucsasAuth({ clientId: 'YOUR_CLIENT_ID' });

// Redirects the user to verify.fivucsas.com/login (PKCE). After they
// authenticate, the browser returns to redirectUri with ?code=…&state=…
auth.loginRedirect({
redirectUri: 'https://yourapp.com/callback',
scopes: ['openid', 'profile', 'email'],
scope: 'openid profile email',
});
</script>`

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 (
<PageTransition>
Expand Down Expand Up @@ -380,7 +369,7 @@ if (code) {
<Button
variant="contained"
endIcon={<OpenInNew sx={{ fontSize: 16 }} />}
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' } }}
Expand Down Expand Up @@ -520,21 +509,6 @@ if (code) {
</TableCell>
<TableCell align="right">
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 0.5 }}>
<Tooltip title={t('developerPortal.viewSecret')}>
<span>
<IconButton
size="small"
disabled
aria-label={t('common.aria.view')}
>
{visibleSecrets[app.id] ? (
<VisibilityOff fontSize="small" />
) : (
<Visibility fontSize="small" />
)}
</IconButton>
</span>
</Tooltip>
<Tooltip title={t('common.delete')}>
<IconButton
size="small"
Expand Down
9 changes: 7 additions & 2 deletions src/pages/MyProfilePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
Warning,
} from '@mui/icons-material'
import { useAuth } from '@features/auth/hooks/useAuth'
import { METHOD_CONFIGS } from '@features/auth/components/enrollment/methodConfigs'
import { useUserEnrollments } from '@features/enrollments/hooks/useEnrollments'
import { useTranslation } from 'react-i18next'
import { format } from 'date-fns'
Expand Down Expand Up @@ -293,7 +294,11 @@ export default function MyProfilePage() {
const daysSinceRegistration = user?.createdAt
? Math.floor((Date.now() - new Date(user.createdAt).getTime()) / (1000 * 60 * 60 * 24))
: 0
const loginCount = activityLogs.filter((l) => 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
Expand Down Expand Up @@ -653,7 +658,7 @@ export default function MyProfilePage() {
<SecurityRow
icon={<Fingerprint />}
label={t('myProfile.enrolledMethodsLabel')}
value={`${enrolledMethods.length} / 9`}
value={`${enrolledMethods.length} / ${METHOD_CONFIGS.length}`}
color={enrolledMethods.length > 0 ? 'success' : 'warning'}
/>

Expand Down
1 change: 1 addition & 0 deletions src/verify-app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<meta name="description" content="FIVUCSAS Verify — hosted login page and embeddable auth widget. Face, voice, fingerprint, NFC ID, TOTP, WebAuthn passkeys, SMS/email OTP, OAuth 2.0 / OpenID Connect. Used by tenants to authenticate users without writing biometric or session code." />
<meta name="keywords" content="FIVUCSAS, hosted login, auth widget, OAuth 2.0, OpenID Connect, face authentication, biometric authentication, passkey, WebAuthn, NFC ID, MFA" />
<link rel="canonical" href="https://verify.fivucsas.com/" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />

<meta property="og:type" content="website" />
<meta property="og:url" content="https://verify.fivucsas.com/" />
Expand Down
Loading