Cargando…

Esto está tardando más de lo esperado.

Volver al centro de ayuda

El contexto del equipo

Cómo una petición sabe en qué equipo está, cómo se elige el guard de permisos y qué hacer en un job, un comando o un componente de Livewire.

Cada petición bajo /team corre dentro de un equipo: los registros que lista, los permisos que revisa, la cuenta de cobros a la que le carga. Esta guía explica la pieza de código que decide cuál equipo, de dónde se lee esa decisión y cómo tomar tú mismo la misma decisión donde no existe una petición. Léela antes de escribir una pantalla, una policy o un job que toque datos del equipo.

Dónde se guarda el equipo

Lugar Qué contiene
users.current_team_id El equipo que la persona abrió por última vez. Se limpia cuando ya no pertenece a él.
La cookie team_id Lo mismo, en el navegador, para que el selector pueda omitirse en la siguiente visita. Se olvida al cerrar sesión.
session('team_id') El equipo de la petición actual, 0 cuando no hay. Lo escribe el middleware en cada petición.
El team id de permisos de Spatie El equipo cuyos roles y permisos se están consultando. getPermissionsTeamId() lo lee, setPermissionsTeamId() lo escribe.

El helper que todo lo demás usa:

team(): ?Team   // auth()->user()->current_team, o null

Los dos middleware

App\Http\Middleware\TeamMiddleware, alias team, corre en cada ruta bajo /team:

  1. Carga el current_team de la persona, desde la cookie cuando ésta nombra un equipo al que pertenece, desde la columna en cualquier otro caso.
  2. Cuando el equipo no existe o la persona no es miembro, limpia current_team_id y redirige a /app.
  3. Llama a setPermissionsTeamId($team->id) y escribe session(['team_id' => $team->id]).

App\Http\Middleware\NoTeamMiddleware, alias noTeam, corre en /app y en /admin: pone el team id de permisos y la sesión en 0, para que el panel de administración consulte los roles globales aunque la persona tenga un equipo abierto en otra pestaña.

Ambos están registrados en bootstrap/app.php. Una ruta que lee datos del equipo y no lleva ninguno de los dos es un error: sin team, getPermissionsTeamId() es 0 y cada verificación en el guard team falla.

Los grupos de rutas

// El selector: con sesión iniciada, todavía sin equipo.
Route::prefix('app')->middleware(['auth', 'security', 'noTeam'])->name('app.')->group(...);

// Cobros: dentro de un equipo, sin exigir suscripción, para que un equipo vencido pueda pagar.
Route::prefix('team')->middleware(['auth', 'security', 'team'])->name('team.')->group(...);

// El espacio de trabajo: dentro de un equipo, con suscripción obligatoria.
Route::prefix('team')->middleware(['auth', 'security', 'team', 'ensure.free.subscription', 'ensure.subscribed'])->name('team.')->group(...);

// El panel de administración: contexto global, rol admin.
Route::prefix('admin')->middleware(['auth', 'security', 'noTeam', 'role:admin'])->name('admin.')->group(...);

Registra una pantalla de equipo nueva en el tercer grupo. Ponla en el segundo sólo cuando tenga que funcionar para un equipo cuya suscripción terminó.

Cómo se elige el guard

Cada policy extiende App\Policies\BasePolicy, cuyo hook before() lee el contexto:

public function before(User $user)
{
    if (getPermissionsTeamId() > 0) {
        $this->defaultGuard = 'team';
    }
}

A partir de ahí cada verificación de la policy pasa ese guard:

$user->hasPermissionTo("retrieve {$this->name}", $this->defaultGuard);

Así la misma clase de policy funciona en el panel de administración y dentro de un equipo. Los métodos view, update y delete también llaman a validateTeamOnObject, que compara el team_id del registro con el contexto y rechaza un registro de otro equipo; sin contexto, en el panel de administración, la verificación pasa.

App\Policies\UserTeamPolicy agrega dos cosas encima: el propietario del equipo pasa cada verificación, y create rechaza cuando team()->reachedLimit('users'), con un mensaje que apunta a la página de planes.

Cuando llamas a Spatie directamente en lugar de a una policy, nombra el guard, porque el guard predeterminado del usuario es web:

auth()->user()->hasPermissionTo('edit settings', 'team');
auth()->user()->hasRole('administrator', 'team');

Fuera de una petición

Un job en cola, un comando de artisan o una tarea programada no tiene middleware, así que el contexto es lo último que se haya puesto, normalmente nada.

Una actualización de Livewire es el mismo caso, y es el que agarra desprevenido a cualquiera. Va por POST a /livewire/update, que no es una ruta /team, así que el middleware nunca corre para ella y el contexto es 0. El primer render de la página sí es una petición normal y sí pasa por el middleware, así que todo se ve bien hasta que el componente se actualiza.

Todo componente de Livewire que revise un permiso del guard team tiene que poner el contexto él mismo, tanto un componente de página completa como un modal. Vuelve a leerlo de la sesión cuando el componente arranca:

class Setting extends Component
{
    public function boot(): void
    {
        $team = session('team_id', 0);
        if (getPermissionsTeamId() !== $team) {
            setPermissionsTeamId($team);
        }
    }
}

Si lo omites, el componente pierde todos los permisos del guard team a partir de su segundo render: un botón condicionado a un permiso está al cargar la página, desaparece la primera vez que el componente se actualiza y vuelve solo al recargar. Nada falla con un error, así que la única forma de detectarlo es conocer su forma.

Para un job o un comando, ponlo tú mismo con el equipo en el que estás trabajando, y limpia las relaciones en caché de cualquier usuario que hayas cargado antes:

setPermissionsTeamId($team->id);
$user->unsetRelation('roles')->unsetRelation('permissions');

App\Traits\HasTeam::isAdmin muestra el patrón que usa el kit para hacer una pregunta global desde dentro de un equipo: pone el contexto en 0, pregunta si la persona tiene el rol global admin y después restaura el id del equipo. Su respuesta se guarda en caché por usuario durante cinco minutos.

Acota tus registros

Un modelo que pertenece a un equipo lleva una columna team_id, llenada desde team() cuando el registro se crea y usada para filtrar cada lista. Los recursos de Laravel Front reciben ambas cosas de App\Front\Resources\Team\Resource; ve Construye un CRUD acotado al equipo. Un componente de Livewire tuyo hace lo mismo a mano:

#[Computed]
public function projects()
{
    return Project::where('team_id', team()->id)->latest()->get();
}

Las categorías ya lo hacen: App\Models\Category::getOptions() devuelve las categorías del equipo actual, y las globales cuando no hay equipo, como en el panel de administración.

Las relaciones del equipo

$user->teams();          // BelongsToMany a través de user_teams
$user->owner_teams();    // HasMany, los equipos que la persona creó
$user->current_team();   // BelongsTo
$team->users();          // BelongsToMany, pivote App\Models\UserTeam
$team->invitations();    // HasMany App\Models\TeamInvitation
$team->roles();          // HasMany App\Models\Role
$team->canAccess();      // si la persona con sesión iniciada es miembro
$team->isOwner();        // si la persona con sesión iniciada lo creó
Team::mine();            // los equipos a los que pertenece la persona con sesión iniciada

Agregar y quitar un miembro es $team->users()->attach($id) y detach($id): el pivote UserTeam tiene un observer que asigna el rol predeterminado y envía las notificaciones, así que no hace falta llamar a ningún servicio.