Imporve the role based forntend protected roles

This commit is contained in:
Urtzi Alfaro
2025-09-09 07:32:59 +02:00
parent ddb75f8e55
commit 5269a083b6
15 changed files with 286 additions and 91 deletions

View File

@@ -54,6 +54,12 @@ export class TenantService {
return apiClient.get<TenantAccessResponse>(`${this.baseUrl}/${tenantId}/access/${userId}`); return apiClient.get<TenantAccessResponse>(`${this.baseUrl}/${tenantId}/access/${userId}`);
} }
async getCurrentUserTenantAccess(tenantId: string): Promise<TenantAccessResponse> {
// This will use the current user from the auth token
// The backend endpoint handles extracting user_id from the token
return apiClient.get<TenantAccessResponse>(`${this.baseUrl}/${tenantId}/my-access`);
}
// Search & Discovery // Search & Discovery
async searchTenants(params: TenantSearchParams): Promise<TenantResponse[]> { async searchTenants(params: TenantSearchParams): Promise<TenantResponse[]> {
const queryParams = new URLSearchParams(); const queryParams = new URLSearchParams();

View File

@@ -2,6 +2,8 @@
* Auth API Types - Mirror backend schemas * Auth API Types - Mirror backend schemas
*/ */
import type { GlobalUserRole } from '../../types/roles';
export interface User { export interface User {
id: string; id: string;
email: string; email: string;
@@ -14,7 +16,7 @@ export interface User {
language?: string; language?: string;
timezone?: string; timezone?: string;
tenant_id?: string; tenant_id?: string;
role?: string; role?: GlobalUserRole;
} }
export interface UserRegistration { export interface UserRegistration {
@@ -65,7 +67,7 @@ export interface UserResponse {
language?: string; language?: string;
timezone?: string; timezone?: string;
tenant_id?: string; tenant_id?: string;
role?: string; role?: GlobalUserRole;
} }
export interface UserUpdate { export interface UserUpdate {
@@ -79,7 +81,7 @@ export interface TokenVerificationResponse {
valid: boolean; valid: boolean;
user_id?: string; user_id?: string;
email?: string; email?: string;
role?: string; role?: GlobalUserRole;
exp?: number; exp?: number;
message?: string; message?: string;
} }

View File

@@ -2,6 +2,8 @@
* Tenant API Types - Mirror backend schemas * Tenant API Types - Mirror backend schemas
*/ */
import type { TenantRole } from '../../types/roles';
export interface BakeryRegistration { export interface BakeryRegistration {
name: string; name: string;
address: string; address: string;
@@ -38,8 +40,10 @@ export interface TenantResponse {
export interface TenantAccessResponse { export interface TenantAccessResponse {
has_access: boolean; has_access: boolean;
role?: string; role?: TenantRole;
permissions?: string[]; permissions?: string[];
membership_id?: string;
joined_at?: string;
} }
export interface TenantUpdate { export interface TenantUpdate {
@@ -62,7 +66,7 @@ export interface TenantMemberResponse {
id: string; id: string;
tenant_id: string; tenant_id: string;
user_id: string; user_id: string;
role: string; role: TenantRole;
is_active: boolean; is_active: boolean;
joined_at: string; joined_at: string;
user_email?: string; user_email?: string;

View File

@@ -36,7 +36,7 @@ export const LoginForm: React.FC<LoginFormProps> = ({
const { login } = useAuthActions(); const { login } = useAuthActions();
const isLoading = useAuthLoading(); const isLoading = useAuthLoading();
const error = useAuthError(); const error = useAuthError();
const { showToast } = useToast(); const { success, error: showError } = useToast();
// Auto-focus on email field when component mounts // Auto-focus on email field when component mounts
useEffect(() => { useEffect(() => {
@@ -76,17 +76,13 @@ export const LoginForm: React.FC<LoginFormProps> = ({
try { try {
await login(credentials.email, credentials.password); await login(credentials.email, credentials.password);
showToast({ success('¡Bienvenido de vuelta a tu panadería!', {
type: 'success', title: 'Sesión iniciada correctamente'
title: 'Sesión iniciada correctamente',
message: '¡Bienvenido de vuelta a tu panadería!'
}); });
onSuccess?.(); onSuccess?.();
} catch (err) { } catch (err) {
showToast({ showError(error || 'Email o contraseña incorrectos. Verifica tus credenciales.', {
type: 'error', title: 'Error al iniciar sesión'
title: 'Error al iniciar sesión',
message: error || 'Email o contraseña incorrectos. Verifica tus credenciales.'
}); });
} }
}; };

View File

@@ -2,6 +2,7 @@ import React, { useState, useCallback, forwardRef, useMemo } from 'react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { useLocation, useNavigate } from 'react-router-dom'; import { useLocation, useNavigate } from 'react-router-dom';
import { useAuthUser, useIsAuthenticated } from '../../../stores'; import { useAuthUser, useIsAuthenticated } from '../../../stores';
import { useCurrentTenantAccess } from '../../../stores/tenant.store';
import { getNavigationRoutes, canAccessRoute, ROUTES } from '../../../router/routes.config'; import { getNavigationRoutes, canAccessRoute, ROUTES } from '../../../router/routes.config';
import { Button } from '../../ui'; import { Button } from '../../ui';
import { Badge } from '../../ui'; import { Badge } from '../../ui';
@@ -127,6 +128,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
const navigate = useNavigate(); const navigate = useNavigate();
const user = useAuthUser(); const user = useAuthUser();
const isAuthenticated = useIsAuthenticated(); const isAuthenticated = useIsAuthenticated();
const currentTenantAccess = useCurrentTenantAccess();
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set()); const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
const [hoveredItem, setHoveredItem] = useState<string | null>(null); const [hoveredItem, setHoveredItem] = useState<string | null>(null);
@@ -160,8 +162,12 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
...item, // Create a shallow copy to avoid mutation ...item, // Create a shallow copy to avoid mutation
children: item.children ? filterItemsByPermissions(item.children) : item.children children: item.children ? filterItemsByPermissions(item.children) : item.children
})).filter(item => { })).filter(item => {
const userRoles = user.role ? [user.role] : []; // Combine global and tenant roles for comprehensive access control
const userPermissions: string[] = user?.permissions || []; const globalUserRoles = user.role ? [user.role as string] : [];
const tenantRole = currentTenantAccess?.role;
const tenantRoles = tenantRole ? [tenantRole as string] : [];
const allUserRoles = [...globalUserRoles, ...tenantRoles];
const tenantPermissions = currentTenantAccess?.permissions || [];
const hasAccess = !item.requiredPermissions && !item.requiredRoles || const hasAccess = !item.requiredPermissions && !item.requiredRoles ||
canAccessRoute( canAccessRoute(
@@ -171,8 +177,8 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
requiredPermissions: item.requiredPermissions requiredPermissions: item.requiredPermissions
} as any, } as any,
isAuthenticated, isAuthenticated,
userRoles, allUserRoles,
userPermissions tenantPermissions
); );
return hasAccess; return hasAccess;
@@ -180,7 +186,7 @@ export const Sidebar = forwardRef<SidebarRef, SidebarProps>(({
}; };
return filterItemsByPermissions(navigationItems); return filterItemsByPermissions(navigationItems);
}, [navigationItems, isAuthenticated, user]); }, [navigationItems, isAuthenticated, user, currentTenantAccess]);
// Handle item click // Handle item click
const handleItemClick = useCallback((item: NavigationItem) => { const handleItemClick = useCallback((item: NavigationItem) => {

View File

@@ -5,6 +5,7 @@
import React from 'react'; import React from 'react';
import { Navigate, useLocation } from 'react-router-dom'; import { Navigate, useLocation } from 'react-router-dom';
import { useAuthUser, useIsAuthenticated, useAuthLoading } from '../stores'; import { useAuthUser, useIsAuthenticated, useAuthLoading } from '../stores';
import { useCurrentTenantAccess, useTenantPermissions } from '../stores/tenant.store';
import { RouteConfig, canAccessRoute, ROUTES } from './routes.config'; import { RouteConfig, canAccessRoute, ROUTES } from './routes.config';
interface ProtectedRouteProps { interface ProtectedRouteProps {
@@ -126,6 +127,8 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
const user = useAuthUser(); const user = useAuthUser();
const isAuthenticated = useIsAuthenticated(); const isAuthenticated = useIsAuthenticated();
const isLoading = useAuthLoading(); const isLoading = useAuthLoading();
const currentTenantAccess = useCurrentTenantAccess();
const { hasPermission } = useTenantPermissions();
const location = useLocation(); const location = useLocation();
// Note: Onboarding routes are now properly protected and require authentication // Note: Onboarding routes are now properly protected and require authentication
@@ -160,16 +163,21 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
} }
// Get user roles and permissions // Get user roles and permissions
const userRoles = user?.role ? [user.role] : []; const globalUserRoles = user?.role ? [user.role] : [];
const userPermissions: string[] = []; const tenantRole = currentTenantAccess?.role;
const tenantRoles = tenantRole ? [tenantRole] : [];
// Combine global and tenant roles for comprehensive access control
const allUserRoles = [...globalUserRoles, ...tenantRoles];
const tenantPermissions = currentTenantAccess?.permissions || [];
// Check if user can access this route // Check if user can access this route
const canAccess = canAccessRoute(route, isAuthenticated, userRoles, userPermissions); const canAccess = canAccessRoute(route, isAuthenticated, allUserRoles, tenantPermissions);
if (!canAccess) { if (!canAccess) {
// Check if it's a permission issue or role issue // Check if it's a permission issue or role issue
const hasRequiredRoles = !route.requiredRoles || const hasRequiredRoles = !route.requiredRoles ||
route.requiredRoles.some(role => userRoles.includes(role)); route.requiredRoles.some(role => allUserRoles.includes(role as string));
if (!hasRequiredRoles) { if (!hasRequiredRoles) {
return <UnauthorizedPage />; return <UnauthorizedPage />;
@@ -211,22 +219,29 @@ export const useRouteAccess = (route: RouteConfig) => {
const user = useAuthUser(); const user = useAuthUser();
const isAuthenticated = useIsAuthenticated(); const isAuthenticated = useIsAuthenticated();
const isLoading = useAuthLoading(); const isLoading = useAuthLoading();
const currentTenantAccess = useCurrentTenantAccess();
const userRoles = user?.role ? [user.role] : []; const globalUserRoles = user?.role ? [user.role as string] : [];
const userPermissions: string[] = []; const tenantRole = currentTenantAccess?.role;
const tenantRoles = tenantRole ? [tenantRole as string] : [];
const allUserRoles = [...globalUserRoles, ...tenantRoles];
const tenantPermissions = currentTenantAccess?.permissions || [];
const canAccess = canAccessRoute(route, isAuthenticated, userRoles, userPermissions); const canAccess = canAccessRoute(route, isAuthenticated, allUserRoles, tenantPermissions);
return { return {
canAccess, canAccess,
isLoading, isLoading,
isAuthenticated, isAuthenticated,
userRoles, globalUserRoles,
userPermissions, tenantRoles,
allUserRoles,
tenantPermissions,
currentTenantAccess,
hasRequiredRoles: !route.requiredRoles || hasRequiredRoles: !route.requiredRoles ||
route.requiredRoles.some(role => userRoles.includes(role)), route.requiredRoles.some(role => allUserRoles.includes(role as string)),
hasRequiredPermissions: !route.requiredPermissions || hasRequiredPermissions: !route.requiredPermissions ||
route.requiredPermissions.every(permission => userPermissions.includes(permission)), route.requiredPermissions.every(permission => tenantPermissions.includes(permission)),
}; };
}; };
@@ -248,21 +263,25 @@ export const ConditionalRender: React.FC<ConditionalRenderProps> = ({
}) => { }) => {
const user = useAuthUser(); const user = useAuthUser();
const isAuthenticated = useIsAuthenticated(); const isAuthenticated = useIsAuthenticated();
const currentTenantAccess = useCurrentTenantAccess();
if (!isAuthenticated || !user) { if (!isAuthenticated || !user) {
return <>{fallback}</>; return <>{fallback}</>;
} }
const userRoles = user.role ? [user.role] : []; const globalUserRoles = user.role ? [user.role as string] : [];
const userPermissions: string[] = []; const tenantRole = currentTenantAccess?.role;
const tenantRoles = tenantRole ? [tenantRole as string] : [];
const allUserRoles = [...globalUserRoles, ...tenantRoles];
const tenantPermissions = currentTenantAccess?.permissions || [];
// Check roles // Check roles
let hasRoles = true; let hasRoles = true;
if (requiredRoles.length > 0) { if (requiredRoles.length > 0) {
if (requireAll) { if (requireAll) {
hasRoles = requiredRoles.every(role => userRoles.includes(role)); hasRoles = requiredRoles.every(role => allUserRoles.includes(role as string));
} else { } else {
hasRoles = requiredRoles.some(role => userRoles.includes(role)); hasRoles = requiredRoles.some(role => allUserRoles.includes(role as string));
} }
} }
@@ -270,9 +289,9 @@ export const ConditionalRender: React.FC<ConditionalRenderProps> = ({
let hasPermissions = true; let hasPermissions = true;
if (requiredPermissions.length > 0) { if (requiredPermissions.length > 0) {
if (requireAll) { if (requireAll) {
hasPermissions = requiredPermissions.every(permission => userPermissions.includes(permission)); hasPermissions = requiredPermissions.every(permission => tenantPermissions.includes(permission));
} else { } else {
hasPermissions = requiredPermissions.some(permission => userPermissions.includes(permission)); hasPermissions = requiredPermissions.some(permission => tenantPermissions.includes(permission));
} }
} }
@@ -283,11 +302,11 @@ export const ConditionalRender: React.FC<ConditionalRenderProps> = ({
return <>{fallback}</>; return <>{fallback}</>;
}; };
// Route guard for admin-only routes // Route guard for admin-only routes (global admin or tenant owner/admin)
export const AdminRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const AdminRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return ( return (
<ConditionalRender <ConditionalRender
requiredRoles={['admin', 'super_admin']} requiredRoles={['admin', 'super_admin', 'owner']}
requireAll={false} requireAll={false}
fallback={<UnauthorizedPage />} fallback={<UnauthorizedPage />}
> >
@@ -296,11 +315,24 @@ export const AdminRoute: React.FC<{ children: React.ReactNode }> = ({ children }
); );
}; };
// Route guard for manager-level routes // Route guard for manager-level routes (global admin/manager or tenant admin/owner)
export const ManagerRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => { export const ManagerRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return ( return (
<ConditionalRender <ConditionalRender
requiredRoles={['admin', 'super_admin', 'manager']} requiredRoles={['admin', 'super_admin', 'manager', 'owner']}
requireAll={false}
fallback={<UnauthorizedPage />}
>
{children}
</ConditionalRender>
);
};
// Route guard for tenant owner-only routes
export const OwnerRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
return (
<ConditionalRender
requiredRoles={['super_admin', 'owner']}
requireAll={false} requireAll={false}
fallback={<UnauthorizedPage />} fallback={<UnauthorizedPage />}
> >

View File

@@ -2,6 +2,8 @@
* Route configuration for the bakery management application * Route configuration for the bakery management application
*/ */
import { ROLE_COMBINATIONS } from '../types/roles';
export interface RouteConfig { export interface RouteConfig {
path: string; path: string;
name: string; name: string;
@@ -286,7 +288,7 @@ export const routesConfig: RouteConfig[] = [
title: 'Analytics', title: 'Analytics',
icon: 'sales', icon: 'sales',
requiresAuth: true, requiresAuth: true,
requiredRoles: ['admin', 'manager'], requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
showInNavigation: true, showInNavigation: true,
children: [ children: [
{ {
@@ -296,7 +298,7 @@ export const routesConfig: RouteConfig[] = [
title: 'Pronósticos', title: 'Pronósticos',
icon: 'forecasting', icon: 'forecasting',
requiresAuth: true, requiresAuth: true,
requiredRoles: ['admin', 'manager'], requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
showInNavigation: true, showInNavigation: true,
showInBreadcrumbs: true, showInBreadcrumbs: true,
}, },
@@ -307,7 +309,7 @@ export const routesConfig: RouteConfig[] = [
title: 'Análisis de Ventas', title: 'Análisis de Ventas',
icon: 'sales', icon: 'sales',
requiresAuth: true, requiresAuth: true,
requiredRoles: ['admin', 'manager'], requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
showInNavigation: true, showInNavigation: true,
showInBreadcrumbs: true, showInBreadcrumbs: true,
}, },
@@ -318,7 +320,7 @@ export const routesConfig: RouteConfig[] = [
title: 'Análisis de Rendimiento', title: 'Análisis de Rendimiento',
icon: 'sales', icon: 'sales',
requiresAuth: true, requiresAuth: true,
requiredRoles: ['admin', 'manager'], requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
showInNavigation: true, showInNavigation: true,
showInBreadcrumbs: true, showInBreadcrumbs: true,
}, },
@@ -329,7 +331,7 @@ export const routesConfig: RouteConfig[] = [
title: 'Insights de IA', title: 'Insights de IA',
icon: 'forecasting', icon: 'forecasting',
requiresAuth: true, requiresAuth: true,
requiredRoles: ['admin', 'manager'], requiredRoles: ROLE_COMBINATIONS.MANAGEMENT_ACCESS,
showInNavigation: true, showInNavigation: true,
showInBreadcrumbs: true, showInBreadcrumbs: true,
}, },
@@ -363,7 +365,7 @@ export const routesConfig: RouteConfig[] = [
title: 'Configuración de Panadería', title: 'Configuración de Panadería',
icon: 'settings', icon: 'settings',
requiresAuth: true, requiresAuth: true,
requiredRoles: ['admin'], requiredRoles: ROLE_COMBINATIONS.ADMIN_ACCESS,
showInNavigation: true, showInNavigation: true,
showInBreadcrumbs: true, showInBreadcrumbs: true,
}, },
@@ -374,7 +376,7 @@ export const routesConfig: RouteConfig[] = [
title: 'Gestión de Equipo', title: 'Gestión de Equipo',
icon: 'settings', icon: 'settings',
requiresAuth: true, requiresAuth: true,
requiredRoles: ['admin', 'manager'], requiredRoles: ROLE_COMBINATIONS.ADMIN_ACCESS,
showInNavigation: true, showInNavigation: true,
showInBreadcrumbs: true, showInBreadcrumbs: true,
}, },
@@ -385,7 +387,7 @@ export const routesConfig: RouteConfig[] = [
title: 'Suscripción y Facturación', title: 'Suscripción y Facturación',
icon: 'credit-card', icon: 'credit-card',
requiresAuth: true, requiresAuth: true,
requiredRoles: ['admin', 'owner'], requiredRoles: ROLE_COMBINATIONS.ADMIN_ACCESS,
showInNavigation: true, showInNavigation: true,
showInBreadcrumbs: true, showInBreadcrumbs: true,
}, },

View File

@@ -1,5 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware'; import { persist, createJSONStorage } from 'zustand/middleware';
import { GLOBAL_USER_ROLES, type GlobalUserRole } from '../types/roles';
export interface User { export interface User {
id: string; id: string;
@@ -13,7 +14,7 @@ export interface User {
language?: string; language?: string;
timezone?: string; timezone?: string;
tenant_id?: string; tenant_id?: string;
role?: string; role?: GlobalUserRole;
} }
export interface AuthState { export interface AuthState {
@@ -191,15 +192,22 @@ export const useAuthStore = create<AuthState>()(
set({ isLoading: loading }); set({ isLoading: loading });
}, },
// Permission helpers - Simplified for backend compatibility // Permission helpers - Global user permissions only
hasPermission: (_permission: string): boolean => { hasPermission: (_permission: string): boolean => {
const { user } = get(); const { user } = get();
if (!user || !user.is_active) return false; if (!user || !user.is_active) return false;
// Admin has all permissions // Super admin and admin have all global permissions
if (user.role === 'admin') return true; if (user.role === GLOBAL_USER_ROLES.SUPER_ADMIN || user.role === GLOBAL_USER_ROLES.ADMIN) {
return true;
}
// Basic role-based permissions // Manager has limited permissions
if (user.role === GLOBAL_USER_ROLES.MANAGER) {
return ['user_management', 'system_settings'].includes(_permission);
}
// Regular users have basic permissions
return false; return false;
}, },
@@ -212,14 +220,15 @@ export const useAuthStore = create<AuthState>()(
const { user } = get(); const { user } = get();
if (!user || !user.is_active) return false; if (!user || !user.is_active) return false;
// Role-based access control // Global role-based access control (system-wide)
switch (user.role) { switch (user.role) {
case 'admin': case GLOBAL_USER_ROLES.SUPER_ADMIN:
case GLOBAL_USER_ROLES.ADMIN:
return true; return true;
case 'manager': case GLOBAL_USER_ROLES.MANAGER:
return ['inventory', 'production', 'sales', 'reports'].includes(resource); return ['users', 'system'].includes(resource);
case 'user': case GLOBAL_USER_ROLES.USER:
return ['inventory', 'sales'].includes(resource) && action === 'read'; return action === 'read';
default: default:
return false; return false;
} }

View File

@@ -1,12 +1,14 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware'; import { persist, createJSONStorage } from 'zustand/middleware';
import { tenantService, type TenantResponse } from '../api'; import { tenantService, type TenantResponse, type TenantAccessResponse } from '../api';
import { useAuthUser } from './auth.store'; import { useAuthUser } from './auth.store';
import { TENANT_ROLES, GLOBAL_USER_ROLES } from '../types/roles';
export interface TenantState { export interface TenantState {
// State // State
currentTenant: TenantResponse | null; currentTenant: TenantResponse | null;
availableTenants: TenantResponse[] | null; availableTenants: TenantResponse[] | null;
currentTenantAccess: TenantAccessResponse | null;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
@@ -14,6 +16,7 @@ export interface TenantState {
setCurrentTenant: (tenant: TenantResponse) => void; setCurrentTenant: (tenant: TenantResponse) => void;
switchTenant: (tenantId: string) => Promise<boolean>; switchTenant: (tenantId: string) => Promise<boolean>;
loadUserTenants: () => Promise<void>; loadUserTenants: () => Promise<void>;
loadCurrentTenantAccess: () => Promise<void>;
clearTenants: () => void; clearTenants: () => void;
clearError: () => void; clearError: () => void;
setLoading: (loading: boolean) => void; setLoading: (loading: boolean) => void;
@@ -29,14 +32,17 @@ export const useTenantStore = create<TenantState>()(
// Initial state // Initial state
currentTenant: null, currentTenant: null,
availableTenants: null, availableTenants: null,
currentTenantAccess: null,
isLoading: false, isLoading: false,
error: null, error: null,
// Actions // Actions
setCurrentTenant: (tenant: TenantResponse) => { setCurrentTenant: (tenant: TenantResponse) => {
set({ currentTenant: tenant }); set({ currentTenant: tenant, currentTenantAccess: null });
// Update API client with new tenant ID // Update API client with new tenant ID
tenantService.setCurrentTenant(tenant); tenantService.setCurrentTenant(tenant);
// Load tenant access info
get().loadCurrentTenantAccess();
}, },
switchTenant: async (tenantId: string): Promise<boolean> => { switchTenant: async (tenantId: string): Promise<boolean> => {
@@ -116,10 +122,25 @@ export const useTenantStore = create<TenantState>()(
} }
}, },
loadCurrentTenantAccess: async (): Promise<void> => {
try {
const { currentTenant } = get();
if (!currentTenant) return;
const accessInfo = await tenantService.getCurrentUserTenantAccess(currentTenant.id);
set({ currentTenantAccess: accessInfo });
} catch (error) {
// Don't set error state for access loading failures - just log
console.warn('Failed to load tenant access:', error);
set({ currentTenantAccess: null });
}
},
clearTenants: () => { clearTenants: () => {
set({ set({
currentTenant: null, currentTenant: null,
availableTenants: null, availableTenants: null,
currentTenantAccess: null,
error: null, error: null,
}); });
tenantService.clearCurrentTenant(); tenantService.clearCurrentTenant();
@@ -135,33 +156,34 @@ export const useTenantStore = create<TenantState>()(
// Permission helpers (migrated from BakeryContext) // Permission helpers (migrated from BakeryContext)
hasPermission: (permission: string): boolean => { hasPermission: (permission: string): boolean => {
const { currentTenant } = get(); const { currentTenant, currentTenantAccess } = get();
if (!currentTenant) return false; if (!currentTenant || !currentTenantAccess || !currentTenantAccess.has_access) {
return false;
}
// Get user to determine role within this tenant // Check if user has specific permission in their tenant permissions array
const authState = JSON.parse(localStorage.getItem('auth-storage') || '{}')?.state; if (currentTenantAccess.permissions?.includes(permission)) {
const user = authState?.user; return true;
}
// Admin role has all permissions // Check if user has broader permissions that include this one
if (user?.role === 'admin') return true; if (currentTenantAccess.permissions?.includes('*') ||
currentTenantAccess.permissions?.includes('admin')) {
return true;
}
// TODO: Implement proper tenant-based permissions // Role-based fallback for common permissions based on tenant role
// For now, use basic role-based permissions const tenantRole = currentTenantAccess.role;
switch (user?.role) { switch (tenantRole) {
case 'admin': case TENANT_ROLES.OWNER:
case TENANT_ROLES.ADMIN:
return true; return true;
case 'manager': case TENANT_ROLES.MEMBER:
return ['inventory', 'production', 'sales', 'reports'].some(resource => // Members can read and write but not delete or manage users
permission.startsWith(resource) return !permission.includes('delete') && !permission.includes('admin');
); case TENANT_ROLES.VIEWER:
case 'baker': // Viewers can only read
return ['production', 'inventory'].some(resource => return permission.includes('read') || permission.includes('view');
permission.startsWith(resource)
) && !permission.includes(':delete');
case 'staff':
return ['inventory', 'sales'].some(resource =>
permission.startsWith(resource)
) && permission.includes(':read');
default: default:
return false; return false;
} }
@@ -185,6 +207,7 @@ export const useTenantStore = create<TenantState>()(
partialize: (state) => ({ partialize: (state) => ({
currentTenant: state.currentTenant, currentTenant: state.currentTenant,
availableTenants: state.availableTenants, availableTenants: state.availableTenants,
currentTenantAccess: state.currentTenantAccess,
}), }),
onRehydrateStorage: () => (state) => { onRehydrateStorage: () => (state) => {
// Initialize API client with stored tenant when store rehydrates // Initialize API client with stored tenant when store rehydrates
@@ -201,6 +224,7 @@ export const useTenantStore = create<TenantState>()(
// Selectors for common use cases // Selectors for common use cases
export const useCurrentTenant = () => useTenantStore((state) => state.currentTenant); export const useCurrentTenant = () => useTenantStore((state) => state.currentTenant);
export const useAvailableTenants = () => useTenantStore((state) => state.availableTenants); export const useAvailableTenants = () => useTenantStore((state) => state.availableTenants);
export const useCurrentTenantAccess = () => useTenantStore((state) => state.currentTenantAccess);
export const useTenantLoading = () => useTenantStore((state) => state.isLoading); export const useTenantLoading = () => useTenantStore((state) => state.isLoading);
export const useTenantError = () => useTenantStore((state) => state.error); export const useTenantError = () => useTenantStore((state) => state.error);
@@ -209,6 +233,7 @@ export const useTenantActions = () => useTenantStore((state) => ({
setCurrentTenant: state.setCurrentTenant, setCurrentTenant: state.setCurrentTenant,
switchTenant: state.switchTenant, switchTenant: state.switchTenant,
loadUserTenants: state.loadUserTenants, loadUserTenants: state.loadUserTenants,
loadCurrentTenantAccess: state.loadCurrentTenantAccess,
clearTenants: state.clearTenants, clearTenants: state.clearTenants,
clearError: state.clearError, clearError: state.clearError,
setLoading: state.setLoading, setLoading: state.setLoading,
@@ -224,6 +249,7 @@ export const useTenantPermissions = () => useTenantStore((state) => ({
export const useTenant = () => { export const useTenant = () => {
const currentTenant = useCurrentTenant(); const currentTenant = useCurrentTenant();
const availableTenants = useAvailableTenants(); const availableTenants = useAvailableTenants();
const currentTenantAccess = useCurrentTenantAccess();
const isLoading = useTenantLoading(); const isLoading = useTenantLoading();
const error = useTenantError(); const error = useTenantError();
const actions = useTenantActions(); const actions = useTenantActions();
@@ -232,6 +258,7 @@ export const useTenant = () => {
return { return {
currentTenant, currentTenant,
availableTenants, availableTenants,
currentTenantAccess,
isLoading, isLoading,
error, error,
...actions, ...actions,

View File

@@ -0,0 +1,83 @@
/**
* Role Types - Must match backend role definitions exactly
*/
// Global User Roles (Auth Service)
export const GLOBAL_USER_ROLES = {
USER: 'user',
ADMIN: 'admin',
MANAGER: 'manager',
SUPER_ADMIN: 'super_admin',
} as const;
// Tenant-Specific Roles (Tenant Service)
export const TENANT_ROLES = {
OWNER: 'owner',
ADMIN: 'admin',
MEMBER: 'member',
VIEWER: 'viewer',
} as const;
// Combined role types
export type GlobalUserRole = typeof GLOBAL_USER_ROLES[keyof typeof GLOBAL_USER_ROLES];
export type TenantRole = typeof TENANT_ROLES[keyof typeof TENANT_ROLES];
export type Role = GlobalUserRole | TenantRole;
// Role hierarchy for permission checking
export const ROLE_HIERARCHY = {
// Global roles (highest to lowest)
global: [
GLOBAL_USER_ROLES.SUPER_ADMIN,
GLOBAL_USER_ROLES.ADMIN,
GLOBAL_USER_ROLES.MANAGER,
GLOBAL_USER_ROLES.USER,
],
// Tenant roles (highest to lowest)
tenant: [
TENANT_ROLES.OWNER,
TENANT_ROLES.ADMIN,
TENANT_ROLES.MEMBER,
TENANT_ROLES.VIEWER,
],
} as const;
// Permission helper functions
export const hasGlobalRole = (userRole: string, requiredRole: GlobalUserRole): boolean => {
const userIndex = ROLE_HIERARCHY.global.indexOf(userRole as GlobalUserRole);
const requiredIndex = ROLE_HIERARCHY.global.indexOf(requiredRole);
return userIndex !== -1 && requiredIndex !== -1 && userIndex <= requiredIndex;
};
export const hasTenantRole = (userRole: string, requiredRole: TenantRole): boolean => {
const userIndex = ROLE_HIERARCHY.tenant.indexOf(userRole as TenantRole);
const requiredIndex = ROLE_HIERARCHY.tenant.indexOf(requiredRole);
return userIndex !== -1 && requiredIndex !== -1 && userIndex <= requiredIndex;
};
export const hasAnyRole = (userRoles: string[], requiredRoles: Role[]): boolean => {
return requiredRoles.some(requiredRole => userRoles.includes(requiredRole));
};
// Common role combinations for easy reuse
export const ROLE_COMBINATIONS = {
// Administrative access (global admin or tenant owner)
ADMIN_ACCESS: [GLOBAL_USER_ROLES.ADMIN, GLOBAL_USER_ROLES.SUPER_ADMIN, TENANT_ROLES.OWNER],
// Management access (admin + manager + tenant admin)
MANAGEMENT_ACCESS: [
GLOBAL_USER_ROLES.ADMIN,
GLOBAL_USER_ROLES.SUPER_ADMIN,
GLOBAL_USER_ROLES.MANAGER,
TENANT_ROLES.OWNER,
TENANT_ROLES.ADMIN,
],
// Owner-only access (super admin or tenant owner)
OWNER_ACCESS: [GLOBAL_USER_ROLES.SUPER_ADMIN, TENANT_ROLES.OWNER],
// Basic access (any authenticated user with any role)
BASIC_ACCESS: [
...Object.values(GLOBAL_USER_ROLES),
...Object.values(TENANT_ROLES),
],
} as const;

View File

@@ -132,7 +132,7 @@ class SecurityManager:
if "role" in user_data: if "role" in user_data:
payload["role"] = user_data["role"] payload["role"] = user_data["role"]
else: else:
payload["role"] = "user" # Default role if not specified payload["role"] = "admin" # Default role if not specified
logger.debug(f"Creating access token with payload keys: {list(payload.keys())}") logger.debug(f"Creating access token with payload keys: {list(payload.keys())}")

View File

@@ -18,7 +18,7 @@ class UserRegistration(BaseModel):
password: str = Field(..., min_length=8, max_length=128) password: str = Field(..., min_length=8, max_length=128)
full_name: str = Field(..., min_length=1, max_length=255) full_name: str = Field(..., min_length=1, max_length=255)
tenant_name: Optional[str] = Field(None, max_length=255) tenant_name: Optional[str] = Field(None, max_length=255)
role: Optional[str] = Field("user", pattern=r'^(user|admin|manager)$') role: Optional[str] = Field("admin", pattern=r'^(user|admin|manager|super_admin)$')
class UserLogin(BaseModel): class UserLogin(BaseModel):
"""User login request""" """User login request"""
@@ -56,7 +56,7 @@ class UserData(BaseModel):
is_verified: bool is_verified: bool
created_at: str # ISO format datetime string created_at: str # ISO format datetime string
tenant_id: Optional[str] = None tenant_id: Optional[str] = None
role: Optional[str] = "user" role: Optional[str] = "admin"
class TokenResponse(BaseModel): class TokenResponse(BaseModel):
""" """
@@ -101,7 +101,7 @@ class UserResponse(BaseModel):
language: Optional[str] = None # ✅ Added missing field language: Optional[str] = None # ✅ Added missing field
timezone: Optional[str] = None # ✅ Added missing field timezone: Optional[str] = None # ✅ Added missing field
tenant_id: Optional[str] = None tenant_id: Optional[str] = None
role: Optional[str] = "user" role: Optional[str] = "admin"
class Config: class Config:
from_attributes = True # ✅ Enable ORM mode for SQLAlchemy objects from_attributes = True # ✅ Enable ORM mode for SQLAlchemy objects
@@ -189,7 +189,7 @@ class UserContext(BaseModel):
user_id: str user_id: str
email: str email: str
tenant_id: Optional[str] = None tenant_id: Optional[str] = None
roles: list[str] = ["user"] roles: list[str] = ["admin"]
is_verified: bool = False is_verified: bool = False
class TokenClaims(BaseModel): class TokenClaims(BaseModel):

View File

@@ -55,7 +55,9 @@ class EnhancedAuthService:
raise ValueError("Password does not meet security requirements") raise ValueError("Password does not meet security requirements")
# Create user data # Create user data
user_role = user_data.role if user_data.role else "user" # Default to admin role for first-time registrations during onboarding flow
# Users creating their own bakery should have admin privileges
user_role = user_data.role if user_data.role else "admin"
hashed_password = SecurityManager.hash_password(user_data.password) hashed_password = SecurityManager.hash_password(user_data.password)
create_data = { create_data = {

View File

@@ -413,7 +413,7 @@ class EnhancedUserService:
user_repo = UserRepository(User, session) user_repo = UserRepository(User, session)
# Validate role # Validate role
valid_roles = ["user", "admin", "super_admin"] valid_roles = ["user", "admin", "manager", "super_admin"]
if new_role not in valid_roles: if new_role not in valid_roles:
raise ValidationError(f"Invalid role. Must be one of: {valid_roles}") raise ValidationError(f"Invalid role. Must be one of: {valid_roles}")

View File

@@ -67,6 +67,32 @@ async def register_bakery_enhanced(
detail="Bakery registration failed" detail="Bakery registration failed"
) )
@router.get("/tenants/{tenant_id}/my-access", response_model=TenantAccessResponse)
async def get_current_user_tenant_access(
tenant_id: UUID = Path(..., description="Tenant ID"),
current_user: Dict[str, Any] = Depends(get_current_user_dep)
):
"""Get current user's access to tenant with role and permissions"""
try:
# Create tenant service directly
from app.core.config import settings
database_manager = create_database_manager(settings.DATABASE_URL, "tenant-service")
tenant_service = EnhancedTenantService(database_manager)
access_info = await tenant_service.verify_user_access(current_user["user_id"], str(tenant_id))
return access_info
except Exception as e:
logger.error("Current user access verification failed",
user_id=current_user["user_id"],
tenant_id=str(tenant_id),
error=str(e))
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Access verification failed"
)
@router.get("/tenants/{tenant_id}/access/{user_id}", response_model=TenantAccessResponse) @router.get("/tenants/{tenant_id}/access/{user_id}", response_model=TenantAccessResponse)
async def verify_tenant_access_enhanced( async def verify_tenant_access_enhanced(
tenant_id: UUID = Path(..., description="Tenant ID"), tenant_id: UUID = Path(..., description="Tenant ID"),