Implements workspace-first enforcement and UX: - Workspace selected before tenant flows; /admin routes into choose-workspace/choose-tenant - Tenant lists and default tenant selection are scoped to current workspace - Workspaces UI is tenantless at /admin/workspaces Security hardening: - Workspaces can never have 0 owners (blocks last-owner removal/demotion) - Blocked attempts are audited with action_id=workspace_membership.last_owner_blocked + required metadata - Optional break-glass recovery page to re-assign workspace owner (audited) Tests: - Added/updated Pest feature tests covering redirects, scoping, tenantless workspaces, last-owner guards, and break-glass recovery. Notes: - Filament v5 strict Page property signatures respected in RepairWorkspaceOwners. Co-authored-by: Ahmed Darrazi <ahmeddarrazi@MacBookPro.fritz.box> Reviewed-on: #86
130 lines
4.2 KiB
PHP
130 lines
4.2 KiB
PHP
<?php
|
|
|
|
namespace App\Support\Workspaces;
|
|
|
|
use App\Models\User;
|
|
use App\Models\Workspace;
|
|
use App\Models\WorkspaceMembership;
|
|
use Illuminate\Http\Request;
|
|
|
|
final class WorkspaceContext
|
|
{
|
|
public const SESSION_KEY = 'current_workspace_id';
|
|
|
|
public function __construct(private WorkspaceResolver $resolver) {}
|
|
|
|
public function currentWorkspaceId(?Request $request = null): ?int
|
|
{
|
|
$session = ($request && $request->hasSession()) ? $request->session() : session();
|
|
|
|
$id = $session->get(self::SESSION_KEY);
|
|
|
|
return is_int($id) ? $id : (is_numeric($id) ? (int) $id : null);
|
|
}
|
|
|
|
public function currentWorkspace(?Request $request = null): ?Workspace
|
|
{
|
|
$id = $this->currentWorkspaceId($request);
|
|
|
|
if (! $id) {
|
|
return null;
|
|
}
|
|
|
|
$workspace = Workspace::query()->whereKey($id)->first();
|
|
|
|
if (! $workspace) {
|
|
return null;
|
|
}
|
|
|
|
if (! $this->isWorkspaceSelectable($workspace)) {
|
|
return null;
|
|
}
|
|
|
|
return $workspace;
|
|
}
|
|
|
|
public function setCurrentWorkspace(Workspace $workspace, ?User $user = null, ?Request $request = null): void
|
|
{
|
|
$session = ($request && $request->hasSession()) ? $request->session() : session();
|
|
$session->put(self::SESSION_KEY, (int) $workspace->getKey());
|
|
|
|
if ($user !== null) {
|
|
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
|
|
}
|
|
}
|
|
|
|
public function clearCurrentWorkspace(?User $user = null, ?Request $request = null): void
|
|
{
|
|
$session = ($request && $request->hasSession()) ? $request->session() : session();
|
|
$session->forget(self::SESSION_KEY);
|
|
|
|
if ($user !== null && $user->last_workspace_id !== null) {
|
|
$user->forceFill(['last_workspace_id' => null])->save();
|
|
}
|
|
}
|
|
|
|
public function resolveInitialWorkspaceFor(User $user, ?Request $request = null): ?Workspace
|
|
{
|
|
$session = ($request && $request->hasSession()) ? $request->session() : session();
|
|
|
|
$currentId = $this->currentWorkspaceId($request);
|
|
|
|
if ($currentId) {
|
|
$current = Workspace::query()->whereKey($currentId)->first();
|
|
|
|
if (! $current instanceof Workspace || ! $this->isWorkspaceSelectable($current) || ! $this->isMember($user, $current)) {
|
|
$session->forget(self::SESSION_KEY);
|
|
|
|
if ((int) $user->last_workspace_id === (int) $currentId) {
|
|
$user->forceFill(['last_workspace_id' => null])->save();
|
|
}
|
|
} else {
|
|
return $current;
|
|
}
|
|
}
|
|
|
|
if ($user->last_workspace_id !== null) {
|
|
$workspace = Workspace::query()->whereKey($user->last_workspace_id)->first();
|
|
|
|
if (! $workspace instanceof Workspace || ! $this->isWorkspaceSelectable($workspace) || ! $this->isMember($user, $workspace)) {
|
|
$user->forceFill(['last_workspace_id' => null])->save();
|
|
}
|
|
}
|
|
|
|
$memberships = WorkspaceMembership::query()
|
|
->where('user_id', $user->getKey())
|
|
->with('workspace')
|
|
->get();
|
|
|
|
$selectableWorkspaces = $memberships
|
|
->map(fn (WorkspaceMembership $membership) => $membership->workspace)
|
|
->filter(fn (?Workspace $workspace) => $workspace instanceof Workspace && $this->isWorkspaceSelectable($workspace))
|
|
->values();
|
|
|
|
if ($selectableWorkspaces->count() === 1) {
|
|
/** @var Workspace $workspace */
|
|
$workspace = $selectableWorkspaces->first();
|
|
|
|
$session->put(self::SESSION_KEY, (int) $workspace->getKey());
|
|
$user->forceFill(['last_workspace_id' => (int) $workspace->getKey()])->save();
|
|
|
|
return $workspace;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
public function isMember(User $user, Workspace $workspace): bool
|
|
{
|
|
return WorkspaceMembership::query()
|
|
->where('user_id', $user->getKey())
|
|
->where('workspace_id', $workspace->getKey())
|
|
->exists();
|
|
}
|
|
|
|
private function isWorkspaceSelectable(Workspace $workspace): bool
|
|
{
|
|
return empty($workspace->archived_at);
|
|
}
|
|
}
|