Files
sencho/frontend/src/context/AuthContext.tsx
T
Anso 405f9cd921 perf(frontend): parallelize auth bootstrap fetches (#826)
* perf(frontend): parallelize auth bootstrap fetches

AuthContext.checkAuth previously chained three sequential fetches:
/api/auth/status, then /api/auth/check, then /api/permissions/me.
The status check has to come first because its response decides
whether to early-return on the setup or mfa-pending path. The next
two are independent for an authenticated session and were costing
an extra round-trip on every cold load.

Run /api/auth/check and /api/permissions/me in parallel via
Promise.all. The permissions request is wasted on the rare
not-authenticated path (cookie expired or logged out) but that
trade-off is worth saving the round-trip on the common success
path. The .catch(() => null) on the permissions fetch and the
inner try/catch around .json() preserve the original fault
tolerance: a network failure or malformed body falls back to no
permissions data, with the global role still authoritative.

* fix(frontend): commit auth state without waiting on /permissions/me

The previous Promise.all awaited both fetches before calling
setAppStatus('authenticated'), so a slow /api/permissions/me would
delay the dashboard commit relative to the old serial code. The E2E
deploy-feedback tests at e2e/deploy-log-panel.spec.ts:168 and :236
race the dashboard render after page.reload() and were timing out
waiting for GET /api/stacks/<name> because the click target was not
yet wired when the slower permissions response held up state.

Keep both requests in parallel on the wire, but await only the auth
check before committing state. The permissions promise resolves in
the background and updates state via void permsPromise.then() so the
non-critical request never gates the bootstrap.
2026-04-28 09:02:08 -04:00

279 lines
9.1 KiB
TypeScript

import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
type AppStatus = 'loading' | 'needsSetup' | 'notAuthenticated' | 'mfaChallenge' | 'authenticated';
export type UserRole = 'admin' | 'viewer' | 'deployer' | 'node-admin' | 'auditor';
export type PermissionAction =
| 'stack:read' | 'stack:edit' | 'stack:deploy' | 'stack:create' | 'stack:delete'
| 'node:read' | 'node:manage'
| 'system:settings' | 'system:users' | 'system:license' | 'system:webhooks'
| 'system:tokens' | 'system:console' | 'system:audit' | 'system:registries';
interface UserInfo {
username: string;
role: UserRole;
}
interface PermissionsData {
globalRole: UserRole;
globalPermissions: PermissionAction[];
scopedPermissions: Record<string, PermissionAction[]>;
isAdmiral: boolean;
}
interface AuthContextType {
appStatus: AppStatus;
isAuthenticated: boolean;
needsSetup: boolean;
user: UserInfo | null;
isAdmin: boolean;
permissions: PermissionsData | null;
can: (action: PermissionAction, resourceType?: string, resourceId?: string) => boolean;
login: (username: string, password: string) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>;
ssoLdapLogin: (username: string, password: string) => Promise<{ success: boolean; error?: string; mfaRequired?: boolean }>;
submitMfa: (code: string, opts?: { isBackupCode?: boolean }) => Promise<{ success: boolean; error?: string; retryAfter?: number }>;
cancelMfa: () => Promise<void>;
logout: () => Promise<void>;
completeSetup: () => void;
checkAuth: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | undefined>(undefined);
export function AuthProvider({ children }: { children: ReactNode }) {
const [appStatus, setAppStatus] = useState<AppStatus>('loading');
const [user, setUser] = useState<UserInfo | null>(null);
const [permissions, setPermissions] = useState<PermissionsData | null>(null);
const checkAuth = async () => {
try {
// First check if setup is needed
const statusResponse = await fetch('/api/auth/status', {
credentials: 'include',
});
const statusData = await statusResponse.json();
if (statusData.needsSetup) {
setAppStatus('needsSetup');
setUser(null);
setPermissions(null);
return;
}
// If a partial-auth (mfa_pending) cookie is active, route to the
// challenge screen. This handles reloads in the middle of the flow,
// including post-OIDC redirects.
if (statusData.mfaPending) {
setUser(null);
setPermissions(null);
setAppStatus('mfaChallenge');
return;
}
// Auth check and permissions fetch are independent for an authenticated
// session, so fire both on the wire at the same time. Await only the
// auth check before committing app state — otherwise a slow
// /permissions/me delays setAppStatus('authenticated') and races
// post-reload UI that expects the dashboard to commit promptly. The
// permissions promise updates state in the background when it resolves.
const authPromise = fetch('/api/auth/check', { credentials: 'include' });
const permsPromise = fetch('/api/permissions/me', { credentials: 'include' }).catch(() => null);
const authResponse = await authPromise;
if (authResponse.ok) {
const data = await authResponse.json();
setUser(data.user ?? null);
setAppStatus('authenticated');
void permsPromise.then(async (res) => {
if (res?.ok) {
try {
setPermissions(await res.json());
} catch {
// Permissions fetch is non-critical — fallback to global role only
}
}
});
} else {
setUser(null);
setPermissions(null);
setAppStatus('notAuthenticated');
}
} catch {
setUser(null);
setPermissions(null);
setAppStatus('notAuthenticated');
}
};
useEffect(() => {
checkAuth();
const handleUnauthorized = () => setAppStatus('notAuthenticated');
window.addEventListener('sencho-unauthorized', handleUnauthorized);
return () => window.removeEventListener('sencho-unauthorized', handleUnauthorized);
}, []);
const can = useCallback((action: PermissionAction, resourceType?: string, resourceId?: string): boolean => {
if (!permissions) return false;
// Admins always have full access
if (permissions.globalRole === 'admin') return true;
// Check global role permissions
if (permissions.globalPermissions.includes(action)) return true;
// Check scoped permissions
if (resourceType && resourceId) {
const key = `${resourceType}:${resourceId}`;
return permissions.scopedPermissions[key]?.includes(action) ?? false;
}
return false;
}, [permissions]);
const login = async (username: string, password: string): Promise<{ success: boolean; error?: string; mfaRequired?: boolean }> => {
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
credentials: 'include',
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (response.ok && data.success) {
if (data.mfaRequired) {
// Password was accepted but a second factor is required. Pull the
// updated /auth/status so the app routes to the challenge screen.
await checkAuth();
return { success: true, mfaRequired: true };
}
setAppStatus('authenticated');
// Fetch user info (role, username) so isAdmin is correct immediately
await checkAuth();
return { success: true };
} else {
return { success: false, error: data.error || 'Login failed' };
}
} catch {
return { success: false, error: 'Network error. Please try again.' };
}
};
const ssoLdapLogin = async (username: string, password: string): Promise<{ success: boolean; error?: string; mfaRequired?: boolean }> => {
try {
const response = await fetch('/api/auth/sso/ldap', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (response.ok && data.success) {
if (data.mfaRequired) {
await checkAuth();
return { success: true, mfaRequired: true };
}
setAppStatus('authenticated');
await checkAuth();
return { success: true };
} else {
return { success: false, error: data.error || 'LDAP login failed' };
}
} catch {
return { success: false, error: 'Network error. Please try again.' };
}
};
const submitMfa = async (
code: string,
opts: { isBackupCode?: boolean } = {},
): Promise<{ success: boolean; error?: string; retryAfter?: number }> => {
try {
const response = await fetch('/api/auth/login/mfa', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ code, isBackupCode: opts.isBackupCode === true }),
});
const data = await response.json().catch(() => ({}));
if (response.ok && data.success) {
await checkAuth();
return { success: true };
}
const retryAfter = typeof data.retryAfter === 'number' ? data.retryAfter : undefined;
return { success: false, error: data.error || 'Verification failed', retryAfter };
} catch {
return { success: false, error: 'Network error. Please try again.' };
}
};
const cancelMfa = async () => {
try {
await fetch('/api/auth/logout', { method: 'POST', credentials: 'include' });
} catch (error) {
console.error('Cancel MFA error:', error);
} finally {
setUser(null);
setPermissions(null);
setAppStatus('notAuthenticated');
}
};
const logout = async () => {
try {
await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
});
} catch (error) {
console.error('Logout error:', error);
} finally {
setUser(null);
setPermissions(null);
setAppStatus('notAuthenticated');
}
};
const completeSetup = () => {
// Fetch user info so isAdmin is correct after setup
checkAuth();
};
return (
<AuthContext.Provider value={{
appStatus,
isAuthenticated: appStatus === 'authenticated',
needsSetup: appStatus === 'needsSetup',
user,
isAdmin: user?.role === 'admin',
permissions,
can,
login,
ssoLdapLogin,
submitMfa,
cancelMfa,
logout,
completeSetup,
checkAuth
}}>
{children}
</AuthContext.Provider>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error('useAuth must be used within an AuthProvider');
}
return context;
}