Files
sencho/frontend/src/context/AuthContext.tsx
T
Anso d586ce393a feat(audit-log): add configurable retention, export, Auditor role, and enhanced filtering (#258)
- Configurable retention: audit_retention_days setting (1-365 days, default 90)
  replaces hardcoded 90-day retention, exposed in Settings > Data Retention
- Export: one-click CSV/JSON export of filtered audit data via new
  GET /api/audit-log/export endpoint (capped at 10,000 entries)
- Auditor role: read-only role with system:audit permission for viewing
  and exporting audit logs without admin privileges (Admiral tier)
- Enhanced filtering: full-text search across summaries/paths/usernames,
  date range picker, and expandable row details showing request path,
  IP address, node ID, and entry ID
2026-03-29 20:18:51 -04:00

215 lines
6.4 KiB
TypeScript

import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
type AppStatus = 'loading' | 'needsSetup' | 'notAuthenticated' | '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 }>;
ssoLdapLogin: (username: string, password: string) => Promise<{ success: boolean; error?: string }>;
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;
}
// Then check if already authenticated
const authResponse = await fetch('/api/auth/check', {
credentials: 'include',
});
if (authResponse.ok) {
const data = await authResponse.json();
setUser(data.user ?? null);
setAppStatus('authenticated');
// Fetch effective permissions
try {
const permsRes = await fetch('/api/permissions/me', { credentials: 'include' });
if (permsRes.ok) {
setPermissions(await permsRes.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 }> => {
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) {
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 }> => {
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) {
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 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,
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;
}