diff --git a/backend/api/v1/modules/core/auth/routes.py b/backend/api/v1/modules/core/auth/routes.py index 5c54780..e313a7e 100644 --- a/backend/api/v1/modules/core/auth/routes.py +++ b/backend/api/v1/modules/core/auth/routes.py @@ -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,60 +438,145 @@ 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 [] - 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). + # 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) + exists = db.execute( + text("SELECT id FROM a76.company WHERE tenant_id = :tid ORDER BY id LIMIT 1"), + {"tid": tenant_id}, + ).fetchone() + if not exists: + tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first() + default_name = ( + (tenant.name if tenant else None) + or current_user.get("tenant_slug") + or "Mi empresa" + ) + created = db.execute( + text("INSERT INTO a76.company (tenant_id, name) VALUES (:tid, :name) RETURNING id"), + {"tid": tenant_id, "name": default_name}, + ).fetchone() + db.execute(text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))")) + db.commit() + 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, int(created[0])) + except Exception as exc: + 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 id, name, rfc, logo FROM a76.company WHERE tenant_id = :tid ORDER BY id"), - {"tid": tenant_id}, + 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() - if not rows: - tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first() - default_name = ( - (tenant.name if tenant else None) - or current_user.get("tenant_slug") - or "Mi empresa" - ) - created = db.execute( - text( - "INSERT INTO a76.company (tenant_id, name) VALUES (:tid, :name) " - "RETURNING id, name, rfc, logo" - ), - {"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]) - if user_id: - try: - _ensure_user_tenant_for_company(db, str(user_id), tenant_id, cid) - 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, + 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} diff --git a/backend/core/security.py b/backend/core/security.py index 9127316..ced790d 100644 --- a/backend/core/security.py +++ b/backend/core/security.py @@ -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 diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 6792c2b..50ca5a1 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -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', diff --git a/frontend/src/lib/stores/company.svelte.ts b/frontend/src/lib/stores/company.svelte.ts index 4b8794a..4618e4b 100644 --- a/frontend/src/lib/stores/company.svelte.ts +++ b/frontend/src/lib/stores/company.svelte.ts @@ -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) { diff --git a/frontend/src/routes/api-sveltekit/company/set-active/+server.ts b/frontend/src/routes/api-sveltekit/company/set-active/+server.ts index 3820adf..18326e1 100644 --- a/frontend/src/routes/api-sveltekit/company/set-active/+server.ts +++ b/frontend/src/routes/api-sveltekit/company/set-active/+server.ts @@ -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 }); diff --git a/frontend/src/routes/dashboard/companias/+page.svelte b/frontend/src/routes/dashboard/companias/+page.svelte new file mode 100644 index 0000000..8ea54f7 --- /dev/null +++ b/frontend/src/routes/dashboard/companias/+page.svelte @@ -0,0 +1,157 @@ + + +
+ 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. +
+Cargando…
+ {:else if companies.length === 0} +Aún no tienes compañías. Crea una arriba.
+ {:else} +| Nombre | +RFC | +Tenant | +Activa | +
|---|---|---|---|
| {c.name} | +{c.rfc ?? '—'} | +{c.tenant_id} | ++ {#if companyStore.activeCompany?.id === c.id} + activa + {:else} + + {/if} + | +