Feat: user dashboard with active services

- Added `UserAppAccessService` for retrieving active services by user with detailed role and permission validation.
- Created `ServiceAppDto` to standardize service data for UI rendering.
- Enhanced user dashboard layout and Livewire component with computed services, role-based restricted access, and expiring service warnings.
- Updated dashboard blade views for improved user experience and dynamic active services display.
This commit is contained in:
= 2026-05-21 11:44:57 +00:00
parent 060526172f
commit 0ca7361a22
4 changed files with 290 additions and 10 deletions

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Data\Ui\Dashboard;
use Spatie\LaravelData\Data;
class ServiceAppDto extends Data
{
public function __construct(
public int $id,
public string $name,
public string $slug,
public string $duration,
public int $days_remaining,
public bool $is_warning,
) {}
}

View File

@ -17,10 +17,7 @@ public static function resolve(Collection $roles): string
if ($roles->contains(DefaultUserRole::Admin->value)) {
return 'dashboard.admin';
}
if ($roles->contains(DefaultUserRole::User->value)) {
return 'dashboard.user';
}
return 'home';
return 'dashboard.user';
}
}

View File

@ -0,0 +1,137 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Data\ConnectedApp\ConnectedAppData;
use App\Data\Ui\Dashboard\ServiceAppDto;
use App\Enums\ConnectedAppPermissonEnum;
use App\Helpers\AppPermission;
use App\Models\{ConnectedApp, RestrictedUserPermission, Role, User};
use Carbon\Carbon;
use Illuminate\Support\Collection;
class UserAppAccessService
{
/**
* Get all active services for the user as a collection of DTOs.
*
* @return Collection<int, ServiceAppDto>
*/
public function getActiveServices(User $user): Collection
{
$user->loadMissing(['roles.apps', 'roles.permissions']);
$restrictedPermissions = $this->getRestrictedPermissions($user);
/** @var Collection<int, ServiceAppDto> $appsMap */
$appsMap = collect();
/** @var Collection<int, Role> $roles */
$roles = $user->roles;
foreach ($roles as $role) {
/** @var Collection<int, ConnectedApp> $apps */
$apps = $role->apps;
foreach ($apps as $app) {
if ($this->isAppRestrictedForRole($role, $app, $restrictedPermissions)) {
continue;
}
/** @var mixed $pivot */
$pivot = $app->pivot;
$durationStr = (string) $pivot->duration;
$daysRemaining = $this->getDaysRemainingIfActive($durationStr);
// If null is returned, the app has expired.
if (null === $daysRemaining) {
continue;
}
$this->addOrUpdateAppInMap($appsMap, $app, $durationStr, $daysRemaining);
}
}
return $appsMap->values();
}
/**
* Retrieves a map of restricted permissions for the user, grouped by role ID.
*
* * @return array<int, array<int, int>>
*/
private function getRestrictedPermissions(User $user): array
{
return RestrictedUserPermission::query()
->where('user_id', $user->id)
->get()
->groupBy('role_id')
->map(fn ($item) => $item->pluck('permission_id')->all())
->all();
}
/**
* Checks if the app is restricted for the given role.
*
* * @param array<int, array<int, int>> $restrictedPermissions
*/
private function isAppRestrictedForRole(Role $role, ConnectedApp $app, array $restrictedPermissions): bool
{
$permissionName = AppPermission::name(ConnectedAppPermissonEnum::Use, ConnectedAppData::from($app));
$permission = $role->permissions->firstWhere('name', $permissionName);
if (! $permission) {
return false;
}
return isset($restrictedPermissions[$role->id])
&& in_array($permission->id, $restrictedPermissions[$role->id], true);
}
/**
* Calculates the days remaining. Returns null if the duration has expired.
*/
private function getDaysRemainingIfActive(string $durationStr): ?int
{
$duration = Carbon::parse($durationStr);
$now = now();
if ($duration->isPast()) {
return null;
}
$daysRemaining = (int) $now->diffInDays($duration, false);
if (0 === $daysRemaining && $now->lt($duration)) {
return 1;
}
return $daysRemaining;
}
/**
* Adds the app to the map or updates it if the new duration is greater than the existing one.
*
* * @param Collection<int, ServiceAppDto> $appsMap
*/
private function addOrUpdateAppInMap(Collection $appsMap, ConnectedApp $app, string $durationStr, int $daysRemaining): void
{
/** @var ServiceAppDto|null $existing */
$existing = $appsMap->get($app->id);
$duration = Carbon::parse($durationStr);
if (! $existing || Carbon::parse($existing->duration)->lt($duration)) {
$appsMap->put($app->id, new ServiceAppDto(
id: $app->id,
name: $app->name,
slug: $app->slug,
duration: $durationStr,
days_remaining: $daysRemaining,
is_warning: $daysRemaining <= 3,
));
}
}
}

