From 9fae110c11012f3f1105ee0c7256a27283a2a879 Mon Sep 17 00:00:00 2001 From: AlexeerCT Date: Wed, 3 Jun 2026 15:02:02 +0000 Subject: [PATCH] fix/AS-487-merge-main-development (#490) Co-authored-by: Galindo97 Reviewed-on: https://git.aduanasoft.com/ADUANASOFT/anexo76/pulls/490 Co-authored-by: AlexeerCT Co-committed-by: AlexeerCT --- .../versions/g2h3i4j5k6l7_add_invite_codes.py | 54 +++ .../v1/modules/core/invite_codes/__init__.py | 0 .../api/v1/modules/core/invite_codes/dto.py | 51 +++ .../v1/modules/core/invite_codes/models.py | 49 +++ .../v1/modules/core/invite_codes/routes.py | 149 ++++++++ .../v1/modules/core/invite_codes/service.py | 321 ++++++++++++++++++ backend/api/v1/modules/core/router.py | 2 + docker-compose.yml | 2 +- .../src/lib/api/dashboard/invite-codes.ts | 60 ++++ .../src/routes/dashboard/users/+page.svelte | 223 +++++++++++- frontend/src/routes/join/+page.server.ts | 85 +++++ frontend/src/routes/join/+page.svelte | 149 ++++++++ 12 files changed, 1143 insertions(+), 2 deletions(-) create mode 100644 backend/alembic/versions/g2h3i4j5k6l7_add_invite_codes.py create mode 100644 backend/api/v1/modules/core/invite_codes/__init__.py create mode 100644 backend/api/v1/modules/core/invite_codes/dto.py create mode 100644 backend/api/v1/modules/core/invite_codes/models.py create mode 100644 backend/api/v1/modules/core/invite_codes/routes.py create mode 100644 backend/api/v1/modules/core/invite_codes/service.py create mode 100644 frontend/src/lib/api/dashboard/invite-codes.ts create mode 100644 frontend/src/routes/join/+page.server.ts create mode 100644 frontend/src/routes/join/+page.svelte diff --git a/backend/alembic/versions/g2h3i4j5k6l7_add_invite_codes.py b/backend/alembic/versions/g2h3i4j5k6l7_add_invite_codes.py new file mode 100644 index 00000000..3fe6e712 --- /dev/null +++ b/backend/alembic/versions/g2h3i4j5k6l7_add_invite_codes.py @@ -0,0 +1,54 @@ +"""add invite codes + +Revision ID: g2h3i4j5k6l7 +Revises: e1f2a3b4c5d6 +Create Date: 2026-06-02 00:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "g2h3i4j5k6l7" +down_revision: str = "e1f2a3b4c5d6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "invite_codes", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("code", sa.String(length=16), nullable=False), + sa.Column("tenant_slug", sa.String(length=100), nullable=False), + sa.Column("company_id", sa.Integer(), nullable=True), + sa.Column("role", sa.String(length=50), nullable=False, server_default="user"), + sa.Column("max_uses", sa.Integer(), nullable=True), + sa.Column("uses_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_by", sa.String(length=255), nullable=False), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"), + sa.Column("created_at", sa.DateTime(), nullable=True, server_default=sa.text("now()")), + sa.Column("updated_at", sa.DateTime(), nullable=True, server_default=sa.text("now()")), + sa.PrimaryKeyConstraint("id"), + schema="core", + ) + op.create_index("ix_core_invite_codes_id", "invite_codes", ["id"], schema="core") + op.create_index( + "ix_core_invite_codes_code", "invite_codes", ["code"], unique=True, schema="core" + ) + op.create_index( + "ix_core_invite_codes_tenant_slug", "invite_codes", ["tenant_slug"], schema="core" + ) + op.create_index( + "ix_core_invite_codes_company_id", "invite_codes", ["company_id"], schema="core" + ) + + +def downgrade() -> None: + op.drop_index("ix_core_invite_codes_company_id", table_name="invite_codes", schema="core") + op.drop_index("ix_core_invite_codes_tenant_slug", table_name="invite_codes", schema="core") + op.drop_index("ix_core_invite_codes_code", table_name="invite_codes", schema="core") + op.drop_index("ix_core_invite_codes_id", table_name="invite_codes", schema="core") + op.drop_table("invite_codes", schema="core") diff --git a/backend/api/v1/modules/core/invite_codes/__init__.py b/backend/api/v1/modules/core/invite_codes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/core/invite_codes/dto.py b/backend/api/v1/modules/core/invite_codes/dto.py new file mode 100644 index 00000000..379ccbe2 --- /dev/null +++ b/backend/api/v1/modules/core/invite_codes/dto.py @@ -0,0 +1,51 @@ +"""DTOs para el módulo de códigos de invitación.""" + +from datetime import datetime +from typing import List, Optional + +from pydantic import BaseModel, Field + + +class CreateInviteCodeDTO(BaseModel): + company_id: Optional[int] = Field( + None, description="Empresa destino (None = cualquier empresa del tenant)" + ) + role: str = Field("user", description="Rol asignado al canjear el código") + max_uses: Optional[int] = Field(None, description="Usos máximos (None = ilimitado)") + expires_at: Optional[datetime] = Field(None, description="Expiración (None = sin expiración)") + + +class InviteCodeResponseDTO(BaseModel): + id: int + code: str + tenant_slug: str + company_id: Optional[int] + role: str + max_uses: Optional[int] + uses_count: int + expires_at: Optional[datetime] + is_active: bool + created_by: str + created_at: datetime + + class Config: + from_attributes = True + + +class ValidateInviteCodeResponseDTO(BaseModel): + code: str + tenant_slug: str + company_id: Optional[int] + role: str + remaining_uses: Optional[int] = Field( + None, description="Usos restantes; None = ilimitado" + ) + expires_at: Optional[datetime] + + +class ConsumeInviteCodeResponseDTO(BaseModel): + success: bool + message: str + tenant_slug: str + company_id: Optional[int] + role: str diff --git a/backend/api/v1/modules/core/invite_codes/models.py b/backend/api/v1/modules/core/invite_codes/models.py new file mode 100644 index 00000000..2a1bcdde --- /dev/null +++ b/backend/api/v1/modules/core/invite_codes/models.py @@ -0,0 +1,49 @@ +"""Modelo de código de invitación reutilizable para registro en Anexo76.""" + +from datetime import datetime +from typing import Optional + +from api.v1.common.base_models import BaseTimestampMixin +from core.database import Base +from sqlalchemy import Boolean, DateTime, Integer, String +from sqlalchemy.orm import Mapped, mapped_column + + +class InviteCode(Base, BaseTimestampMixin): + """ + Código corto multiuso para invitar usuarios a un tenant/empresa. + A diferencia de InviteToken (único por email), un InviteCode es + compartible: se distribuye como cadena de 8 chars y puede + ser canjeado por múltiples usuarios hasta agotar max_uses. + """ + + __tablename__ = "invite_codes" + __table_args__ = {"schema": "core", "extend_existing": True} + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + + # Código legible generado automáticamente (8 chars, sin ambigüedad 0/O/I/l) + code: Mapped[str] = mapped_column(String(16), unique=True, nullable=False, index=True) + + # Tenant destino — el usuario debe unirse a este workspace en el Hub + tenant_slug: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + + # Empresa destino específica (None = cualquier empresa del tenant) + company_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True) + + # Rol con el que se provisiona el usuario al canjear + role: Mapped[str] = mapped_column(String(50), nullable=False, server_default="user") + + # Control de uso + max_uses: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + uses_count: Mapped[int] = mapped_column(Integer, nullable=False, server_default="0", default=0) + + # Expiración (None = sin expiración) + expires_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True) + + # keycloak_user_id del admin que generó el código + created_by: Mapped[str] = mapped_column(String(255), nullable=False) + + is_active: Mapped[bool] = mapped_column( + Boolean, nullable=False, server_default="true", default=True + ) diff --git a/backend/api/v1/modules/core/invite_codes/routes.py b/backend/api/v1/modules/core/invite_codes/routes.py new file mode 100644 index 00000000..674ef3cd --- /dev/null +++ b/backend/api/v1/modules/core/invite_codes/routes.py @@ -0,0 +1,149 @@ +"""Rutas para gestión de códigos de invitación.""" + +import logging +from typing import List, Optional + +from core.database import get_core_db +from core.security import get_current_user, validate_access_to_resource +from fastapi import APIRouter, Depends, Query +from fastapi.security import HTTPBearer +from sqlalchemy.orm import Session + +from .dto import ( + ConsumeInviteCodeResponseDTO, + CreateInviteCodeDTO, + InviteCodeResponseDTO, + ValidateInviteCodeResponseDTO, +) +from .service import InviteCodeService + +router = APIRouter(prefix="/invite-codes", tags=["Invite Codes"]) +_bearer = HTTPBearer() + +logger = logging.getLogger(__name__) + + +@router.post("", response_model=InviteCodeResponseDTO, status_code=201) +async def create_invite_code( + data: CreateInviteCodeDTO, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), + credentials=Depends(_bearer), +): + """ + Genera un código de invitación reutilizable. + Requiere permiso user.create sobre la empresa (o ser admin del tenant). + """ + company_id = data.company_id + if company_id is not None: + validate_access_to_resource( + db, + company_id, + current_user, + required_permissions=["user.create"], + ) + else: + # Invitación a nivel tenant: solo roles admin del tenant + roles = set(current_user.get("roles") or []) + if "admin" not in roles and "hub_admin" not in roles: + from fastapi import HTTPException + raise HTTPException( + status_code=403, + detail="Se requiere rol admin para crear invitaciones de nivel tenant", + ) + + tenant_slug: str = current_user.get("tenant_slug") or "" + created_by: str = current_user.get("sub") or "" + + service = InviteCodeService(db) + return await service.create_code( + data=data, + created_by=created_by, + tenant_slug=tenant_slug, + user_access_token=credentials.credentials, + ) + + +@router.get("", response_model=List[InviteCodeResponseDTO]) +def list_invite_codes( + company_id: Optional[int] = Query(None, description="Filtrar por empresa"), + include_inactive: bool = Query(False, description="Incluir códigos inactivos/agotados"), + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Lista los códigos de invitación del tenant. + Filtra opcionalmente por empresa. + """ + if company_id is not None: + validate_access_to_resource( + db, + company_id, + current_user, + required_permissions=["user.create"], + ) + else: + roles = set(current_user.get("roles") or []) + if "admin" not in roles and "hub_admin" not in roles: + from fastapi import HTTPException + raise HTTPException(status_code=403, detail="Se requiere rol admin") + + tenant_slug: str = current_user.get("tenant_slug") or "" + service = InviteCodeService(db) + return service.list_codes( + tenant_slug=tenant_slug, + company_id=company_id, + include_inactive=include_inactive, + ) + + +@router.delete("/{code}", status_code=204) +def revoke_invite_code( + code: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """Revoca (desactiva) un código de invitación.""" + roles = set(current_user.get("roles") or []) + if "admin" not in roles and "hub_admin" not in roles: + from fastapi import HTTPException + raise HTTPException(status_code=403, detail="Se requiere rol admin") + + tenant_slug: str = current_user.get("tenant_slug") or "" + service = InviteCodeService(db) + service.revoke_code(code=code, tenant_slug=tenant_slug) + + +@router.get("/validate/{code}", response_model=ValidateInviteCodeResponseDTO) +def validate_invite_code( + code: str, + db: Session = Depends(get_core_db), +): + """ + Valida un código de invitación sin consumirlo. + Endpoint público — no requiere autenticación. + """ + service = InviteCodeService(db) + return service.validate(code=code) + + +@router.post("/consume/{code}", response_model=ConsumeInviteCodeResponseDTO) +def consume_invite_code( + code: str, + db: Session = Depends(get_core_db), + current_user: dict = Depends(get_current_user), +): + """ + Canjea el código: incrementa el contador de usos y crea la relación + UserTenant (usuario ↔ empresa) si el código tiene company_id definido. + Requiere autenticación. + """ + keycloak_user_id: str = current_user.get("sub") or "" + tenant_id: int = current_user.get("tenant_id") or 0 + + service = InviteCodeService(db) + return service.consume( + code=code, + keycloak_user_id=keycloak_user_id, + tenant_id=tenant_id, + ) diff --git a/backend/api/v1/modules/core/invite_codes/service.py b/backend/api/v1/modules/core/invite_codes/service.py new file mode 100644 index 00000000..955275ef --- /dev/null +++ b/backend/api/v1/modules/core/invite_codes/service.py @@ -0,0 +1,321 @@ +"""Servicio de códigos de invitación reutilizables para Anexo76.""" + +import logging +import random +import string +from datetime import datetime, timezone +from typing import List, Optional + +import httpx +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from core.config import settings +from .dto import ( + ConsumeInviteCodeResponseDTO, + CreateInviteCodeDTO, + InviteCodeResponseDTO, + ValidateInviteCodeResponseDTO, +) +from .models import InviteCode + +logger = logging.getLogger(__name__) + +# Charset sin caracteres ambiguos (0/O/I/l/1) +_CODE_CHARSET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789" +_CODE_LENGTH = 8 + + +def _generate_code() -> str: + return "".join(random.choices(_CODE_CHARSET, k=_CODE_LENGTH)) + + +def _is_valid(invite: InviteCode) -> bool: + """True si el código es canjeable en este momento.""" + if not invite.is_active: + return False + if invite.max_uses is not None and invite.uses_count >= invite.max_uses: + return False + if invite.expires_at and invite.expires_at < datetime.now(timezone.utc): + return False + return True + + +class InviteCodeService: + def __init__(self, db: Session): + self.db = db + + async def create_code( + self, + data: CreateInviteCodeDTO, + created_by: str, + tenant_slug: str, + user_access_token: str = "", + ) -> InviteCodeResponseDTO: + from api.v1.modules.core.tenants.models import Tenant + + tenant = ( + self.db.query(Tenant) + .filter(Tenant.slug == tenant_slug, Tenant.is_active == True) + .first() + ) + if not tenant: + raise HTTPException(status_code=404, detail="Tenant no encontrado") + + if data.company_id is not None: + from api.v1.modules.a76.general_catalogs.company.models import Company + + company = ( + self.db.query(Company) + .filter( + Company.id == data.company_id, + Company.tenant_id == tenant.id, + ) + .first() + ) + if not company: + raise HTTPException( + status_code=404, + detail="Empresa no encontrada o no pertenece al tenant", + ) + + # Genera código único; reintenta si hay colisión (improbable) + for _ in range(5): + code = _generate_code() + if not self.db.query(InviteCode).filter(InviteCode.code == code).first(): + break + else: + raise HTTPException( + status_code=500, + detail="No se pudo generar un código único, intenta de nuevo", + ) + + invite = InviteCode( + code=code, + tenant_slug=tenant_slug, + company_id=data.company_id, + role=data.role, + max_uses=data.max_uses, + uses_count=0, + expires_at=data.expires_at, + created_by=created_by, + is_active=True, + ) + self.db.add(invite) + self.db.commit() + self.db.refresh(invite) + + # Registrar el mismo código en el Hub para que funcione en workspace /join + await self._sync_code_to_hub( + code=code, + tenant_slug=tenant_slug, + data=data, + user_access_token=user_access_token, + ) + + return InviteCodeResponseDTO.model_validate(invite) + + async def _sync_code_to_hub( + self, + code: str, + tenant_slug: str, + data: CreateInviteCodeDTO, + user_access_token: str, + ) -> None: + """ + Crea el mismo código en Hub's workspace_invite_codes con allowed_systems=['a76']. + Best-effort: si falla, el código sigue válido en A76 pero no en workspace. + """ + if not user_access_token: + logger.warning( + "[invite_code] Sin token para sincronizar '%s' al Hub — " + "el código NO funcionará en workspace /join", + code, + ) + return + + hub_url = getattr(settings, "HUB_URL", "").rstrip("/") + if not hub_url: + logger.warning("[invite_code] HUB_URL no configurado — código '%s' no sincronizado", code) + return + + payload: dict = { + "code": code, + "allowed_systems": ["anexo76"], + "role": data.role, + } + if data.max_uses is not None: + payload["max_uses"] = data.max_uses + if data.expires_at is not None: + payload["expires_at"] = data.expires_at.isoformat() + + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post( + f"{hub_url}/api/v1/hub/invite-codes/{tenant_slug}", + json=payload, + headers={"Authorization": f"Bearer {user_access_token}"}, + ) + if resp.status_code in (200, 201): + logger.info( + "[invite_code] Código '%s' sincronizado al Hub (tenant=%s)", code, tenant_slug + ) + elif resp.status_code == 409: + logger.info( + "[invite_code] Código '%s' ya existe en Hub (tenant=%s) — OK", code, tenant_slug + ) + else: + logger.error( + "[invite_code] Hub sync falló code='%s' status=%s body=%s", + code, resp.status_code, resp.text[:300], + ) + except Exception as exc: + logger.error("[invite_code] Hub sync excepción code='%s': %s", code, exc) + + def list_codes( + self, + tenant_slug: str, + company_id: Optional[int] = None, + include_inactive: bool = False, + ) -> List[InviteCodeResponseDTO]: + q = self.db.query(InviteCode).filter(InviteCode.tenant_slug == tenant_slug) + + if company_id is not None: + q = q.filter(InviteCode.company_id == company_id) + + if not include_inactive: + q = q.filter(InviteCode.is_active == True) + + invites = q.order_by(InviteCode.created_at.desc()).all() + return [InviteCodeResponseDTO.model_validate(i) for i in invites] + + def revoke_code(self, code: str, tenant_slug: str) -> None: + invite = ( + self.db.query(InviteCode) + .filter(InviteCode.code == code, InviteCode.tenant_slug == tenant_slug) + .first() + ) + if not invite: + raise HTTPException(status_code=404, detail="Código de invitación no encontrado") + + invite.is_active = False + self.db.commit() + + def validate(self, code: str) -> ValidateInviteCodeResponseDTO: + """Valida el código sin consumirlo. Devuelve 403 genérico si no es válido.""" + invite = self.db.query(InviteCode).filter(InviteCode.code == code).first() + + if not invite or not _is_valid(invite): + raise HTTPException( + status_code=403, + detail="Código de invitación inválido o expirado", + ) + + remaining: Optional[int] = None + if invite.max_uses is not None: + remaining = invite.max_uses - invite.uses_count + + return ValidateInviteCodeResponseDTO( + code=invite.code, + tenant_slug=invite.tenant_slug, + company_id=invite.company_id, + role=invite.role, + remaining_uses=remaining, + expires_at=invite.expires_at, + ) + + def consume( + self, + code: str, + keycloak_user_id: str, + tenant_id: int, + ) -> ConsumeInviteCodeResponseDTO: + """ + Canjea el código: + - Incrementa uses_count. + - Si company_id está definido, crea UserTenant (usuario ↔ empresa). + - Desactiva el código si se agotaron los usos. + """ + invite = self.db.query(InviteCode).filter(InviteCode.code == code).first() + + if not invite or not _is_valid(invite): + raise HTTPException( + status_code=403, + detail="Código de invitación inválido o expirado", + ) + + if invite.company_id is not None: + self._ensure_user_tenant( + keycloak_user_id=keycloak_user_id, + tenant_id=tenant_id, + company_id=invite.company_id, + role=invite.role, + ) + + invite.uses_count += 1 + if invite.max_uses is not None and invite.uses_count >= invite.max_uses: + invite.is_active = False + + self.db.commit() + + logger.info( + "[invite_code] canjeado code=%s user=%s company_id=%s uses=%d/%s", + invite.code, + keycloak_user_id, + invite.company_id, + invite.uses_count, + invite.max_uses or "∞", + ) + + return ConsumeInviteCodeResponseDTO( + success=True, + message="Código canjeado correctamente", + tenant_slug=invite.tenant_slug, + company_id=invite.company_id, + role=invite.role, + ) + + def _ensure_user_tenant( + self, + keycloak_user_id: str, + tenant_id: int, + company_id: int, + role: str, + ) -> None: + """Crea la fila UserTenant si el usuario aún no tiene acceso a la empresa.""" + from sqlalchemy.exc import IntegrityError + from api.v1.modules.core.user_tenant.models import UserTenant + + existing = ( + self.db.query(UserTenant) + .filter( + UserTenant.keycloak_user_id == keycloak_user_id, + UserTenant.tenant_id == tenant_id, + UserTenant.company_id == company_id, + ) + .first() + ) + if existing: + if not existing.is_active: + existing.is_active = True + existing.role = role + self.db.commit() + return + + user_tenant = UserTenant( + keycloak_user_id=keycloak_user_id, + tenant_id=tenant_id, + company_id=company_id, + role=role, + is_active=True, + ) + self.db.add(user_tenant) + try: + self.db.commit() + except IntegrityError: + self.db.rollback() + logger.warning( + "[invite_code] race: UserTenant ya existe user=%s company=%d", + keycloak_user_id, + company_id, + ) diff --git a/backend/api/v1/modules/core/router.py b/backend/api/v1/modules/core/router.py index d122e312..c479e1d6 100644 --- a/backend/api/v1/modules/core/router.py +++ b/backend/api/v1/modules/core/router.py @@ -1,4 +1,5 @@ from .auth.routes import router as auth_router +from .invite_codes.routes import router as invite_codes_router from .invites.routes import router as invites_router from .licenses.routes import router as licenses_router from .permissions.routes import router as permissions_router @@ -14,6 +15,7 @@ router = APIRouter() router.include_router(auth_router) router.include_router(invites_router, prefix="/core", tags=["core / invites"]) +router.include_router(invite_codes_router, prefix="/core", tags=["core / invite-codes"]) router.include_router(tenants_router, prefix="/core", tags=["core / tenants"]) router.include_router(user_tenant_router, prefix="/core", tags=["core / user-tenants"]) router.include_router(users_router, prefix="/core", tags=["core / users"]) diff --git a/docker-compose.yml b/docker-compose.yml index b948675a..ad7e7cda 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -315,5 +315,5 @@ networks: driver: bridge hub-net: - external: false + external: true name: aduanasoft-hub_default diff --git a/frontend/src/lib/api/dashboard/invite-codes.ts b/frontend/src/lib/api/dashboard/invite-codes.ts new file mode 100644 index 00000000..d06d3174 --- /dev/null +++ b/frontend/src/lib/api/dashboard/invite-codes.ts @@ -0,0 +1,60 @@ +import { api } from '$lib/api'; + +export interface InviteCode { + id: number; + code: string; + tenant_slug: string; + company_id: number | null; + role: string; + max_uses: number | null; + uses_count: number; + expires_at: string | null; + is_active: boolean; + created_by: string; + created_at: string; +} + +export interface CreateInviteCodeRequest { + company_id?: number | null; + role: string; + max_uses?: number | null; + expires_at?: string | null; +} + +export interface ValidateInviteCodeResponse { + code: string; + tenant_slug: string; + company_id: number | null; + role: string; + remaining_uses: number | null; + expires_at: string | null; +} + +export const inviteCodesAPI = { + async list(companyId: number, includeInactive = false): Promise { + const params = new URLSearchParams({ company_id: companyId.toString() }); + if (includeInactive) params.set('include_inactive', 'true'); + const response = await api.get(`/v1/core/invite-codes?${params}`); + if (response.error) throw new Error(response.error); + return response.data!; + }, + + async create(data: CreateInviteCodeRequest): Promise { + const response = await api.post('/v1/core/invite-codes', data); + if (response.error) throw new Error(response.error); + return response.data!; + }, + + async revoke(code: string): Promise { + const response = await api.delete(`/v1/core/invite-codes/${code}`); + if (response.error) throw new Error(response.error); + }, + + async validate(code: string): Promise { + const response = await api.get( + `/v1/core/invite-codes/validate/${code}` + ); + if (response.error) throw new Error(response.error); + return response.data!; + } +}; diff --git a/frontend/src/routes/dashboard/users/+page.svelte b/frontend/src/routes/dashboard/users/+page.svelte index 856fc0ce..586a327c 100644 --- a/frontend/src/routes/dashboard/users/+page.svelte +++ b/frontend/src/routes/dashboard/users/+page.svelte @@ -1,6 +1,7 @@ + +
+
+ + +
+
+ + + +
+
+

Anexo 76

+

Unirse a una empresa

+

Ingresa el código que te compartió tu administrador.

+
+
+ + + {#if data.step === 'input'} +
+
+ + {#if error} +

{error}

+ {/if} +
+ +
+ + + {:else if data.step === 'preview' && data.codeInfo} +
+ +
+
+ Código + {data.code} +
+
+ Workspace + {data.codeInfo.tenant_slug} +
+
+ Rol asignado + + {data.codeInfo.role} + +
+ {#if data.codeInfo.remaining_uses !== null} +
+ Usos restantes + {data.codeInfo.remaining_uses} +
+ {/if} + {#if data.codeInfo.expires_at} +
+ Vence + + {new Date(data.codeInfo.expires_at).toLocaleDateString('es-MX', { day: '2-digit', month: 'short', year: 'numeric' })} + +
+ {/if} +
+ + {#if error} +

+ {error} +

+ {/if} + +
+ + +
+ + Usar otro código + +
+ + + {:else if data.step === 'preview'} +
+

+ {error ?? 'Código inválido, expirado o agotado. Verifica con tu administrador.'} +

+ ← Intentar con otro código +
+ + + {:else if data.step === 'success'} +
+
+
+ + + +
+
+
+

¡Bienvenido!

+

+ Te uniste al workspace {data.result?.tenant_slug} + {#if data.result?.company_id} + como {data.result?.role}. + {/if} +

+
+ + Ir al dashboard → + +
+ {/if} + +
+