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 };
|
||||
}
|
||||
Reference in New Issue
Block a user