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:
parent
060526172f
commit
0ca7361a22
19
app/Data/Ui/Dashboard/ServiceAppDto.php
Normal file
19
app/Data/Ui/Dashboard/ServiceAppDto.php
Normal 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,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
@ -17,10 +17,7 @@ public static function resolve(Collection $roles): string
|
|||||||
if ($roles->contains(DefaultUserRole::Admin->value)) {
|
if ($roles->contains(DefaultUserRole::Admin->value)) {
|
||||||
return 'dashboard.admin';
|
return 'dashboard.admin';
|
||||||
}
|
}
|
||||||
if ($roles->contains(DefaultUserRole::User->value)) {
|
|
||||||
return 'dashboard.user';
|
return 'dashboard.user';
|
||||||
}
|
}
|
||||||
|
|
||||||
return 'home';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
137
app/Services/UserAppAccessService.php
Normal file
137
app/Services/UserAppAccessService.php
Normal 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,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,13 +1,140 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Services\UserAppAccessService;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Livewire\Attributes\{Computed, Layout, Title};
|
||||||
use Livewire\Component;
|
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>
|
<div class="space-y-6">
|
||||||
{{-- People find pleasure in different ways. I find it in keeping my mind clear. - Marcus Aurelius --}}
|
<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>
|
</div>
|
||||||
Loading…
x
Reference in New Issue
Block a user