feat(core,crm): gestión de compañías en el CRM + soporte hub_admin sin tenant

Las compañías (a76.company) se gestionan en el CRM, ligadas a un tenant del
Workspace. Cambios:

- my-companies: por MEMBRESÍA (user_tenants ∪ user_company_roles), así el hub_admin
  sin tenant en el token ve las compañías que creó/se le asignaron; para usuarios
  con tenant, autocrea una por defecto en el primer acceso.
- POST /auth/companies: da de alta una compañía bajo un tenant + asigna al usuario.
  GET /auth/assignable-tenants: tenants elegibles (hub_admin: todos; usuario: el suyo).
- security.validate_access_to_resource: si el token no trae tenant (hub_admin),
  resuelve el tenant desde la compañía activa (a76.company.tenant_id) → puede operar
  por compañía seleccionada.
- set-active: fija sso_tenant_id/pub con el tenant de la compañía (override).
- Pantalla "Compañías" (nav) para crear/listar/seleccionar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ernesto Herrera
2026-07-17 11:44:36 -06:00
parent 0b6848bbdd
commit 63ad2e2ecd
6 changed files with 335 additions and 59 deletions

View File

@@ -24,6 +24,12 @@ from .dto import (
) )
from .service import AuthService from .service import AuthService
import logging
from typing import Optional
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["Authentication"]) router = APIRouter(prefix="/auth", tags=["Authentication"])
security = HTTPBearer() security = HTTPBearer()
@@ -432,25 +438,19 @@ async def get_my_companies(
) )
from api.v1.modules.core.tenants.models import Tenant from api.v1.modules.core.tenants.models import Tenant
from sqlalchemy import text from sqlalchemy import text
import logging as _logging
_log = _logging.getLogger(__name__)
user_id = current_user.get("sub") or current_user.get("id")
tenant_id = resolve_effective_tenant_id_from_user(current_user) tenant_id = resolve_effective_tenant_id_from_user(current_user)
if not tenant_id:
# Usuario sin tenant (p. ej. hub_admin global): no hay compañía que resolver. # 1) Usuario CON tenant en el token (flujo normal): autocrea una compañía por
return [] # defecto en el primer acceso y asegura la membresía.
if tenant_id:
tenant_id = int(tenant_id) tenant_id = int(tenant_id)
exists = db.execute(
# La compañía es una entidad REAL del CRM (a76.company, heredada de Anexo76). text("SELECT id FROM a76.company WHERE tenant_id = :tid ORDER BY id LIMIT 1"),
# Se listan las compañías del tenant; si no hay ninguna, se crea una por defecto
# en el primer acceso (nombre = el del tenant).
rows = db.execute(
text("SELECT id, name, rfc, logo FROM a76.company WHERE tenant_id = :tid ORDER BY id"),
{"tid": tenant_id}, {"tid": tenant_id},
).fetchall() ).fetchone()
if not exists:
if not rows:
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first() tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
default_name = ( default_name = (
(tenant.name if tenant else None) (tenant.name if tenant else None)
@@ -458,34 +458,125 @@ async def get_my_companies(
or "Mi empresa" or "Mi empresa"
) )
created = db.execute( created = db.execute(
text( text("INSERT INTO a76.company (tenant_id, name) VALUES (:tid, :name) RETURNING id"),
"INSERT INTO a76.company (tenant_id, name) VALUES (:tid, :name) "
"RETURNING id, name, rfc, logo"
),
{"tid": tenant_id, "name": default_name}, {"tid": tenant_id, "name": default_name},
).fetchone() ).fetchone()
# Alinear la secuencia por si hubo inserts con id explícito (seed dev).
db.execute(text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))")) db.execute(text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))"))
db.commit() db.commit()
rows = [created] logger.info("Compañía por defecto creada para tenant=%s: id=%s", tenant_id, created[0])
_log.info("Compañía por defecto creada para tenant=%s: id=%s", tenant_id, created[0])
# Asegurar el vínculo usuario↔compañía por cada compañía del tenant.
user_id = current_user.get("sub") or current_user.get("id")
companies = []
for r in rows:
cid = int(r[0])
if user_id: if user_id:
try: try:
_ensure_user_tenant_for_company(db, str(user_id), tenant_id, cid) _ensure_user_tenant_for_company(db, str(user_id), tenant_id, int(created[0]))
except Exception as exc: except Exception as exc:
_log.warning("no se pudo asegurar user_tenant (no bloquea): %s", exc) logger.warning("no se pudo asegurar user_tenant (no bloquea): %s", exc)
companies.append({
"id": cid, # 2) Compañías por MEMBRESÍA (user_tenants user_company_roles) → funciona
"name": r[1] or "Mi empresa", # también para hub_admin sin tenant en el token: verá las compañías que creó
"tenant_id": tenant_id, # o a las que fue asignado. La membresía la determina el CRM, no el Hub.
if not user_id:
return []
rows = db.execute(
text(
"""
SELECT c.id, c.name, c.rfc, c.logo, c.tenant_id
FROM a76.company c
WHERE c.id IN (
SELECT company_id FROM core.user_tenants
WHERE keycloak_user_id = :uid AND is_active AND company_id IS NOT NULL
UNION
SELECT company_id FROM core.user_company_roles
WHERE user_id = :uid AND is_active
)
ORDER BY c.id
"""
),
{"uid": str(user_id)},
).fetchall()
return [
{
"id": int(r[0]),
"name": r[1] or "Empresa",
"tenant_id": int(r[4]),
"rfc": r[2], "rfc": r[2],
"logo": r[3], "logo": r[3],
"is_active": True, "is_active": True,
}) }
return companies for r in rows
]
class _CreateCompanyDTO(BaseModel):
name: str
tenant_id: int
rfc: Optional[str] = None
@router.get("/assignable-tenants")
async def assignable_tenants(
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""
Tenants disponibles para asignar una compañía nueva. El tenant lo crea el
Workspace; aquí solo se elige. hub_admin ve todos; un usuario con tenant ve el suyo.
"""
from api.v1.modules.core.tenants.models import Tenant
from core.security import resolve_effective_tenant_id_from_user, is_hub_admin
q = db.query(Tenant).filter(Tenant.is_active == True) # noqa: E712
tid = resolve_effective_tenant_id_from_user(current_user)
if tid and not is_hub_admin(current_user):
q = q.filter(Tenant.id == int(tid))
return [{"id": t.id, "name": t.name, "slug": t.slug} for t in q.order_by(Tenant.id).all()]
@router.post("/companies", status_code=201)
async def create_company(
data: _CreateCompanyDTO,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""
Da de alta una compañía (a76.company) bajo un tenant del Workspace y asigna al
usuario como miembro. hub_admin puede crear en cualquier tenant; un usuario con
tenant solo en el suyo. El rol super_admin se otorga al seleccionarla (/permissions/me).
"""
from sqlalchemy import text as _text
from api.v1.modules.core.tenants.models import Tenant
from core.security import (
resolve_effective_tenant_id_from_user,
is_hub_admin,
_ensure_user_tenant_for_company,
)
name = (data.name or "").strip()
if len(name) < 2:
raise HTTPException(status_code=422, detail="El nombre de la compañía es obligatorio.")
tid = int(data.tenant_id)
tenant = db.query(Tenant).filter(Tenant.id == tid, Tenant.is_active == True).first() # noqa: E712
if not tenant:
raise HTTPException(status_code=404, detail="Tenant no encontrado.")
if not is_hub_admin(current_user):
own = resolve_effective_tenant_id_from_user(current_user)
if own is None or int(own) != tid:
raise HTTPException(status_code=403, detail="No puedes crear compañías en ese tenant.")
created = db.execute(
_text("INSERT INTO a76.company (tenant_id, name, rfc) VALUES (:t, :n, :r) RETURNING id"),
{"t": tid, "n": name, "r": (data.rfc or None)},
).fetchone()
db.execute(_text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))"))
db.commit()
cid = int(created[0])
user_id = current_user.get("sub") or current_user.get("id")
if user_id:
try:
_ensure_user_tenant_for_company(db, str(user_id), tid, cid)
except Exception as exc:
logger.warning("create_company: no se pudo asegurar membresía (no bloquea): %s", exc)
return {"id": cid, "name": name, "tenant_id": tid, "rfc": data.rfc, "logo": None, "is_active": True}