View File

@ -1,13 +1,140 @@
<?php
use App\Services\UserAppAccessService;
use Illuminate\Support\Collection;
use Livewire\Attributes\{Computed, Layout, Title};
use Livewire\Component;
new class extends Component
{
//
new
#[Layout('layouts.app.sidebar')]
#[Title('Dashboard')]
class extends Component {
private UserAppAccessService $service;
public function boot(UserAppAccessService $service): void
{
$this->service = $service;
}
public function mount(): void
{
$user = auth()->user();
// Using spatie/laravel-activitylog v5 to log dashboard access
if ($user) {
activity()
->causedBy($user)
->event('dashboard_access')
->log('User accessed their assigned services dashboard.');
}
}
#[Computed]
public function services(): Collection
{
$user = auth()->user();
if (!$user) {
return collect();
}
return $this->service->getActiveServices($user);
}
};
?>
<div>
{{-- People find pleasure in different ways. I find it in keeping my mind clear. - Marcus Aurelius --}}
</div>
<div class="space-y-6">
<x-shared.card class="p-4 flex items-center justify-between">
<x-mary-avatar
:placeholder="auth()->user()->initials()"
:title="'Welcome back, ' . auth()->user()->name . '!'"
>
<x-slot:subtitle>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-0.5">
Manage and access your authenticated single sign-on services.
</p>
</x-slot:subtitle>
</x-mary-avatar>
<div class="flex items-center gap-4">
<x-mary-badge class="badge-soft badge-primary font-semibold text-xs px-3 py-1.5">
Active Account
</x-mary-badge>
<span class="text-xs text-gray-400 dark:text-gray-500 flex items-center gap-1">
<x-mary-icon name="lucide.calendar" class="w-3.5 h-3.5"/>
{{ now()->format('F j, Y') }}
</span>
</div>
</x-shared.card>
@if($this->services->isEmpty())
<x-shared.card
class="flex flex-col items-center justify-center text-center p-12 min-h-100">
<div class="p-4 bg-gray-50 rounded-full mb-4">
<x-mary-icon
name="lucide.shield-alert"
class="w-12 h-12 text-gray-600 animate-pulse"
/>
</div>
<h3 class="text-lg font-semibold text-gray-900 mb-2">No Active Services Assigned</h3>
<p class="text-sm text-gray-500 max-w-md mb-6">
Your account does not currently have any active or visible services assigned by your role, or your
access duration has expired.
</p>
<x-mary-alert icon="lucide.info" class="alert-soft alert-info">
To request access to specific applications, please contact your system administrator or IT helpdesk with
your account details
</x-mary-alert>
</x-shared.card>
@else
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
@foreach($this->services as $service)
<x-shared.card
:title="$service->name"
icon="lucide-key-round"
class="group relative {{ $service->is_warning ? 'border-amber-300 dark:border-amber-900/40 bg-amber-50/10' :'' }} rounded-2xl shadow-xs flex flex-col justify-between"
>
<x-slot:actions>
@if($service->is_warning)
<x-mary-badge
class="badge-soft badge-warning font-semibold text-xs px-2.5 py-1 flex items-center gap-1.5">
<x-mary-icon name="lucide.alert-triangle"
class="w-3.5 h-3.5 inline animate-pulse text-amber-600 dark:text-amber-400"/>
<span>Expiring Soon</span>
</x-mary-badge>
@else
<x-mary-badge class="badge-soft badge-success text-xs font-semibold px-2.5 py-1">
Active
</x-mary-badge>
@endif
</x-slot:actions>
<div class="p-4 flex-1 flex flex-col justify-between gap-4">
<div>
<p class="text-xs text-gray-400 dark:text-gray-500 font-mono tracking-wider uppercase">
{{ $service->slug }}
</p>
</div>
<div class="pt-4 border-t border-gray-200 flex items-center justify-between gap-4">
<div class="flex items-center gap-2">
<x-mary-icon name="lucide.clock"
class="w-4 h-4 {{ $service->is_warning ? 'text-amber-500 animate-pulse' : 'text-gray-400 dark:text-gray-500' }}"/>
<span
class="text-sm font-medium {{ $service->is_warning ? 'text-amber-600 dark:text-amber-400 font-semibold' : 'text-gray-600 dark:text-gray-400' }}">
{{ $service->days_remaining }} {{ Str::plural('day', $service->days_remaining) }} left
</span>
</div>
<x-mary-button
icon="lucide.external-link"
class="btn-sm btn-circle btn-soft btn-primary"
tooltip="Launch Service"
/>
</div>
</div>
</x-shared.card>
@endforeach
</div>
@endif
</div>