feat: Implement multi-tenancy support in middleware and security layers
- Enhanced TenantMiddleware to validate tenant information from JWT tokens. - Added LicenseValidationMiddleware to check tenant licenses before processing requests. - Updated security utilities to extract tenant information from tokens and validate company access. - Introduced CompanyStore to manage active company state and handle company switching in the frontend. - Modified API routes to include company_id in requests for better resource management. - Improved logging and error handling throughout the middleware and API layers. - Updated frontend components to reflect changes in company management and selection. - Added new API route for fetching user's companies with proper authentication handling.
This commit is contained in:
@@ -80,9 +80,10 @@ export const pedimentosApi = {
|
||||
* @param page - Número de página (por defecto 1)
|
||||
* @param pageSize - Tamaño de página (por defecto 50)
|
||||
* @param filters - Filtros opcionales
|
||||
* @param companyId - ID de la compañía (por defecto 1)
|
||||
*/
|
||||
list: (page = 1, pageSize = 50, filters?: PedimentoFilters) => {
|
||||
let url = `/v1/a76/pedimentos?page=${page}&page_size=${pageSize}`;
|
||||
list: (page = 1, pageSize = 50, filters?: PedimentoFilters, companyId = 1) => {
|
||||
let url = `/v1/a76/pedimentos?company_id=${companyId}&page=${page}&page_size=${pageSize}`;
|
||||
|
||||
if (filters?.status) {
|
||||
url += `&status=${encodeURIComponent(filters.status)}`;
|
||||
@@ -100,27 +101,31 @@ export const pedimentosApi = {
|
||||
/**
|
||||
* Obtiene un pedimento por ID
|
||||
* @param id - ID del pedimento
|
||||
* @param companyId - ID de la compañía (por defecto 1)
|
||||
*/
|
||||
get: (id: number) => api.get<Pedimento>(`/v1/a76/pedimentos/${id}`),
|
||||
get: (id: number, companyId = 1) => api.get<Pedimento>(`/v1/a76/pedimentos/${id}?company_id=${companyId}`),
|
||||
|
||||
/**
|
||||
* Crea un nuevo pedimento
|
||||
* @param data - Datos del pedimento a crear
|
||||
* @param companyId - ID de la compañía (por defecto 1)
|
||||
*/
|
||||
create: (data: CreatePedimentoData) =>
|
||||
api.post<Pedimento>('/v1/a76/pedimentos', data),
|
||||
create: (data: CreatePedimentoData, companyId = 1) =>
|
||||
api.post<Pedimento>(`/v1/a76/pedimentos?company_id=${companyId}`, data),
|
||||
|
||||
/**
|
||||
* Actualiza un pedimento existente
|
||||
* @param id - ID del pedimento a actualizar
|
||||
* @param data - Datos a actualizar
|
||||
* @param companyId - ID de la compañía (por defecto 1)
|
||||
*/
|
||||
update: (id: number, data: UpdatePedimentoData) =>
|
||||
api.put<Pedimento>(`/v1/a76/pedimentos/${id}`, data),
|
||||
update: (id: number, data: UpdatePedimentoData, companyId = 1) =>
|
||||
api.put<Pedimento>(`/v1/a76/pedimentos/${id}?company_id=${companyId}`, data),
|
||||
|
||||
/**
|
||||
* Elimina un pedimento
|
||||
* @param id - ID del pedimento a eliminar
|
||||
* @param companyId - ID de la compañía (por defecto 1)
|
||||
*/
|
||||
delete: (id: number) => api.delete(`/v1/a76/pedimentos/${id}`)
|
||||
delete: (id: number, companyId = 1) => api.delete(`/v1/a76/pedimentos/${id}?company_id=${companyId}`)
|
||||
};
|
||||
|
||||
@@ -173,8 +173,11 @@ export const initKeycloak = async (): Promise<boolean> => {
|
||||
}
|
||||
};
|
||||
|
||||
// Variable para rastrear el tenant anterior
|
||||
let previousTenantId: number | undefined = undefined;
|
||||
|
||||
/**
|
||||
* Actualiza el estado de autenticación
|
||||
* Actualiza el estado de autenticación con los datos de Keycloak
|
||||
*/
|
||||
const updateAuthState = async () => {
|
||||
if (!keycloakInstance?.authenticated) {
|
||||
@@ -189,19 +192,36 @@ const updateAuthState = async () => {
|
||||
|
||||
const roles = tokenParsed?.realm_access?.roles || [];
|
||||
const tenantId = tokenParsed?.tenant_id || tokenParsed?.attributes?.tenant_id;
|
||||
const newTenantId = tenantId ? parseInt(tenantId) : undefined;
|
||||
|
||||
// Detectar si cambió el tenant
|
||||
const tenantChanged = previousTenantId !== undefined && previousTenantId !== newTenantId;
|
||||
|
||||
const user: User = {
|
||||
id: profile.id || '',
|
||||
username: profile.username || '',
|
||||
email: profile.email,
|
||||
name: `${profile.firstName || ''} ${profile.lastName || ''}`.trim(),
|
||||
tenantId: tenantId ? parseInt(tenantId) : undefined,
|
||||
tenantId: newTenantId,
|
||||
roles
|
||||
};
|
||||
|
||||
authStore.setAuthenticated(true);
|
||||
authStore.setUser(user);
|
||||
authStore.setToken(token);
|
||||
|
||||
// Si cambió el tenant, limpiar el store de compañías
|
||||
if (tenantChanged && browser) {
|
||||
try {
|
||||
const { companyStore } = await import('./stores/company.svelte');
|
||||
companyStore.clear();
|
||||
} catch (error) {
|
||||
console.error('Error al limpiar store de compañías:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// Actualizar el tenant anterior
|
||||
previousTenantId = newTenantId;
|
||||
} catch (error) {
|
||||
console.error('Error actualizando estado de autenticación:', error);
|
||||
authStore.reset();
|
||||
@@ -361,6 +381,14 @@ export const logout = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
// Limpiar store de compañías
|
||||
try {
|
||||
const { companyStore } = await import('./stores/company.svelte');
|
||||
companyStore.clear();
|
||||
} catch (error) {
|
||||
console.error('Error al limpiar store de compañías:', error);
|
||||
}
|
||||
|
||||
// Limpiar estado local
|
||||
authStore.reset();
|
||||
localStorage.removeItem('access_token');
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
<Sidebar.Root {collapsible} {...restProps}>
|
||||
<Sidebar.Header>
|
||||
<TeamSwitcher teams={data.teams} />
|
||||
<TeamSwitcher />
|
||||
</Sidebar.Header>
|
||||
<Sidebar.Content>
|
||||
<NavMain items={data.navMain} />
|
||||
|
||||
@@ -3,14 +3,18 @@
|
||||
import * as Sidebar from "$lib/components/ui/sidebar/index.js";
|
||||
import { useSidebar } from "$lib/components/ui/sidebar/index.js";
|
||||
import ChevronsUpDownIcon from "@lucide/svelte/icons/chevrons-up-down";
|
||||
import PlusIcon from "@lucide/svelte/icons/plus";
|
||||
import BuildingIcon from "@lucide/svelte/icons/building";
|
||||
import CheckIcon from "@lucide/svelte/icons/check";
|
||||
import { companyStore } from "$lib/stores/company.svelte";
|
||||
|
||||
// This should be `Component` after @lucide/svelte updates types
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let { teams }: { teams: { name: string; logo: any; plan: string }[] } = $props();
|
||||
const sidebar = useSidebar();
|
||||
|
||||
let activeTeam = $state(teams[0]);
|
||||
// Inicializar el store cuando se monta el componente
|
||||
$effect(() => {
|
||||
if (companyStore.companies.length === 0) {
|
||||
companyStore.initialize();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Sidebar.Menu>
|
||||
@@ -26,15 +30,27 @@
|
||||
<div
|
||||
class="bg-sidebar-primary text-sidebar-primary-foreground flex aspect-square size-8 items-center justify-center rounded-lg"
|
||||
>
|
||||
<activeTeam.logo class="size-4 text-white" />
|
||||
{#if companyStore.activeCompany?.logo}
|
||||
<img
|
||||
src={companyStore.activeCompany.logo}
|
||||
alt={companyStore.activeCompany.name}
|
||||
class="size-full rounded-lg object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<BuildingIcon class="size-4 text-white" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="grid flex-1 text-left text-sm leading-tight">
|
||||
<span class="truncate font-medium">
|
||||
{activeTeam.name}
|
||||
{companyStore.activeCompany?.name || 'Seleccionar compañía'}
|
||||
</span>
|
||||
<span class="truncate text-xs">{activeTeam.plan}</span>
|
||||
{#if companyStore.activeCompany?.rfc}
|
||||
<span class="truncate text-xs text-muted-foreground">
|
||||
{companyStore.activeCompany.rfc}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<ChevronsUpDownIcon class="ml-auto" />
|
||||
<ChevronsUpDownIcon class="ml-auto size-4" />
|
||||
</Sidebar.MenuButton>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
@@ -44,25 +60,50 @@
|
||||
side={sidebar.isMobile ? "bottom" : "right"}
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenu.Label class="text-muted-foreground text-xs">Teams</DropdownMenu.Label>
|
||||
{#each teams as team, index (team.name)}
|
||||
<DropdownMenu.Item onSelect={() => (activeTeam = team)} class="gap-2 p-2">
|
||||
<div class="flex size-6 items-center justify-center rounded-md border">
|
||||
<team.logo class="size-3.5 shrink-0" />
|
||||
</div>
|
||||
{team.name}
|
||||
<DropdownMenu.Shortcut>⌘{index + 1}</DropdownMenu.Shortcut>
|
||||
<DropdownMenu.Label class="text-muted-foreground text-xs">
|
||||
Mis Compañías
|
||||
</DropdownMenu.Label>
|
||||
|
||||
{#if companyStore.loading}
|
||||
<DropdownMenu.Item disabled class="gap-2 p-2">
|
||||
<span class="text-muted-foreground">Cargando...</span>
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.Item class="gap-2 p-2">
|
||||
<div
|
||||
class="flex size-6 items-center justify-center rounded-md border bg-transparent"
|
||||
>
|
||||
<PlusIcon class="size-4" />
|
||||
</div>
|
||||
<div class="text-muted-foreground font-medium">Add team</div>
|
||||
</DropdownMenu.Item>
|
||||
{:else if companyStore.companies.length === 0}
|
||||
<DropdownMenu.Item disabled class="gap-2 p-2">
|
||||
<span class="text-muted-foreground">No hay compañías disponibles</span>
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
{#each companyStore.companies as company, index (company.id)}
|
||||
<DropdownMenu.Item
|
||||
onSelect={() => companyStore.setActiveCompany(company)}
|
||||
class="gap-2 p-2 cursor-pointer"
|
||||
>
|
||||
<div class="flex size-6 items-center justify-center rounded-md border">
|
||||
{#if company.logo}
|
||||
<img
|
||||
src={company.logo}
|
||||
alt={company.name}
|
||||
class="size-full rounded object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<BuildingIcon class="size-3.5 shrink-0" />
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-1 flex-col">
|
||||
<span class="font-medium">{company.name}</span>
|
||||
{#if company.rfc}
|
||||
<span class="text-xs text-muted-foreground">{company.rfc}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if companyStore.activeCompany?.id === company.id}
|
||||
<CheckIcon class="ml-auto size-4 text-primary" />
|
||||
{/if}
|
||||
{#if index < 9}
|
||||
<DropdownMenu.Shortcut>⌘{index + 1}</DropdownMenu.Shortcut>
|
||||
{/if}
|
||||
</DropdownMenu.Item>
|
||||
{/each}
|
||||
{/if}
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</Sidebar.MenuItem>
|
||||
|
||||
@@ -2,9 +2,10 @@ import { Tooltip as TooltipPrimitive } from "bits-ui";
|
||||
import Trigger from "./tooltip-trigger.svelte";
|
||||
import Content from "./tooltip-content.svelte";
|
||||
|
||||
const Root = TooltipPrimitive.Root;
|
||||
const Provider = TooltipPrimitive.Provider;
|
||||
const Portal = TooltipPrimitive.Portal;
|
||||
// Handle SSR safely
|
||||
const Root = TooltipPrimitive?.Root ?? (class {} as any);
|
||||
const Provider = TooltipPrimitive?.Provider ?? (class {} as any);
|
||||
const Portal = TooltipPrimitive?.Portal ?? (class {} as any);
|
||||
|
||||
export {
|
||||
Root,
|
||||
|
||||
122
frontend/src/lib/stores/company.svelte.ts
Normal file
122
frontend/src/lib/stores/company.svelte.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Store para manejar la compañía activa del usuario
|
||||
* Permite cambiar entre las compañías que pertenecen al tenant
|
||||
*/
|
||||
|
||||
interface Company {
|
||||
id: number;
|
||||
name: string;
|
||||
rfc?: string;
|
||||
logo?: string;
|
||||
tenant_id: number;
|
||||
}
|
||||
|
||||
class CompanyStore {
|
||||
private _activeCompany = $state<Company | null>(null);
|
||||
private _companies = $state<Company[]>([]);
|
||||
private _loading = $state(false);
|
||||
|
||||
get activeCompany() {
|
||||
return this._activeCompany;
|
||||
}
|
||||
|
||||
get companies() {
|
||||
return this._companies;
|
||||
}
|
||||
|
||||
get loading() {
|
||||
return this._loading;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carga las compañías del tenant del usuario desde el backend
|
||||
*/
|
||||
async loadCompanies() {
|
||||
this._loading = true;
|
||||
try {
|
||||
const response = await fetch('/api/company/my-companies');
|
||||
if (response.ok) {
|
||||
this._companies = await response.json();
|
||||
|
||||
// Si hay compañías y no hay una activa, seleccionar la primera
|
||||
if (this._companies.length > 0 && !this._activeCompany) {
|
||||
this.setActiveCompany(this._companies[0]);
|
||||
}
|
||||
} else {
|
||||
console.error('Error loading companies:', response.statusText);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading companies:', error);
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Establece la compañía activa
|
||||
*/
|
||||
setActiveCompany(company: Company) {
|
||||
this._activeCompany = company;
|
||||
|
||||
// Guardar en localStorage para persistencia
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('activeCompanyId', company.id.toString());
|
||||
}
|
||||
|
||||
// Guardar en cookie para acceso desde el servidor (SSR)
|
||||
if (typeof document !== 'undefined') {
|
||||
document.cookie = `active_company_id=${company.id}; path=/; max-age=${60 * 60 * 24 * 30}; SameSite=Lax`;
|
||||
}
|
||||
|
||||
// Despachar evento personalizado para que otros componentes reaccionen
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('companyChanged', {
|
||||
detail: { companyId: company.id }
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restaura la compañía activa desde localStorage
|
||||
*/
|
||||
restoreActiveCompany() {
|
||||
if (typeof window !== 'undefined') {
|
||||
const savedId = localStorage.getItem('activeCompanyId');
|
||||
if (savedId && this._companies.length > 0) {
|
||||
const company = this._companies.find(c => c.id === parseInt(savedId));
|
||||
if (company) {
|
||||
this._activeCompany = company;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpia el store (útil al cambiar de tenant o cerrar sesión)
|
||||
*/
|
||||
clear() {
|
||||
this._activeCompany = null;
|
||||
this._companies = [];
|
||||
this._loading = false;
|
||||
|
||||
// Limpiar localStorage
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.removeItem('activeCompanyId');
|
||||
}
|
||||
|
||||
// Limpiar cookie
|
||||
if (typeof document !== 'undefined') {
|
||||
document.cookie = 'active_company_id=; path=/; max-age=0';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inicializa el store cargando las compañías
|
||||
*/
|
||||
async initialize() {
|
||||
await this.loadCompanies();
|
||||
this.restoreActiveCompany();
|
||||
}
|
||||
}
|
||||
|
||||
export const companyStore = new CompanyStore();
|
||||
Reference in New Issue
Block a user