View File

@@ -666,6 +666,20 @@ def validate_access_to_resource(
tenant_id = resolve_effective_tenant_id_from_user(current_user) tenant_id = resolve_effective_tenant_id_from_user(current_user)
# Si el usuario no trae tenant en el token (p. ej. hub_admin del workspace),
# resolverlo desde la compañía activa (a76.company.tenant_id). Permite operar
# por compañía seleccionada cuando el token no está ligado a un tenant.
if tenant_id is None and company_id:
try:
from sqlalchemy import text as _text
row = db.execute(
_text("SELECT tenant_id FROM a76.company WHERE id = :c"), {"c": company_id}
).first()
if row and row[0] is not None:
tenant_id = int(row[0])
except Exception as exc:
logger.warning("no se pudo resolver tenant desde company_id=%s: %s", company_id, exc)
# Bypass de checks de permisos: hub_admin (atestado por el Hub en /auth/me) # Bypass de checks de permisos: hub_admin (atestado por el Hub en /auth/me)
# o rol local "super_admin" en la compañía (fuente de verdad: BD de a76). # o rol local "super_admin" en la compañía (fuente de verdad: BD de a76).
# Se reemplazó el antiguo "admin" in realm_access.roles para que la # Se reemplazó el antiguo "admin" in realm_access.roles para que la

View File

@@ -7,6 +7,7 @@ import {
Ship, Ship,
Receipt, Receipt,
Building2, Building2,
Building,
} from '@lucide/svelte'; } from '@lucide/svelte';
export type SystemContext = 'fixed_asset' | 'inventory'; export type SystemContext = 'fixed_asset' | 'inventory';
@@ -70,6 +71,11 @@ export function getNavMain(): NavMainItem[] {
{ title: 'Facturas y cobranza', url: '/dashboard/fin/facturas' }, { title: 'Facturas y cobranza', url: '/dashboard/fin/facturas' },
], ],
}, },
{
title: 'Compañías',
url: '/dashboard/companias',
icon: Building,
},
{ {
title: 'Workspace', title: 'Workspace',
url: '/dashboard/workspace/organizaciones', url: '/dashboard/workspace/organizaciones',

View File

@@ -158,7 +158,9 @@ class CompanyStore {
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}, },
body: JSON.stringify({ companyId: company.id }), // tenant_id de la compañía → override para que el backend escale por
// ese tenant (necesario cuando el usuario es hub_admin sin tenant en el token).
body: JSON.stringify({ companyId: company.id, tenantId: company.tenant_id }),
credentials: 'include' credentials: 'include'
}); });
} catch (error) { } catch (error) {

View File

@@ -6,22 +6,28 @@ import type { RequestHandler } from './$types';
export const POST: RequestHandler = async ({ cookies, request }) => { export const POST: RequestHandler = async ({ cookies, request }) => {
try { try {
const { companyId } = await request.json(); const body = await request.json();
const companyId = body?.companyId;
const tenantId = body?.tenantId;
if (!companyId || typeof companyId !== 'number') { if (!companyId || typeof companyId !== 'number') {
return json({ error: 'Invalid company ID' }, { status: 400 }); return json({ error: 'Invalid company ID' }, { status: 400 });
} }
// Establecer la cookie desde el servidor const isProd = process.env.NODE_ENV === 'production';
cookies.set('active_company_id', companyId.toString(), { const base = { path: '/', maxAge: 60 * 60 * 24 * 30, sameSite: 'lax' as const, secure: isProd };
path: '/',
maxAge: 60 * 60 * 24 * 30, // 30 días
sameSite: 'lax',
httpOnly: false, // Permitir acceso desde JavaScript
secure: process.env.NODE_ENV === 'production'
});
return json({ success: true, companyId }); // Compañía activa (legible desde JS)
cookies.set('active_company_id', companyId.toString(), { ...base, httpOnly: false });
// Fijar el tenant de la compañía como override → el backend escala por ese
// tenant aunque el token no lo traiga (caso hub_admin operando por compañía).
if (typeof tenantId === 'number' && Number.isFinite(tenantId)) {
cookies.set('sso_tenant_id', tenantId.toString(), { ...base, httpOnly: true });
cookies.set('sso_tenant_pub', tenantId.toString(), { ...base, httpOnly: false });
}
return json({ success: true, companyId, tenantId: tenantId ?? null });
} catch (error) { } catch (error) {
console.error('Error setting active company:', error); console.error('Error setting active company:', error);
return json({ error: 'Internal server error' }, { status: 500 }); return json({ error: 'Internal server error' }, { status: 500 });

View File

@@ -0,0 +1,157 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Building, Plus } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card';
import { Button } from '$lib/components/ui/button';
import { toast } from 'svelte-sonner';
import { api } from '$lib/api';
import { companyStore } from '$lib/stores/company.svelte';
const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
type Tenant = { id: number; name: string; slug: string };
let tenants = $state<Tenant[]>([]);
let loading = $state(true);
let submitting = $state(false);
let name = $state('');
let rfc = $state('');
let tenantId = $state<number | null>(null);
const companies = $derived(companyStore.companies);
onMount(async () => {
const res = await api.get<Tenant[]>('/v1/auth/assignable-tenants');
if (res.data) {
tenants = res.data;
if (tenants.length === 1) tenantId = tenants[0].id;
}
await companyStore.loadCompanies();
loading = false;
});
async function createCompany() {
if (name.trim().length < 2) {
toast.error('El nombre de la compañía es obligatorio');
return;
}
if (!tenantId) {
toast.error('Selecciona el tenant al que pertenece');
return;
}
submitting = true;
try {
const res = await api.post<{ id: number }>('/v1/auth/companies', {
name: name.trim(),
tenant_id: tenantId,
rfc: rfc.trim() || null
});
if (res.error) {
toast.error(res.error);
return;
}
toast.success('Compañía creada');
name = '';
rfc = '';
await companyStore.loadCompanies();
// Seleccionarla como activa para poder trabajar de inmediato.
const created = res.data?.id
? companyStore.companies.find((c) => c.id === res.data!.id)
: null;
if (created) await companyStore.setActiveCompany(created);
} catch (e) {
toast.error(e instanceof Error ? e.message : 'No se pudo crear la compañía');
} finally {
submitting = false;
}
}
</script>
<div class="space-y-6">
<div>
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
<Building class="h-6 w-6" /> Compañías
</h1>
<p class="mt-1 text-sm text-muted-foreground">
Da de alta las empresas del CRM. Cada compañía pertenece a un tenant (organización) del
Workspace. Al crear una, quedas asignado como administrador y se selecciona como activa.
</p>
</div>
<Card.Root>
<Card.Header>
<Card.Title class="flex items-center gap-2"><Plus class="h-4 w-4" /> Nueva compañía</Card.Title>
<Card.Description>El tenant lo crea el Workspace; aquí eliges bajo cuál registrar la empresa.</Card.Description>
</Card.Header>
<Card.Content>
<div class="grid gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
<span class="font-medium">Nombre / Razón social *</span>
<input class={inputCls} bind:value={name} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">RFC</span>
<input class="font-mono {inputCls}" maxlength="13" bind:value={rfc} />
</label>
<label class="flex flex-col gap-1 text-sm">
<span class="font-medium">Tenant (Workspace) *</span>
<select class={inputCls} bind:value={tenantId}>
<option value={null} disabled>Selecciona…</option>
{#each tenants as t (t.id)}
<option value={t.id}>{t.name} ({t.slug})</option>
{/each}
</select>
</label>
</div>
<div class="mt-6 flex justify-end border-t pt-4">
<Button onclick={createCompany} disabled={submitting}>
{submitting ? 'Creando…' : 'Crear compañía'}
</Button>
</div>
</Card.Content>
</Card.Root>
<Card.Root>
<Card.Header>
<Card.Title>Compañías ({companies.length})</Card.Title>
<Card.Description>Empresas a las que tienes acceso.</Card.Description>
</Card.Header>
<Card.Content>
{#if loading}
<p class="text-sm text-muted-foreground">Cargando…</p>
{:else if companies.length === 0}
<p class="text-sm text-muted-foreground">Aún no tienes compañías. Crea una arriba.</p>
{:else}
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="text-left text-muted-foreground">
<tr class="border-b">
<th class="py-2 pr-4 font-medium">Nombre</th>
<th class="py-2 pr-4 font-medium">RFC</th>
<th class="py-2 pr-4 font-medium">Tenant</th>
<th class="py-2 pr-4 font-medium">Activa</th>
</tr>
</thead>
<tbody>
{#each companies as c (c.id)}
<tr class="border-b last:border-0">
<td class="py-2 pr-4">{c.name}</td>
<td class="py-2 pr-4 font-mono text-xs">{c.rfc ?? '—'}</td>
<td class="py-2 pr-4">{c.tenant_id}</td>
<td class="py-2 pr-4">
{#if companyStore.activeCompany?.id === c.id}
<span class="rounded-full bg-emerald-500/15 px-2 py-0.5 text-xs text-emerald-600">activa</span>
{:else}
<button class="text-xs text-primary hover:underline" onclick={() => companyStore.setActiveCompany(c)}>usar</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
</Card.Content>
</Card.Root>
</div>