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:
@@ -24,6 +24,12 @@ from .dto import (
|
||||
)
|
||||
from .service import AuthService
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
||||
security = HTTPBearer()
|
||||
|
||||
@@ -432,25 +438,19 @@ async def get_my_companies(
|
||||
)
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
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)
|
||||
if not tenant_id:
|
||||
# Usuario sin tenant (p. ej. hub_admin global): no hay compañía que resolver.
|
||||
return []
|
||||
|
||||
# 1) Usuario CON tenant en el token (flujo normal): autocrea una compañía por
|
||||
# defecto en el primer acceso y asegura la membresía.
|
||||
if tenant_id:
|
||||
tenant_id = int(tenant_id)
|
||||
|
||||
# La compañía es una entidad REAL del CRM (a76.company, heredada de Anexo76).
|
||||
# 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"),
|
||||
exists = db.execute(
|
||||
text("SELECT id FROM a76.company WHERE tenant_id = :tid ORDER BY id LIMIT 1"),
|
||||
{"tid": tenant_id},
|
||||
).fetchall()
|
||||
|
||||
if not rows:
|
||||
).fetchone()
|
||||
if not exists:
|
||||
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||
default_name = (
|
||||
(tenant.name if tenant else None)
|
||||
@@ -458,34 +458,125 @@ async def get_my_companies(
|
||||
or "Mi empresa"
|
||||
)
|
||||
created = db.execute(
|
||||
text(
|
||||
"INSERT INTO a76.company (tenant_id, name) VALUES (:tid, :name) "
|
||||
"RETURNING id, name, rfc, logo"
|
||||
),
|
||||
text("INSERT INTO a76.company (tenant_id, name) VALUES (:tid, :name) RETURNING id"),
|
||||
{"tid": tenant_id, "name": default_name},
|
||||
).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.commit()
|
||||
rows = [created]
|
||||
_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])
|
||||
logger.info("Compañía por defecto creada para tenant=%s: id=%s", tenant_id, created[0])
|
||||
if user_id:
|
||||
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:
|
||||
_log.warning("no se pudo asegurar user_tenant (no bloquea): %s", exc)
|
||||
companies.append({
|
||||
"id": cid,
|
||||
"name": r[1] or "Mi empresa",
|
||||
"tenant_id": tenant_id,
|
||||
logger.warning("no se pudo asegurar user_tenant (no bloquea): %s", exc)
|
||||
|
||||
# 2) Compañías por MEMBRESÍA (user_tenants ∪ user_company_roles) → funciona
|
||||
# también para hub_admin sin tenant en el token: verá las compañías que creó
|
||||
# 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],
|
||||
"logo": r[3],
|
||||
"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}
|
||||
|
||||
@@ -666,6 +666,20 @@ def validate_access_to_resource(
|
||||
|
||||
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)
|
||||
# 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
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Ship,
|
||||
Receipt,
|
||||
Building2,
|
||||
Building,
|
||||
} from '@lucide/svelte';
|
||||
|
||||
export type SystemContext = 'fixed_asset' | 'inventory';
|
||||
@@ -70,6 +71,11 @@ export function getNavMain(): NavMainItem[] {
|
||||
{ title: 'Facturas y cobranza', url: '/dashboard/fin/facturas' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Compañías',
|
||||
url: '/dashboard/companias',
|
||||
icon: Building,
|
||||
},
|
||||
{
|
||||
title: 'Workspace',
|
||||
url: '/dashboard/workspace/organizaciones',
|
||||
|
||||
@@ -158,7 +158,9 @@ class CompanyStore {
|
||||
headers: {
|
||||
'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'
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,22 +6,28 @@ import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies, request }) => {
|
||||
try {
|
||||
const { companyId } = await request.json();
|
||||
const body = await request.json();
|
||||
const companyId = body?.companyId;
|
||||
const tenantId = body?.tenantId;
|
||||
|
||||
if (!companyId || typeof companyId !== 'number') {
|
||||
return json({ error: 'Invalid company ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Establecer la cookie desde el servidor
|
||||
cookies.set('active_company_id', companyId.toString(), {
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 días
|
||||
sameSite: 'lax',
|
||||
httpOnly: false, // Permitir acceso desde JavaScript
|
||||
secure: process.env.NODE_ENV === 'production'
|
||||
});
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
const base = { path: '/', maxAge: 60 * 60 * 24 * 30, sameSite: 'lax' as const, secure: isProd };
|
||||
|
||||
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) {
|
||||
console.error('Error setting active company:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
|
||||
157
frontend/src/routes/dashboard/companias/+page.svelte
Normal file
157
frontend/src/routes/dashboard/companias/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user