Cargando…

Esto está tardando más de lo esperado.

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
La cookie team_id El equipo que la persona abrió por última vez en este navegador, para que la lista de equipos pueda omitirse en la siguiente visita. Se escribe cuando abre un equipo desde la lista, crea uno o sigue un enlace de equipo; se olvida al cerrar sesión, y la olvida el middleware team cuando nombra un equipo al que ya no pertenece.
users.current_team_id Un respaldo que se lee sólo cuando la cookie no nombra ningún equipo de la persona. Nada en el kit lo escribe; el middleware team lo limpia cuando ya no lleva a un equipo suyo.
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   // el equipo de la petición actual, o null

team() lee App\Classes\CurrentTeam, que AppServiceProvider resuelve una vez por petición: el equipo que nombra la cookie team_id cuando la persona pertenece a él; si no, el equipo de users.current_team_id cuando pertenece a él; si no, null. Es null para un visitante sin sesión, y en cada ruta llamada app.*: App\Http\Middleware\PersonalAreaMiddleware, en el grupo web y persistente en las actualizaciones de Livewire, lo vacía ahí, para que el área personal /app nunca funcione dentro de un equipo aunque haya uno recordado.

Los dos middleware

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

  1. Toma el equipo de team(), y olvida la cookie team_id cuando no nombra un equipo al que la persona pertenece.
  2. Cuando no hay equipo, limpia current_team_id si estaba puesto 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 /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.

Antes del paso 1 de team, una petición GET que lleva el parámetro team en la dirección es un enlace de equipo: el middleware cambia a un miembro a ese equipo y redirige a la misma dirección sin el parámetro, y manda a cualquier otro al selector con un aviso. Un correo o una notificación sobre un equipo construye su enlace con Team::routeUrl(), que recibe un nombre de ruta y sus parámetros como route() y devuelve una dirección absoluta:

$team->routeUrl('team.media', ['path' => 'reports']); // https://example.test/team/media/reports?team=12

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

Están registrados en bootstrap/app.php:

// El selector: con sesión iniciada, todavía sin equipo. Pide suscripción sólo
// cuando el proyecto vende suscripciones a usuarios.
Route::middleware(['web', 'auth', 'security'])->prefix('app')->name('app.')->group(base_path('routes/app.php'));

// El espacio de trabajo: dentro de un equipo.
Route::middleware(['web', 'auth', 'security', 'team'])->prefix('team')->name('team.')->group(base_path('routes/team.php'));

// El panel de administración: contexto global, rol admin.
Route::middleware(['web', 'auth', 'security', 'noTeam', 'adminScope', 'role:' . config('app.admin_role')])->prefix('admin')->name('admin.')->group(base_path('routes/admin.php'));

Registra una pantalla de equipo nueva en routes/team.php, que corre dentro del grupo del espacio de trabajo. Ese grupo no pide ninguna suscripción: cada pantalla pide su propio permiso, sea cual sea el plan del equipo, y un equipo cuyo plan terminó sigue llegando a sus miembros, roles y archivos. Exigir una suscripción es opcional, y lo decide pantalla por pantalla quien la construye. Las pantallas de planes y cobros vienen del paquete de facturación, con su propia revisión de acceso.

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, la policy de las pantallas de miembros, agrega dos cosas encima: el propietario del equipo pasa cada una de sus verificaciones, juzgado sobre el Team que se pasa a la verificación o, cuando no se pasa ninguno, sobre team(); y create rechaza cuando no hay equipo o reachedLimit('users') es verdadero, con el aviso flash Has alcanzado el número máximo de miembros permitido por tu suscripción. Por favor, mejora tu plan para añadir más miembros., que no lleva enlace. update y delete también aceptan un ?Team opcional, para una verificación hecha fuera de las pantallas del equipo. Ninguna otra policy tiene un atajo para el propietario: el propietario las pasa porque el rol de propietario tiene todos los permisos team.

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);
        }
    }
}

Las pantallas de miembros, Members\Index, Members\Create y Members\Edit, toman el otro camino: guardan el equipo en una propiedad public Team $team llenada en el mount, #[Locked] en Members\Edit, y llaman a setPermissionsTeamId($this->team->id) en hydrate().

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\UserBase::isAdmin() muestra el patrón que usa el kit para hacer una pregunta global desde dentro de un equipo: pone el contexto en 0, descarta los roles cargados, pregunta si la persona tiene el rol global nombrado en app.admin_role y después restaura el id del equipo. Nada se guarda en caché: cada llamada vuelve a preguntarle a la base de datos. App\Traits\HasTeam::hasPermissionInTeam($permission, $teamId) hace lo mismo para un permiso de otro equipo, y devuelve el contexto a su lugar al terminar.

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->categories();     // HasMany App\Models\Category
$team->can_access;       // si la persona con sesión iniciada es miembro
$team->is_owner;         // si la persona con sesión iniciada lo creó
Team::mine();            // los equipos a los que pertenece la persona con sesión iniciada
$team->avatarMedia?->displayUrlFor(48) ?? $team->default_avatar; // la foto del equipo a 48 px

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.

Volver