Merge pull request 'feat: Implement multi-tenancy support in middleware and security layers' (#15) from feature/security into development
Reviewed-on: ADUANASOFT/anexo76#15
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
*/
|
||||
import { getToken } from './auth';
|
||||
import { browser } from '$app/environment';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
// Normalize API_BASE_URL to remove trailing slash
|
||||
const API_BASE_URL = (import.meta.env.VITE_API_URL || '').replace(/\/+$/, '');
|
||||
@@ -172,6 +173,23 @@ async function fetchApi<T = any>(
|
||||
|
||||
// Si recibimos 401 o 403 y no es el endpoint de refresh, intentar refrescar el token
|
||||
if ((response.status === 401 || response.status === 403) && !endpoint.includes('/auth/refresh') && retryCount === 0) {
|
||||
// Si es 403 (Forbidden), mostrar toast de permisos insuficientes
|
||||
if (response.status === 403) {
|
||||
if (browser) {
|
||||
toast.error('No tienes permisos para realizar esta acción', {
|
||||
duration: 4000,
|
||||
description: 'Contacta a tu administrador si crees que esto es un error'
|
||||
});
|
||||
}
|
||||
// Retornar el error 403 sin intentar refresh
|
||||
const data = await response.json();
|
||||
return {
|
||||
error: data.detail || 'No tienes permisos para realizar esta acción',
|
||||
status: 403
|
||||
};
|
||||
}
|
||||
|
||||
// Si es 401, intentar refrescar el token
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
|
||||
@@ -1,26 +1,7 @@
|
||||
import { api } from '$lib/api';
|
||||
import type { ApiResponse } from '$lib/api';
|
||||
|
||||
export interface CustomsBroker {
|
||||
type?: string | null;
|
||||
broker_key: string;
|
||||
name?: string | null;
|
||||
address?: string | null;
|
||||
postal_code?: string | null;
|
||||
city?: string | null;
|
||||
state?: string | null;
|
||||
phone?: string | null;
|
||||
fax?: string | null;
|
||||
email?: string | null;
|
||||
country?: string | null;
|
||||
tax_id?: string | null;
|
||||
personal_id?: string | null;
|
||||
position?: string | null;
|
||||
license: string;
|
||||
company?: string | null;
|
||||
contact?: string | null;
|
||||
tenant_id: string;
|
||||
company_id: string;
|
||||
export interface CustomsBroker {
|
||||
id: number;
|
||||
type?: string | null;
|
||||
broker_key: string;
|
||||
@@ -117,13 +98,14 @@ export const customsBrokersApi = {
|
||||
return api.post<CustomsBroker>(`/v1/a76/customs-brokers/?company_id=${companyId}`, data);
|
||||
},
|
||||
|
||||
update: (brokerKey: string, data: CreateCustomsBrokerData, companyId: string) => {
|
||||
return api.patch<CustomsBroker>(`/v1/a76/customs-brokers/${brokerKey}?company_id=${companyId}`, data);
|
||||
},
|
||||
/**
|
||||
* Actualiza la información de un agente aduanal
|
||||
*/
|
||||
update: (brokerKey: string, data: CreateCustomsBrokerData) => {
|
||||
const companyId = data.company_id;
|
||||
return api.put<CustomsBroker>(`/v1/a76/customs-brokers/${brokerKey}/?company_id=${companyId}`, data);
|
||||
},
|
||||
|
||||
delete: (brokerKey: string, companyId: string) => {
|
||||
return api.delete<void>(`/v1/a76/customs-brokers/${brokerKey}?company_id=${companyId}`);
|
||||
},
|
||||
/**
|
||||
* Elimina un agente aduanal
|
||||
*/
|
||||
@@ -131,13 +113,7 @@ export const customsBrokersApi = {
|
||||
return api.delete<CustomsBroker>(`/v1/a76/customs-brokers/${brokerKey}/?company_id=${companyId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Actualiza la información de un agente aduanal
|
||||
*/
|
||||
update: (brokerKey: string, data: CreateCustomsBrokerData) => {
|
||||
const companyId = data.company_id;
|
||||
return api.put<CustomsBroker>(`/v1/a76/customs-brokers/${brokerKey}/?company_id=${companyId}`, data);
|
||||
},
|
||||
|
||||
|
||||
updateVU: (brokerKey: string, data: CustomsBrokerVU, companyId: string) => {
|
||||
return api.put<CustomsBrokerVU>(`/v1/a76/customs-broker-vu/${brokerKey}?company_id=${companyId}`, data);
|
||||
|
||||
8
frontend/src/lib/api/dashboard/admin/index.ts
Normal file
8
frontend/src/lib/api/dashboard/admin/index.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Exportaciones centralizadas de APIs de administración
|
||||
*/
|
||||
|
||||
export * from './permissions';
|
||||
export * from './roles';
|
||||
export * from './role-permissions';
|
||||
export * from './user-roles';
|
||||
110
frontend/src/lib/api/dashboard/admin/permissions.ts
Normal file
110
frontend/src/lib/api/dashboard/admin/permissions.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* API para gestión de permisos del sistema
|
||||
*/
|
||||
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export interface Permission {
|
||||
id: number;
|
||||
code: string;
|
||||
description?: string;
|
||||
module: string;
|
||||
action: string;
|
||||
is_active: boolean;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface CreatePermissionData {
|
||||
code?: string;
|
||||
description?: string;
|
||||
module: string;
|
||||
action: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdatePermissionData {
|
||||
code?: string;
|
||||
description?: string;
|
||||
module?: string;
|
||||
action?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface PermissionListResponse {
|
||||
items: Permission[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export const permissionsAPI = {
|
||||
/**
|
||||
* Listar permisos con filtros
|
||||
*/
|
||||
async list(params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
module?: string;
|
||||
action?: string;
|
||||
is_active?: boolean;
|
||||
search?: string;
|
||||
}): Promise<PermissionListResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params?.page) queryParams.set('page', params.page.toString());
|
||||
if (params?.page_size) queryParams.set('page_size', params.page_size.toString());
|
||||
if (params?.module) queryParams.set('module', params.module);
|
||||
if (params?.action) queryParams.set('action', params.action);
|
||||
if (params?.search) queryParams.set('search', params.search);
|
||||
const query = queryParams.toString();
|
||||
const response = await api.get(`/v1/core/permissions${query ? '?' + query : ''}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtener un permiso por ID
|
||||
*/
|
||||
async getById(id: number): Promise<Permission> {
|
||||
const response = await api.get(`/v1/core/permissions/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Crear un nuevo permiso
|
||||
*/
|
||||
async create(data: CreatePermissionData): Promise<Permission> {
|
||||
const response = await api.post('/v1/core/permissions', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Actualizar un permiso
|
||||
*/
|
||||
async update(id: number, data: UpdatePermissionData): Promise<Permission> {
|
||||
const response = await api.put(`/v1/core/permissions/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Eliminar un permiso
|
||||
*/
|
||||
async delete(id: number): Promise<void> {
|
||||
await api.delete(`/v1/core/permissions/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtener módulos únicos
|
||||
*/
|
||||
async getModules(): Promise<string[]> {
|
||||
const response = await api.get('/v1/core/permissions/modules');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtener acciones únicas
|
||||
*/
|
||||
async getActions(): Promise<string[]> {
|
||||
const response = await api.get('/v1/core/permissions/actions');
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
76
frontend/src/lib/api/dashboard/admin/role-permissions.ts
Normal file
76
frontend/src/lib/api/dashboard/admin/role-permissions.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* API para gestión de permisos de roles
|
||||
*/
|
||||
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export interface RolePermission {
|
||||
id: number;
|
||||
company_role_id: number;
|
||||
permission_id: number;
|
||||
granted_at?: string;
|
||||
granted_by?: number;
|
||||
tenant_id: number;
|
||||
permission?: {
|
||||
id: number;
|
||||
code: string;
|
||||
module: string;
|
||||
action: string;
|
||||
description?: string;
|
||||
is_active: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AssignPermissionData {
|
||||
permission_id: number;
|
||||
}
|
||||
|
||||
export interface RolePermissionsResponse {
|
||||
role_id: number;
|
||||
permissions: RolePermission[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const rolePermissionsAPI = {
|
||||
/**
|
||||
* Listar todos los permisos asignados a un rol
|
||||
*/
|
||||
async listByRole(roleId: number, companyId: number): Promise<RolePermissionsResponse> {
|
||||
const response = await api.get(`/v1/core/permissions/roles/${roleId}/permissions?company_id=${companyId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Asignar un permiso a un rol
|
||||
*/
|
||||
async assign(
|
||||
roleId: number,
|
||||
companyId: number,
|
||||
data: AssignPermissionData
|
||||
): Promise<RolePermission> {
|
||||
const response = await api.post(`/v1/core/permissions/roles/${roleId}/permissions?company_id=${companyId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Remover un permiso de un rol
|
||||
*/
|
||||
async remove(roleId: number, permissionId: number, companyId: number): Promise<void> {
|
||||
await api.delete(`/v1/core/permissions/roles/${roleId}/permissions/${permissionId}?company_id=${companyId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Asignar múltiples permisos a un rol
|
||||
*/
|
||||
async assignMultiple(
|
||||
roleId: number,
|
||||
companyId: number,
|
||||
permissionIds: number[]
|
||||
): Promise<RolePermission[]> {
|
||||
const response = await api.post(
|
||||
`/v1/core/permissions/roles/${roleId}/permissions/batch?company_id=${companyId}`,
|
||||
{ permission_ids: permissionIds }
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
93
frontend/src/lib/api/dashboard/admin/roles.ts
Normal file
93
frontend/src/lib/api/dashboard/admin/roles.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* API para gestión de roles por compañía
|
||||
*/
|
||||
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export interface CompanyRole {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
is_active: boolean;
|
||||
company_id: number;
|
||||
tenant_id: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface CreateRoleData {
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateRoleData {
|
||||
name?: string;
|
||||
code?: string;
|
||||
description?: string;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export interface RoleListResponse {
|
||||
items: CompanyRole[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export const rolesAPI = {
|
||||
/**
|
||||
* Listar roles con filtros
|
||||
*/
|
||||
async list(
|
||||
companyId: number,
|
||||
params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
is_active?: boolean;
|
||||
search?: string;
|
||||
}
|
||||
): Promise<RoleListResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.set('company_id', companyId.toString());
|
||||
if (params?.page) queryParams.set('page', params.page.toString());
|
||||
if (params?.page_size) queryParams.set('page_size', params.page_size.toString());
|
||||
if (params?.is_active !== undefined) queryParams.set('is_active', params.is_active.toString());
|
||||
if (params?.search) queryParams.set('search', params.search);
|
||||
const response = await api.get(`/v1/core/permissions/roles?${queryParams.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtener un rol por ID
|
||||
*/
|
||||
async getById(id: number, companyId: number): Promise<CompanyRole> {
|
||||
const response = await api.get(`/v1/core/permissions/roles/${id}?company_id=${companyId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Crear un nuevo rol
|
||||
*/
|
||||
async create(companyId: number, data: CreateRoleData): Promise<CompanyRole> {
|
||||
const response = await api.post(`/v1/core/permissions/roles?company_id=${companyId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Actualizar un rol
|
||||
*/
|
||||
async update(id: number, companyId: number, data: UpdateRoleData): Promise<CompanyRole> {
|
||||
const response = await api.patch(`/v1/core/permissions/roles/${id}?company_id=${companyId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Eliminar un rol
|
||||
*/
|
||||
async delete(id: number, companyId: number): Promise<void> {
|
||||
await api.delete(`/v1/core/permissions/roles/${id}?company_id=${companyId}`);
|
||||
}
|
||||
};
|
||||
108
frontend/src/lib/api/dashboard/admin/user-permissions.ts
Normal file
108
frontend/src/lib/api/dashboard/admin/user-permissions.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* API para gestión de permisos individuales de usuario
|
||||
*/
|
||||
|
||||
import { api } from '$lib/api';
|
||||
import type { Permission } from './permissions';
|
||||
|
||||
export interface UserPermission {
|
||||
id: number;
|
||||
user_id: string;
|
||||
permission_id: number;
|
||||
company_id: number;
|
||||
tenant_id: number;
|
||||
is_granted: boolean;
|
||||
is_active: boolean;
|
||||
assigned_by?: string;
|
||||
expires_at?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
permission?: Permission;
|
||||
}
|
||||
|
||||
export interface AssignUserPermissionData {
|
||||
user_id: string;
|
||||
permission_id: number;
|
||||
is_granted?: boolean;
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
export interface UserPermissionsListResponse {
|
||||
items: UserPermission[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface EffectiveUserPermissions {
|
||||
user_id: string;
|
||||
company_id: number;
|
||||
role_permissions: Permission[];
|
||||
granted_permissions: Permission[];
|
||||
revoked_permissions: Permission[];
|
||||
effective_permissions: Permission[];
|
||||
}
|
||||
|
||||
export const userPermissionsAPI = {
|
||||
/**
|
||||
* Obtener permisos individuales de un usuario
|
||||
*/
|
||||
async getIndividual(userId: string, companyId: number): Promise<UserPermissionsListResponse> {
|
||||
const response = await api.get(
|
||||
`/v1/core/permissions/users/${userId}/permissions?company_id=${companyId}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtener permisos efectivos de un usuario (roles + individuales - revocados)
|
||||
*/
|
||||
async getEffective(userId: string, companyId: number): Promise<EffectiveUserPermissions> {
|
||||
const response = await api.get(
|
||||
`/v1/core/permissions/users/${userId}/permissions/effective?company_id=${companyId}`
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Asignar un permiso individual a un usuario
|
||||
*/
|
||||
async assign(
|
||||
userId: string,
|
||||
companyId: number,
|
||||
data: Omit<AssignUserPermissionData, 'user_id'>
|
||||
): Promise<UserPermission> {
|
||||
const response = await api.post(
|
||||
`/v1/core/permissions/users/${userId}/permissions?company_id=${companyId}`,
|
||||
data
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Conceder un permiso extra a un usuario
|
||||
*/
|
||||
async grant(userId: string, companyId: number, permissionId: number): Promise<UserPermission> {
|
||||
return this.assign(userId, companyId, {
|
||||
permission_id: permissionId,
|
||||
is_granted: true
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Revocar un permiso específico (aunque venga del rol)
|
||||
*/
|
||||
async revoke(userId: string, companyId: number, permissionId: number): Promise<UserPermission> {
|
||||
return this.assign(userId, companyId, {
|
||||
permission_id: permissionId,
|
||||
is_granted: false
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Eliminar un permiso individual
|
||||
*/
|
||||
async remove(userId: string, companyId: number, permissionId: number): Promise<void> {
|
||||
await api.delete(
|
||||
`/v1/core/permissions/users/${userId}/permissions/${permissionId}?company_id=${companyId}`
|
||||
);
|
||||
}
|
||||
};
|
||||
94
frontend/src/lib/api/dashboard/admin/user-roles.ts
Normal file
94
frontend/src/lib/api/dashboard/admin/user-roles.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* API para gestión de roles de usuarios
|
||||
*/
|
||||
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export interface UserRole {
|
||||
id: number;
|
||||
user_id: string;
|
||||
company_id: number;
|
||||
company_role_id: number;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
assigned_by?: string;
|
||||
company_role?: {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
description?: string;
|
||||
};
|
||||
user?: {
|
||||
id: number;
|
||||
username: string;
|
||||
email?: string;
|
||||
full_name?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface AssignUserRoleData {
|
||||
user_id: string;
|
||||
company_role_id: number;
|
||||
}
|
||||
|
||||
export interface UserRolesResponse {
|
||||
items: UserRole[];
|
||||
total: number;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
|
||||
export const userRolesAPI = {
|
||||
/**
|
||||
* Listar todos los roles asignados a usuarios
|
||||
*/
|
||||
async list(
|
||||
companyId: number,
|
||||
params?: {
|
||||
user_id?: string;
|
||||
company_role_id?: number;
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
}
|
||||
): Promise<UserRolesResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.set('company_id', companyId.toString());
|
||||
if (params?.user_id) queryParams.set('user_id', params.user_id);
|
||||
if (params?.company_role_id) queryParams.set('company_role_id', params.company_role_id.toString());
|
||||
if (params?.page) queryParams.set('page', params.page.toString());
|
||||
if (params?.page_size) queryParams.set('page_size', params.page_size.toString());
|
||||
const response = await api.get(`/v1/core/permissions/user-roles?${queryParams.toString()}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Listar roles de un usuario específico
|
||||
*/
|
||||
async listByUser(userId: number, companyId: number): Promise<UserRolesResponse> {
|
||||
const response = await api.get(`/v1/core/permissions/users/${userId}/roles?company_id=${companyId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Listar usuarios con un rol específico
|
||||
*/
|
||||
async listByRole(roleId: number, companyId: number): Promise<UserRolesResponse> {
|
||||
const response = await api.get(`/v1/core/permissions/roles/${roleId}/users?company_id=${companyId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Asignar un rol a un usuario
|
||||
*/
|
||||
async assign(companyId: number, data: AssignUserRoleData): Promise<UserRole> {
|
||||
const response = await api.post(`/v1/core/permissions/user-roles?company_id=${companyId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Remover un rol de un usuario
|
||||
*/
|
||||
async remove(userRoleId: number, companyId: number): Promise<void> {
|
||||
await api.delete(`/v1/core/permissions/user-roles/${userRoleId}?company_id=${companyId}`);
|
||||
}
|
||||
};
|
||||
@@ -62,8 +62,8 @@ export const usersAPI = {
|
||||
/**
|
||||
* Obtiene estadísticas de usuarios del tenant
|
||||
*/
|
||||
async getStats(): Promise<UserStats> {
|
||||
const response = await api.get<UserStats>('/v1/core/users/stats');
|
||||
async getStats(companyId: number): Promise<UserStats> {
|
||||
const response = await api.get<UserStats>(`/v1/core/users/stats?company_id=${companyId}`);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
@@ -73,17 +73,18 @@ export const usersAPI = {
|
||||
/**
|
||||
* Lista usuarios del tenant con paginación
|
||||
*/
|
||||
async list(params?: {
|
||||
async list(companyId: number, params?: {
|
||||
page?: number;
|
||||
page_size?: number;
|
||||
search?: string;
|
||||
}): Promise<UserListResponse> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.set('company_id', companyId.toString());
|
||||
if (params?.page) queryParams.set('page', params.page.toString());
|
||||
if (params?.page_size) queryParams.set('page_size', params.page_size.toString());
|
||||
if (params?.search) queryParams.set('search', params.search);
|
||||
|
||||
const endpoint = `/v1/core/users/${queryParams.toString() ? `?${queryParams}` : ''}`;
|
||||
const endpoint = `/v1/core/users/?${queryParams}`;
|
||||
const response = await api.get<UserListResponse>(endpoint);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
@@ -94,8 +95,8 @@ export const usersAPI = {
|
||||
/**
|
||||
* Obtiene un usuario específico
|
||||
*/
|
||||
async get(userId: string): Promise<User> {
|
||||
const response = await api.get<User>(`/v1/core/users/${userId}`);
|
||||
async get(userId: string, companyId: number): Promise<User> {
|
||||
const response = await api.get<User>(`/v1/core/users/${userId}?company_id=${companyId}`);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
@@ -105,8 +106,8 @@ export const usersAPI = {
|
||||
/**
|
||||
* Crea un nuevo usuario
|
||||
*/
|
||||
async create(data: CreateUserRequest): Promise<User> {
|
||||
const response = await api.post<User>('/v1/core/users/', data);
|
||||
async create(data: CreateUserRequest, companyId: number): Promise<User> {
|
||||
const response = await api.post<User>(`/v1/core/users/?company_id=${companyId}`, data);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
@@ -116,8 +117,8 @@ export const usersAPI = {
|
||||
/**
|
||||
* Actualiza un usuario existente
|
||||
*/
|
||||
async update(userId: string, data: UpdateUserRequest): Promise<User> {
|
||||
const response = await api.put<User>(`/v1/core/users/${userId}`, data);
|
||||
async update(userId: string, data: UpdateUserRequest, companyId: number): Promise<User> {
|
||||
const response = await api.put<User>(`/v1/core/users/${userId}?company_id=${companyId}`, data);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
@@ -127,8 +128,9 @@ export const usersAPI = {
|
||||
/**
|
||||
* Elimina un usuario
|
||||
*/
|
||||
async delete(userId: string, softDelete: boolean = true): Promise<void> {
|
||||
async delete(userId: string, companyId: number, softDelete: boolean = true): Promise<void> {
|
||||
const queryParams = new URLSearchParams();
|
||||
queryParams.set('company_id', companyId.toString());
|
||||
queryParams.set('soft_delete', softDelete.toString());
|
||||
|
||||
const response = await api.delete(`/v1/core/users/${userId}?${queryParams}`);
|
||||
@@ -140,8 +142,8 @@ export const usersAPI = {
|
||||
/**
|
||||
* Cambia la contraseña de un usuario
|
||||
*/
|
||||
async changePassword(userId: string, data: ChangePasswordRequest): Promise<void> {
|
||||
const response = await api.post(`/v1/core/users/${userId}/change-password`, data);
|
||||
async changePassword(userId: string, data: ChangePasswordRequest, companyId: number): Promise<void> {
|
||||
const response = await api.post(`/v1/core/users/${userId}/change-password?company_id=${companyId}`, data);
|
||||
if (response.error) {
|
||||
throw new Error(response.error);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
// Campo de comentario estatus
|
||||
comments_status: invoice.comments_status || '',
|
||||
// Campos que van en diferentes recursos pero se editan aquí
|
||||
transport_mode: invoice.logistics?.[0]?.transport_mode || null,
|
||||
transport_mode: invoice.logistics?.transport_mode || null,
|
||||
is_mixed: invoice.compliance_mx?.is_mixed || null,
|
||||
print_stamp: invoice.financials?.seal_value_2500 || false,
|
||||
rule_3121_parties_ii: false,
|
||||
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
GalleryVerticalEnd,
|
||||
LayoutDashboard,
|
||||
Package,
|
||||
Settings2,
|
||||
Settings2,
|
||||
Shield,
|
||||
Users,
|
||||
} from 'lucide-svelte';
|
||||
import * as m from "$lib/paraglide/messages.js";
|
||||
@@ -382,14 +383,12 @@ export function getSidebarData(): SidebarData {
|
||||
url: "/dashboard/customs_brokers",
|
||||
icon: BadgeCheck,
|
||||
items: [],
|
||||
},
|
||||
},
|
||||
{
|
||||
title: m["sidebar.reference_data.configuracion"](),
|
||||
url: "#",
|
||||
icon: Settings2,
|
||||
items: [
|
||||
|
||||
|
||||
{
|
||||
title: m["sidebar.reference_data.usuarios"](),
|
||||
url: "",
|
||||
|
||||
@@ -23,6 +23,9 @@
|
||||
|
||||
// URL completa del avatar
|
||||
let avatarUrl = $derived(getBackendAssetUrl(user.avatar) || '/avatars/default.jpg');
|
||||
|
||||
// Iniciales del usuario (2 primeras letras)
|
||||
let initials = $derived(user.name.slice(0, 2).toUpperCase());
|
||||
|
||||
// Estado reactivo del idioma actual
|
||||
let currentLocale = $derived(page.data.locale || 'en');
|
||||
@@ -107,7 +110,7 @@
|
||||
>
|
||||
<Avatar.Root class="size-8 rounded-lg">
|
||||
<Avatar.Image src={avatarUrl} alt={user.name} />
|
||||
<Avatar.Fallback class="rounded-lg">AS</Avatar.Fallback>
|
||||
<Avatar.Fallback class="rounded-lg">{initials}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">{user.name}</span>
|
||||
@@ -127,7 +130,7 @@
|
||||
<div class="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar.Root class="size-8 rounded-lg">
|
||||
<Avatar.Image src={avatarUrl} alt={user.name} />
|
||||
<Avatar.Fallback class="rounded-lg">AS</Avatar.Fallback>
|
||||
<Avatar.Fallback class="rounded-lg">{initials}</Avatar.Fallback>
|
||||
</Avatar.Root>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">{user.name}</span>
|
||||
|
||||
@@ -16,6 +16,11 @@
|
||||
? getBackendAssetUrl(companyStore.activeCompany.logo)
|
||||
: null
|
||||
);
|
||||
|
||||
// Iniciales de la compañía activa (2 primeras letras)
|
||||
let activeCompanyInitials = $derived(
|
||||
companyStore.activeCompany?.name?.slice(0, 2).toUpperCase() || 'CO'
|
||||
);
|
||||
|
||||
</script>
|
||||
|
||||
@@ -40,7 +45,7 @@
|
||||
onerror={(e) => { (e.currentTarget as HTMLImageElement).style.display = 'none'; }}
|
||||
/>
|
||||
{:else}
|
||||
<BuildingIcon class="size-4 text-white" />
|
||||
<span class="text-sm font-semibold text-white">{activeCompanyInitials}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight min-w-0">
|
||||
@@ -89,7 +94,7 @@
|
||||
class="size-full rounded object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<BuildingIcon class="size-3.5 shrink-0" />
|
||||
<span class="text-xs font-semibold">{company.name.slice(0, 2).toUpperCase()}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col min-w-0">
|
||||
|
||||
@@ -133,7 +133,7 @@ export async function refreshAccessToken(
|
||||
* @param cookies - Objeto de cookies de SvelteKit
|
||||
* @param fetch - Función fetch de SvelteKit
|
||||
* @param redirectUrl - URL a la que redirigir si falla la autenticación (opcional)
|
||||
* @param timeout - Timeout en milisegundos (default: 10000ms)
|
||||
* @param timeout - Timeout en milisegundos (default: 30000ms)
|
||||
*/
|
||||
export async function authenticatedFetch(
|
||||
endpoint: string,
|
||||
@@ -141,7 +141,7 @@ export async function authenticatedFetch(
|
||||
cookies: Cookies,
|
||||
fetch: typeof globalThis.fetch,
|
||||
redirectUrl?: string,
|
||||
timeout: number = 10000
|
||||
timeout: number = 30000
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const baseUrl = getServerApiUrl();
|
||||
@@ -180,6 +180,12 @@ export async function authenticatedFetch(
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
// Si es 403, no intentar refrescar - es un problema de permisos
|
||||
if (response.status === 403) {
|
||||
console.warn('🚫 [API] Acceso denegado (403):', endpoint);
|
||||
return response; // Retornar directamente para que el llamador maneje el error
|
||||
}
|
||||
|
||||
// Si es 401, intentar refrescar el token
|
||||
if (response.status === 401) {
|
||||
const newToken = await refreshAccessToken(cookies, fetch);
|
||||
@@ -346,3 +352,32 @@ export async function getActiveCompanyId(
|
||||
|
||||
return companyId || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper para manejar respuestas de API y convertir errores 403 en formato adecuado
|
||||
* para mostrar toasts en el cliente
|
||||
*/
|
||||
export async function handleApiResponse<T = any>(
|
||||
response: Response
|
||||
): Promise<{ data?: T; error?: { detail: string; status: number; isForbidden?: boolean } }> {
|
||||
if (response.ok) {
|
||||
// Para respuestas sin contenido (204)
|
||||
if (response.status === 204) {
|
||||
return { data: null as T };
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { data };
|
||||
}
|
||||
|
||||
// Manejar errores
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Error desconocido' }));
|
||||
|
||||
const error = {
|
||||
detail: errorData.detail || errorData.message || 'Error en la petición',
|
||||
status: response.status,
|
||||
isForbidden: response.status === 403
|
||||
};
|
||||
|
||||
return { error };
|
||||
}
|
||||
|
||||
84
frontend/src/lib/utils/error-handler.ts
Normal file
84
frontend/src/lib/utils/error-handler.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Utilidades para manejar errores de API en el cliente
|
||||
*/
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
export interface ApiError {
|
||||
detail: string;
|
||||
status: number;
|
||||
isForbidden?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maneja errores de API mostrando el toast apropiado
|
||||
* @param error - El error a manejar (puede ser un objeto ApiError o un string)
|
||||
* @returns true si se manejó un error, false si no había error
|
||||
*/
|
||||
export function handleApiError(error?: ApiError | string | null): boolean {
|
||||
if (!error) return false;
|
||||
|
||||
// Si es un string, convertirlo a objeto
|
||||
if (typeof error === 'string') {
|
||||
// Detectar si es un error 403
|
||||
if (error.includes('403') || error.toLowerCase().includes('forbidden')) {
|
||||
toast.error(error, {
|
||||
duration: 5000,
|
||||
description: 'No tienes permisos para realizar esta acción'
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otros errores en formato string
|
||||
toast.error(error, {
|
||||
duration: 4000
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Es un objeto ApiError
|
||||
if (error.isForbidden || error.status === 403) {
|
||||
// Mostrar el mensaje específico del backend si está disponible
|
||||
const message = error.detail || 'No tienes permisos para realizar esta acción';
|
||||
toast.error(message, {
|
||||
duration: 5000,
|
||||
description: error.detail ? 'Contacta a tu administrador si crees que esto es un error' : undefined
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (error.status === 401) {
|
||||
toast.error('Sesión expirada', {
|
||||
duration: 3000,
|
||||
description: 'Por favor, inicia sesión nuevamente'
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Otros errores
|
||||
toast.error(error.detail || 'Error en la operación', {
|
||||
duration: 4000
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook para usar en componentes Svelte con $effect
|
||||
* Muestra automáticamente un toast cuando hay un error
|
||||
*
|
||||
* Ejemplo de uso en +page.svelte:
|
||||
* ```svelte
|
||||
* <script lang="ts">
|
||||
* import { handleApiError } from '$lib/utils/error-handler';
|
||||
* let { data } = $props();
|
||||
*
|
||||
* $effect(() => {
|
||||
* handleApiError(data.error);
|
||||
* });
|
||||
* </script>
|
||||
* ```
|
||||
*/
|
||||
export function useErrorHandler(error?: ApiError | null) {
|
||||
if (error) {
|
||||
handleApiError(error);
|
||||
}
|
||||
}
|
||||
71
frontend/src/lib/utils/permissions.ts
Normal file
71
frontend/src/lib/utils/permissions.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Helper para verificar permisos del usuario
|
||||
* Basado en el sistema de permisos RBAC del backend
|
||||
*/
|
||||
|
||||
import { get } from 'svelte/store';
|
||||
import { page } from '$app/stores';
|
||||
|
||||
export interface UserPermission {
|
||||
module: string;
|
||||
action: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si el usuario tiene un permiso específico
|
||||
* @param module - El módulo (ej: 'invoices', 'pedimentos')
|
||||
* @param action - La acción (ej: 'create', 'update', 'delete', 'read')
|
||||
* @returns true si el usuario tiene el permiso, false si no
|
||||
*/
|
||||
export function hasPermission(module: string, action: string): boolean {
|
||||
// TODO: Implementar verificación real contra permisos del usuario
|
||||
// Por ahora retorna true para permitir desarrollo
|
||||
// En producción esto debe:
|
||||
// 1. Obtener los permisos del usuario desde el contexto/store
|
||||
// 2. Verificar si existe un permiso con module y action
|
||||
// 3. Retornar true/false basado en la verificación
|
||||
|
||||
console.warn('hasPermission() no está implementado - retornando true por defecto');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si el usuario tiene alguno de varios permisos
|
||||
* @param permissions - Array de permisos a verificar
|
||||
* @returns true si el usuario tiene al menos uno de los permisos
|
||||
*/
|
||||
export function hasAnyPermission(permissions: UserPermission[]): boolean {
|
||||
return permissions.some(p => hasPermission(p.module, p.action));
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifica si el usuario tiene todos los permisos especificados
|
||||
* @param permissions - Array de permisos a verificar
|
||||
* @returns true si el usuario tiene todos los permisos
|
||||
*/
|
||||
export function hasAllPermissions(permissions: UserPermission[]): boolean {
|
||||
return permissions.every(p => hasPermission(p.module, p.action));
|
||||
}
|
||||
|
||||
/**
|
||||
* Guard para proteger rutas basado en permisos
|
||||
* Puede ser usado en +page.server.ts o +layout.server.ts
|
||||
* @param module - El módulo requerido
|
||||
* @param action - La acción requerida
|
||||
* @returns objeto con allowed (boolean) y redirect (string opcional)
|
||||
*/
|
||||
export function requirePermission(module: string, action: string): {
|
||||
allowed: boolean;
|
||||
redirect?: string;
|
||||
} {
|
||||
const allowed = hasPermission(module, action);
|
||||
|
||||
if (!allowed) {
|
||||
return {
|
||||
allowed: false,
|
||||
redirect: '/dashboard?error=forbidden'
|
||||
};
|
||||
}
|
||||
|
||||
return { allowed: true };
|
||||
}
|
||||
@@ -2,8 +2,18 @@
|
||||
import '../app.css';
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import { Toaster } from 'svelte-sonner';
|
||||
import { page } from '$app/stores';
|
||||
import { handleApiError } from '$lib/utils/error-handler';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
// Detectar errores de CUALQUIER página (layout o page)
|
||||
$effect(() => {
|
||||
const pageData = $page.data as any;
|
||||
if (pageData?.error) {
|
||||
handleApiError(pageData.error);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
|
||||
@@ -30,7 +30,8 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
||||
return {
|
||||
authenticated: true,
|
||||
user: userData,
|
||||
companies // Pasar las compañías al cliente
|
||||
companies, // Pasar las compañías al cliente
|
||||
error: undefined // Agregar error opcional para compatibilidad con error-handler
|
||||
};
|
||||
} catch (error) {
|
||||
// Si es un redirect, re-lanzarlo sin tocar las cookies
|
||||
|
||||
@@ -12,12 +12,10 @@
|
||||
FileText,
|
||||
Users,
|
||||
Package,
|
||||
TruckIcon,
|
||||
Clock,
|
||||
BarChart3,
|
||||
PieChart,
|
||||
TrendingUp,
|
||||
AlertCircle,
|
||||
Calendar,
|
||||
CircleAlert,
|
||||
RefreshCw
|
||||
} from 'lucide-svelte';
|
||||
import type { DashboardStats } from '$lib/api/dashboard/types';
|
||||
@@ -28,6 +26,13 @@
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let greeting = $derived(() => {
|
||||
const hour = new Date().getHours();
|
||||
if (hour < 12) return 'Buenos días';
|
||||
if (hour < 18) return 'Buenas tardes';
|
||||
return 'Buenas noches';
|
||||
});
|
||||
|
||||
async function loadDashboardData() {
|
||||
if (!companyStore.activeCompany) {
|
||||
error = 'No hay compañía activa seleccionada';
|
||||
@@ -56,29 +61,39 @@
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p class="text-muted-foreground">
|
||||
{#snippet headerSection()}
|
||||
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight text-foreground">
|
||||
{greeting()}, equipo.
|
||||
</h1>
|
||||
<p class="text-muted-foreground mt-1 flex items-center gap-2">
|
||||
{#if stats?.company_name}
|
||||
{stats.company_name} - Sistema de gestión de comercio exterior
|
||||
<span class="font-semibold text-primary">{stats.company_name}</span>
|
||||
<span class="text-xs bg-muted px-2 py-0.5 rounded-full">Anexos 22/24/30</span>
|
||||
{:else}
|
||||
Sistema de gestión de comercio exterior conforme a Anexos 24, 30 y 22 del SAT
|
||||
Sistema de gestión de comercio exterior
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
<Button onclick={loadDashboardData} variant="outline" disabled={loading}>
|
||||
<RefreshCw class={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
{loading ? 'Cargando...' : 'Actualizar'}
|
||||
</Button>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" class="hidden sm:flex">
|
||||
<Calendar class="mr-2 h-4 w-4" />
|
||||
Hoy: {new Date().toLocaleDateString()}
|
||||
</Button>
|
||||
<Button onclick={loadDashboardData} variant="outline" disabled={loading}>
|
||||
<RefreshCw class={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
|
||||
{loading ? 'Cargando...' : 'Actualizar'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{@render headerSection()}
|
||||
|
||||
{#if error}
|
||||
<Alert.Root variant="destructive">
|
||||
<AlertCircle class="h-4 w-4" />
|
||||
<CircleAlert class="h-4 w-4" />
|
||||
<Alert.Title>Error</Alert.Title>
|
||||
<Alert.Description>{error}</Alert.Description>
|
||||
</Alert.Root>
|
||||
|
||||
36
frontend/src/routes/dashboard/admin/+layout.svelte
Normal file
36
frontend/src/routes/dashboard/admin/+layout.svelte
Normal file
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
import { page } from '$app/stores';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Shield, Key, Users, UserCog } from 'lucide-svelte';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
|
||||
const adminRoutes = [
|
||||
{ name: 'Roles', path: '/dashboard/admin/roles', icon: Shield }
|
||||
];
|
||||
|
||||
const currentPath = $derived($page.url.pathname);
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen bg-background">
|
||||
<div class="border-b">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex items-center gap-2 py-4 overflow-x-auto">
|
||||
{#each adminRoutes as route}
|
||||
{@const Icon = route.icon}
|
||||
<Button
|
||||
variant={currentPath === route.path ? 'default' : 'ghost'}
|
||||
href={route.path}
|
||||
class="flex items-center gap-2 whitespace-nowrap"
|
||||
>
|
||||
<Icon class="h-4 w-4" />
|
||||
{route.name}
|
||||
</Button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{@render children()}
|
||||
</div>
|
||||
529
frontend/src/routes/dashboard/admin/roles/+page.svelte
Normal file
529
frontend/src/routes/dashboard/admin/roles/+page.svelte
Normal file
@@ -0,0 +1,529 @@
|
||||
<script lang="ts">
|
||||
import { rolesAPI, type CompanyRole } from '$lib/api/dashboard/admin/roles';
|
||||
import {
|
||||
rolePermissionsAPI,
|
||||
type RolePermission
|
||||
} from '$lib/api/dashboard/admin/role-permissions';
|
||||
import { permissionsAPI, type Permission } from '$lib/api/dashboard/admin/permissions';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Textarea } from '$lib/components/ui/textarea';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow
|
||||
} from '$lib/components/ui/table';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger
|
||||
} from '$lib/components/ui/select';
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import { Plus, Trash2, Shield, Key, Pencil } from 'lucide-svelte';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let roles = $state<CompanyRole[]>([]);
|
||||
let selectedRole = $state<CompanyRole | null>(null);
|
||||
let rolePermissions = $state<RolePermission[]>([]);
|
||||
let allPermissions = $state<Permission[]>([]);
|
||||
let availablePermissions = $state<Permission[]>([]);
|
||||
let permissionsByModule = $state<Map<string, Permission[]>>(new Map());
|
||||
let loading = $state(true);
|
||||
let loadingPermissions = $state(false);
|
||||
|
||||
// Dialog state
|
||||
let showPermissionsDialog = $state(false);
|
||||
let showRoleDialog = $state(false);
|
||||
let selectedPermissionIds = $state<number[]>([]);
|
||||
let permissionSearchQuery = $state('');
|
||||
let editingRole = $state<CompanyRole | null>(null);
|
||||
let roleFormData = $state({
|
||||
name: '',
|
||||
code: '',
|
||||
description: '',
|
||||
is_active: true
|
||||
});
|
||||
|
||||
const companyId = $derived(companyStore.activeCompany?.id);
|
||||
|
||||
// Cargar datos cuando companyId esté disponible
|
||||
$effect(() => {
|
||||
if (companyId) {
|
||||
loadRoles();
|
||||
loadAllPermissions();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadRoles() {
|
||||
if (!companyId) return;
|
||||
|
||||
try {
|
||||
loading = true;
|
||||
const response = await rolesAPI.list(companyId);
|
||||
roles = response.items || [];
|
||||
if (roles.length > 0 && !selectedRole) {
|
||||
await selectRole(roles[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Error al cargar roles');
|
||||
console.error(error);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAllPermissions() {
|
||||
try {
|
||||
const response = await permissionsAPI.list();
|
||||
allPermissions = response.items || [];
|
||||
console.log('Total permissions loaded:', allPermissions.length);
|
||||
console.log('Unique permission IDs:', new Set(allPermissions.map(p => p.id)).size);
|
||||
if (selectedRole) {
|
||||
updateAvailablePermissions();
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Error al cargar permisos');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function selectRole(role: CompanyRole) {
|
||||
if (!companyId) return;
|
||||
|
||||
selectedRole = role;
|
||||
loadingPermissions = true;
|
||||
|
||||
try {
|
||||
const response = await rolePermissionsAPI.listByRole(role.id, companyId);
|
||||
rolePermissions = response.permissions;
|
||||
updateAvailablePermissions();
|
||||
} catch (error) {
|
||||
toast.error('Error al cargar permisos del rol');
|
||||
console.error(error);
|
||||
} finally {
|
||||
loadingPermissions = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateAvailablePermissions() {
|
||||
const assignedIds = rolePermissions.map((rp) => rp.permission_id);
|
||||
availablePermissions = allPermissions.filter((p) => !assignedIds.includes(p.id));
|
||||
|
||||
console.log('Available permissions:', availablePermissions.length);
|
||||
console.log('Unique available IDs:', new Set(availablePermissions.map(p => p.id)).size);
|
||||
|
||||
updatePermissionsByModule();
|
||||
}
|
||||
|
||||
function updatePermissionsByModule() {
|
||||
const grouped = new Map<string, Permission[]>();
|
||||
|
||||
console.log('Grouping permissions:', availablePermissions.length);
|
||||
|
||||
// Filtrar por búsqueda
|
||||
const filtered = availablePermissions.filter((p) => {
|
||||
if (!permissionSearchQuery.trim()) return true;
|
||||
const search = permissionSearchQuery.toLowerCase();
|
||||
return (
|
||||
p.code.toLowerCase().includes(search) ||
|
||||
p.description?.toLowerCase().includes(search) ||
|
||||
p.action.toLowerCase().includes(search)
|
||||
);
|
||||
});
|
||||
|
||||
filtered.forEach((p) => {
|
||||
if (!grouped.has(p.module)) {
|
||||
grouped.set(p.module, []);
|
||||
}
|
||||
grouped.get(p.module)!.push(p);
|
||||
});
|
||||
|
||||
console.log('Grouped by module:', Array.from(grouped.entries()).map(([k, v]) => `${k}: ${v.length}`));
|
||||
|
||||
permissionsByModule = grouped;
|
||||
}
|
||||
|
||||
function openAddDialog() {
|
||||
const assignedIds = rolePermissions.map((rp) => rp.permission_id);
|
||||
const availablePermissions = allPermissions.filter((p) => !assignedIds.includes(p.id));
|
||||
selectedPermissionIds = [];
|
||||
showPermissionsDialog = true;
|
||||
}
|
||||
|
||||
function openCreateRoleDialog() {
|
||||
editingRole = null;
|
||||
roleFormData = { name: '', code: '', description: '', is_active: true };
|
||||
showRoleDialog = true;
|
||||
}
|
||||
|
||||
function openEditRoleDialog(role: CompanyRole) {
|
||||
editingRole = role;
|
||||
roleFormData = {
|
||||
name: role.name,
|
||||
code: role.code,
|
||||
description: role.description || '',
|
||||
is_active: role.is_active
|
||||
};
|
||||
showRoleDialog = true;
|
||||
}
|
||||
|
||||
async function handleRoleSubmit() {
|
||||
if (!companyId) return;
|
||||
|
||||
try {
|
||||
if (editingRole) {
|
||||
await rolesAPI.update(editingRole.id, companyId, roleFormData);
|
||||
toast.success('Rol actualizado correctamente');
|
||||
// Si el rol editado es el seleccionado, actualizar la referencia
|
||||
if (selectedRole?.id === editingRole.id) {
|
||||
selectedRole = { ...selectedRole, ...roleFormData };
|
||||
}
|
||||
} else {
|
||||
const newRole = await rolesAPI.create(companyId, roleFormData);
|
||||
toast.success('Rol creado correctamente');
|
||||
// Seleccionar el nuevo rol automáticamente
|
||||
selectedRole = newRole;
|
||||
}
|
||||
showRoleDialog = false;
|
||||
await loadRoles();
|
||||
} catch (error: any) {
|
||||
const message =
|
||||
error?.response?.data?.detail ||
|
||||
error?.message ||
|
||||
(editingRole ? 'Error al actualizar rol' : 'Error al crear rol');
|
||||
toast.error(message);
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteRole(role: CompanyRole) {
|
||||
if (!companyId) return;
|
||||
if (!confirm(`¿Estás seguro de eliminar el rol "${role.name}"?`)) return;
|
||||
|
||||
try {
|
||||
await rolesAPI.delete(role.id, companyId);
|
||||
toast.success('Rol eliminado correctamente');
|
||||
// Si el rol eliminado es el seleccionado, deseleccionar
|
||||
if (selectedRole?.id === role.id) {
|
||||
selectedRole = null;
|
||||
}
|
||||
await loadRoles();
|
||||
} catch (error) {
|
||||
toast.error('Error al eliminar rol');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAssignPermissions() {
|
||||
if (!companyId || !selectedRole || selectedPermissionIds.length === 0) return;
|
||||
|
||||
try {
|
||||
await rolePermissionsAPI.assignMultiple(selectedRole.id, companyId, selectedPermissionIds);
|
||||
toast.success('Permisos asignados correctamente');
|
||||
showPermissionsDialog = false;
|
||||
await selectRole(selectedRole);
|
||||
} catch (error) {
|
||||
toast.error('Error al asignar permisos');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRemovePermission(permissionId: number) {
|
||||
if (!companyId || !selectedRole) return;
|
||||
if (!confirm('¿Estás seguro de remover este permiso?')) return;
|
||||
|
||||
try {
|
||||
await rolePermissionsAPI.remove(selectedRole.id, permissionId, companyId);
|
||||
toast.success('Permiso removido correctamente');
|
||||
await selectRole(selectedRole);
|
||||
} catch (error) {
|
||||
toast.error('Error al remover permiso');
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
function togglePermission(permissionId: number) {
|
||||
if (selectedPermissionIds.includes(permissionId)) {
|
||||
selectedPermissionIds = selectedPermissionIds.filter((id) => id !== permissionId);
|
||||
} else {
|
||||
selectedPermissionIds = [...selectedPermissionIds, permissionId];
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<div class="container mx-auto py-6">
|
||||
<div class="mb-6">
|
||||
<h1 class="text-3xl font-bold">Roles y Permisos</h1>
|
||||
<p class="text-muted-foreground">Gestiona los roles de la compañía y sus permisos</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Roles List -->
|
||||
<div class="lg:col-span-1">
|
||||
<div class="border rounded-lg p-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||||
<Shield class="h-5 w-5" />
|
||||
Roles
|
||||
</h2>
|
||||
<Button size="sm" onclick={openCreateRoleDialog}>
|
||||
<Plus class="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<p class="text-muted-foreground text-center py-4">Cargando...</p>
|
||||
{:else if roles.length === 0}
|
||||
<div class="text-center py-8">
|
||||
<p class="text-muted-foreground mb-4">No hay roles disponibles</p>
|
||||
<Button size="sm" onclick={openCreateRoleDialog}>
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
Crear primer rol
|
||||
</Button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="space-y-2">
|
||||
{#each roles as role (role.id)}
|
||||
<div
|
||||
class="flex items-center gap-2 p-2 rounded-lg border {selectedRole?.id ===
|
||||
role.id
|
||||
? 'bg-primary/10 border-primary'
|
||||
: 'hover:bg-accent'}"
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
class="flex-1 justify-start h-auto p-2"
|
||||
onclick={() => selectRole(role)}
|
||||
>
|
||||
<div class="flex flex-col items-start gap-1 w-full">
|
||||
<div class="flex items-center gap-2 w-full">
|
||||
<Shield class="h-4 w-4 flex-shrink-0" />
|
||||
<span class="font-medium text-sm truncate">{role.name}</span>
|
||||
</div>
|
||||
{#if !role.is_active}
|
||||
<Badge variant="secondary" class="text-xs">Inactivo</Badge>
|
||||
{/if}
|
||||
</div>
|
||||
</Button>
|
||||
<div class="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 w-8 p-0"
|
||||
onclick={() => openEditRoleDialog(role)}
|
||||
>
|
||||
<Pencil class="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="h-8 w-8 p-0"
|
||||
onclick={() => handleDeleteRole(role)}
|
||||
>
|
||||
<Trash2 class="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Permissions List -->
|
||||
<div class="lg:col-span-2">
|
||||
{#if selectedRole}
|
||||
<div class="border rounded-lg">
|
||||
<div class="p-4 border-b flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold flex items-center gap-2">
|
||||
<Key class="h-5 w-5" />
|
||||
Permisos de "{selectedRole.name}"
|
||||
</h2>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{rolePermissions.length} permisos asignados
|
||||
</p>
|
||||
</div>
|
||||
<Button onclick={openAddDialog} disabled={availablePermissions.length === 0}>
|
||||
<Plus class="mr-2 h-4 w-4" />
|
||||
Agregar Permisos
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Módulo</TableHead>
|
||||
<TableHead>Acción</TableHead>
|
||||
<TableHead>Descripción</TableHead>
|
||||
<TableHead class="text-right">Acciones</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{#if loadingPermissions}
|
||||
<TableRow>
|
||||
<TableCell colspan={4} class="text-center py-8">
|
||||
Cargando permisos...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{:else if rolePermissions.length === 0}
|
||||
<TableRow>
|
||||
<TableCell colspan={4} class="text-center py-8">
|
||||
Este rol no tiene permisos asignados
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{:else}
|
||||
{#each rolePermissions as rp (rp.id)}
|
||||
<TableRow>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{rp.permission?.module}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{rp.permission?.action}</TableCell>
|
||||
<TableCell class="text-muted-foreground">
|
||||
{rp.permission?.description || '-'}
|
||||
</TableCell>
|
||||
<TableCell class="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => handleRemovePermission(rp.permission_id)}
|
||||
>
|
||||
<Trash2 class="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{/each}
|
||||
{/if}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="border rounded-lg p-12 text-center">
|
||||
<Shield class="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||
<p class="text-muted-foreground">Selecciona un rol para ver sus permisos</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Permissions Dialog -->
|
||||
<Dialog.Root bind:open={showPermissionsDialog}>
|
||||
<Dialog.Content class="max-w-2xl max-h-[80vh]">
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>Agregar Permisos a "{selectedRole?.name}"</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="overflow-y-auto max-h-96 py-4">
|
||||
<div class="px-4 pb-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Buscar permisos..."
|
||||
bind:value={permissionSearchQuery}
|
||||
oninput={() => updatePermissionsByModule()}
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if availablePermissions.length === 0}
|
||||
<p class="text-center text-muted-foreground py-8">
|
||||
No hay permisos disponibles para asignar
|
||||
</p>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each [...permissionsByModule] as [module, permissions] (module)}
|
||||
<div class="border rounded-lg p-4">
|
||||
<h3 class="font-semibold mb-3 flex items-center gap-2">
|
||||
<Badge>{module}</Badge>
|
||||
<span class="text-xs text-muted-foreground">({permissions.length} permisos, {new Set(permissions.map(p => p.id)).size} únicos)</span>
|
||||
</h3>
|
||||
<div class="space-y-2">
|
||||
{#each permissions as permission (permission.id)}
|
||||
<div class="flex items-start space-x-3">
|
||||
<Checkbox
|
||||
id="perm-{permission.id}"
|
||||
checked={selectedPermissionIds.includes(permission.id)}
|
||||
onCheckedChange={() => togglePermission(permission.id)}
|
||||
/>
|
||||
<div class="flex-1">
|
||||
<Label
|
||||
for="perm-{permission.id}"
|
||||
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{permission.description || permission.code}
|
||||
</Label>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<code class="text-xs bg-muted px-1.5 py-0.5 rounded">{permission.code}</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (showPermissionsDialog = false)}>Cancelar</Button>
|
||||
<Button onclick={handleAssignPermissions} disabled={selectedPermissionIds.length === 0}>
|
||||
Asignar {selectedPermissionIds.length} permisos
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
|
||||
<!-- Create/Edit Role Dialog -->
|
||||
<Dialog.Root bind:open={showRoleDialog}>
|
||||
<Dialog.Content>
|
||||
<Dialog.Header>
|
||||
<Dialog.Title>
|
||||
{editingRole ? 'Editar Rol' : 'Nuevo Rol'}
|
||||
</Dialog.Title>
|
||||
</Dialog.Header>
|
||||
|
||||
<div class="grid gap-4 py-4">
|
||||
<div class="grid gap-2">
|
||||
<Label for="name">Nombre</Label>
|
||||
<Input id="name" bind:value={roleFormData.name} placeholder="Administrador" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="code">Código</Label>
|
||||
<Input id="code" bind:value={roleFormData.code} placeholder="ADMIN" />
|
||||
</div>
|
||||
|
||||
<div class="grid gap-2">
|
||||
<Label for="description">Descripción</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
bind:value={roleFormData.description}
|
||||
placeholder="Rol con acceso completo al sistema"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<Switch id="is_active" bind:checked={roleFormData.is_active} />
|
||||
<Label for="is_active">Activo</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog.Footer>
|
||||
<Button variant="outline" onclick={() => (showRoleDialog = false)}>Cancelar</Button>
|
||||
<Button onclick={handleRoleSubmit}>
|
||||
{editingRole ? 'Actualizar' : 'Crear'}
|
||||
</Button>
|
||||
</Dialog.Footer>
|
||||
</Dialog.Content>
|
||||
</Dialog.Root>
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getAuthTokens, authenticatedFetch } from '$lib/server/api';
|
||||
import { getAuthTokens, authenticatedFetch, handleApiResponse } from '$lib/server/api';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => {
|
||||
// Esperar a que el layout padre valide/refresque el token
|
||||
@@ -63,16 +63,13 @@ export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => {
|
||||
fetch
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('📊 [Clients&Providers] API Error:', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
error: errorText
|
||||
});
|
||||
const result = await handleApiResponse(response);
|
||||
|
||||
if (result.error) {
|
||||
console.error('📊 [Clients&Providers] API Error:', result.error);
|
||||
|
||||
return {
|
||||
error: `Error ${response.status}: ${response.statusText}`,
|
||||
error: result.error,
|
||||
items: [],
|
||||
total: 0,
|
||||
page: page,
|
||||
@@ -82,7 +79,7 @@ export const load: PageServerLoad = async ({ cookies, fetch, url, parent }) => {
|
||||
};
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const data = result.data;
|
||||
|
||||
return {
|
||||
items: data.items || [],
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
import type { PageData } from './$types';
|
||||
import { browser } from '$app/environment';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import type { ApiError } from '$lib/utils/error-handler';
|
||||
|
||||
// Los datos iniciales vienen del servidor
|
||||
let { data }: { data: PageData } = $props();
|
||||
@@ -84,7 +85,7 @@
|
||||
let totalItems = $state(data.total || 0);
|
||||
let loading = $state(false);
|
||||
let hasMore = $derived(allItems.length < totalItems);
|
||||
let error = $state<string | null>(data.error || null);
|
||||
let error = $state<string | ApiError | null>(data.error || null);
|
||||
|
||||
async function loadMore() {
|
||||
if (loading || !hasMore || !companyStore.activeCompany) return;
|
||||
@@ -205,7 +206,9 @@
|
||||
<Card.Root class="border-destructive">
|
||||
<Card.Header>
|
||||
<Card.Title class="text-destructive">Error</Card.Title>
|
||||
<Card.Description>{error}</Card.Description>
|
||||
<Card.Description>
|
||||
{typeof error === 'string' ? error : error.detail}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import type { PageData } from './$types';
|
||||
import { browser } from '$app/environment';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { Plus, Filter, Trash2, RefreshCw } from 'lucide-svelte';
|
||||
import { Plus, RefreshCw } from 'lucide-svelte';
|
||||
|
||||
// Los datos iniciales vienen del servidor
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user