mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 18:56:53 +00:00
feat(rbac): add Deployer & Node Admin roles with scoped permissions (Team Pro) (#253)
* feat(rbac): add Deployer & Node Admin roles with scoped permissions (Team Pro) Add intermediate RBAC roles gated to Team Pro tier: - Deployer: can deploy/restart/stop/start stacks but cannot edit compose files, delete stacks, or access system settings - Node Admin: full stack and node management within scope, no system settings access - Scoped permissions: assign roles per-stack or per-node for fine-grained access control - Permission engine with checkPermission/requirePermission guards replacing requireAdmin on stack/node routes - Frontend can() function with /api/permissions/me endpoint for client-side permission checks - User management UI updated with 4-role selector and scoped permission editor - Documentation updated with permission matrix, scoped permission docs, and screenshots * fix(rbac): remove unused RoleAssignment import to fix lint error
This commit is contained in:
@@ -1,10 +1,25 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react';
|
||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||
|
||||
type AppStatus = 'loading' | 'needsSetup' | 'notAuthenticated' | 'authenticated';
|
||||
|
||||
export type UserRole = 'admin' | 'viewer' | 'deployer' | 'node-admin';
|
||||
|
||||
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: 'admin' | 'viewer';
|
||||
role: UserRole;
|
||||
}
|
||||
|
||||
interface PermissionsData {
|
||||
globalRole: UserRole;
|
||||
globalPermissions: PermissionAction[];
|
||||
scopedPermissions: Record<string, PermissionAction[]>;
|
||||
isTeamPro: boolean;
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
@@ -13,6 +28,8 @@ interface AuthContextType {
|
||||
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>;
|
||||
@@ -25,6 +42,7 @@ 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 {
|
||||
@@ -37,6 +55,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
if (statusData.needsSetup) {
|
||||
setAppStatus('needsSetup');
|
||||
setUser(null);
|
||||
setPermissions(null);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -49,12 +68,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
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');
|
||||
}
|
||||
};
|
||||
@@ -66,6 +97,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
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', {
|
||||
@@ -125,6 +174,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
console.error('Logout error:', error);
|
||||
} finally {
|
||||
setUser(null);
|
||||
setPermissions(null);
|
||||
setAppStatus('notAuthenticated');
|
||||
}
|
||||
};
|
||||
@@ -141,6 +191,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
needsSetup: appStatus === 'needsSetup',
|
||||
user,
|
||||
isAdmin: user?.role === 'admin',
|
||||
permissions,
|
||||
can,
|
||||
login,
|
||||
ssoLdapLogin,
|
||||
logout,
|
||||
|
||||
Reference in New Issue
Block a user