Feat: refactor app role duration to validity date

- Replaced `duration` (integer, days) with `validUpto` (datetime) in role-app assignments.
- Updated `AssignAppsToRoleData`, `AppRole` model, and Livewire forms to adopt the new `validUpto` field.
- Adjusted database migrations and UI components to reflect the change in validity representation.
- Enhanced validity date inputs with support for "Unlimited" roles using toggles.
This commit is contained in:
= 2026-05-21 06:38:05 +00:00
parent 1e5c084855
commit fd57ac57e1
9 changed files with 68 additions and 62 deletions

View File

@ -10,7 +10,7 @@ final class AssignAppsToRoleData extends Data
{ {
public function __construct( public function __construct(
public int $roleId, public int $roleId,
public int $duration, public string $validUpto,
/** @var int[] */ /** @var int[] */
public array $appIds public array $appIds
) {} ) {}

View File

@ -13,7 +13,7 @@ public function __construct(
public int $id, public int $id,
public int $appId, public int $appId,
public string $appName, public string $appName,
public int $duration, public string $duration,
) {} ) {}
/** /**

View File

@ -15,13 +15,15 @@ class AssignAppToRoleForm extends Form
])] ])]
public array $appIds = []; public array $appIds = [];
#[Validate('required|integer|min:1|max:3650')] #[Validate('exclude_if:isUnlimited,true|required|date|after_or_equal:today')]
public int $duration = 30; public ?string $validUpto = null;
public bool $isUnlimited = false;
public function reset(mixed ...$properties): void public function reset(mixed ...$properties): void
{ {
$this->resetErrorBag(); $this->resetErrorBag();
parent::reset(...$properties); parent::reset(...$properties);
$this->duration = 30; $this->validUpto = null;
} }
} }

View File

@ -12,7 +12,7 @@
* @property int $id * @property int $id
* @property int $app_id * @property int $app_id
* @property int $role_id * @property int $role_id
* @property int $duration Duration in days. * @property string $duration Validity of the role
*/ */
#[Fillable(['app_id', 'role_id', 'duration'])] #[Fillable(['app_id', 'role_id', 'duration'])]
class AppRole extends Pivot class AppRole extends Pivot

View File

@ -30,6 +30,7 @@ public function assign(AssignAppsToRoleData $data): void
if (empty($data->appIds)) { if (empty($data->appIds)) {
return; return;
} }
DB::transaction(function () use ($data): void {
$role = Role::query()->findOrFail($data->roleId); $role = Role::query()->findOrFail($data->roleId);
$this->assignAppPermissionsRole($role, $data->appIds); $this->assignAppPermissionsRole($role, $data->appIds);
@ -37,7 +38,7 @@ public function assign(AssignAppsToRoleData $data): void
$records = array_map(fn (int $appId) => [ $records = array_map(fn (int $appId) => [
'role_id' => $data->roleId, 'role_id' => $data->roleId,
'app_id' => $appId, 'app_id' => $appId,
'duration' => $data->duration, 'duration' => $data->validUpto,
], $data->appIds); ], $data->appIds);
AppRole::query()->upsert( AppRole::query()->upsert(
@ -45,7 +46,7 @@ public function assign(AssignAppsToRoleData $data): void
['role_id', 'app_id'], ['role_id', 'app_id'],
['duration'] ['duration']
); );
});
} }
/** /**

View File

@ -18,7 +18,7 @@ public function up(): void
$table->id(); $table->id();
$table->foreignIdFor(ConnectedApp::class, 'app_id'); $table->foreignIdFor(ConnectedApp::class, 'app_id');
$table->foreignId('role_id')->comment('role_id from spatie roles table'); $table->foreignId('role_id')->comment('role_id from spatie roles table');
$table->integer('duration')->comment('in days'); $table->dateTime('duration');
$table->unique(['app_id', 'role_id']); $table->unique(['app_id', 'role_id']);
}); });
} }

View File

@ -81,13 +81,11 @@ public function save(): void
$this->attempt( $this->attempt(
action: function (): void { action: function (): void {
$role = $this->roleService->create(new CreateRoleData(name: $this->form->name)); $role = $this->roleService->create(new CreateRoleData(name: $this->form->name));
$this->appRoleService->assign( $this->appRoleService->assign(new AssignAppsToRoleData(
new AssignAppsToRoleData(
roleId: $role->id, roleId: $role->id,
duration: $this->form->duration, validUpto: $this->form->isUnlimited ? now()->addYears(15) : $this->form->validUpto,
appIds: $this->form->appIds appIds: $this->form->appIds,
) ));
);
$this->showCreateModal = false; $this->showCreateModal = false;
$this->form->reset(); $this->form->reset();
unset($this->getRoles); unset($this->getRoles);

View File

@ -7,6 +7,7 @@
use App\Models\Role; use App\Models\Role;
use App\Services\AppRoleService; use App\Services\AppRoleService;
use Livewire\Attributes\{Computed, Layout, Title}; use Livewire\Attributes\{Computed, Layout, Title};
use Illuminate\Support\Carbon;
use Livewire\Component; use Livewire\Component;
use Spatie\LaravelData\Attributes\DataCollectionOf; use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\DataCollection; use Spatie\LaravelData\DataCollection;
@ -71,7 +72,7 @@ public function mount(int $id): void
$this->appHeaders = TableHeader::collect([ $this->appHeaders = TableHeader::collect([
new TableHeader(key: 'app_name', label: 'App'), new TableHeader(key: 'app_name', label: 'App'),
new TableHeader(key: 'duration', label: 'Duration (days)'), new TableHeader(key: 'duration', label: 'Validity'),
], DataCollection::class); ], DataCollection::class);
$this->userHeaders = TableHeader::collect([ $this->userHeaders = TableHeader::collect([
@ -118,7 +119,7 @@ public function openEditAppModal(int $assignmentId): void
if ($assignment) { if ($assignment) {
$this->assignForm->appIds = [$assignment['appId'] ?? $assignment['id']]; $this->assignForm->appIds = [$assignment['appId'] ?? $assignment['id']];
$this->assignForm->duration = $assignment['duration']; $this->assignForm->validUpto = $assignment['duration'];
$this->editingAppName = $assignment['appName']; $this->editingAppName = $assignment['appName'];
} }
@ -160,12 +161,12 @@ public function assignApp(): void
{ {
$this->attempt( $this->attempt(
action: function (): void { action: function (): void {
$this->assignForm->validate();
$this->appRoleService->assign(new AssignAppsToRoleData( $this->appRoleService->assign(new AssignAppsToRoleData(
roleId: $this->roleId, roleId: $this->roleId,
duration: $this->assignForm->duration, validUpto: $this->assignForm->isUnlimited ? now()->addYears(15) : $this->assignForm->validUpto,
appIds: $this->assignForm->appIds, appIds: $this->assignForm->appIds,
)); ));
$this->assignForm->reset(); $this->assignForm->reset();
$this->showAddAppModal = false; $this->showAddAppModal = false;
unset($this->apps); unset($this->apps);
@ -178,9 +179,10 @@ public function updateApp(): void
{ {
$this->attempt( $this->attempt(
action: function (): void { action: function (): void {
$this->assignForm->validate();
$this->appRoleService->assign(new AssignAppsToRoleData( $this->appRoleService->assign(new AssignAppsToRoleData(
roleId: $this->roleId, roleId: $this->roleId,
duration: $this->assignForm->duration, validUpto: $this->assignForm->isUnlimited ? now()->addYears(15) : $this->assignForm->validUpto,
appIds: $this->assignForm->appIds, appIds: $this->assignForm->appIds,
)); ));
@ -275,7 +277,7 @@ class="btn-sm btn-ghost"
<x-shared.card title="Connected Apps" icon="lucide-blocks" class="pb-2 w-full h-min"> <x-shared.card title="Connected Apps" icon="lucide-blocks" class="pb-2 w-full h-min">
<x-slot:actions> <x-slot:actions>
<x-mary-button wire:click="openAddAppModal" icon="lucide.plus" class="btn-sm"> <x-mary-button wire:click="openAddAppModal" spinner="openAddAppModal" icon="lucide.plus" class="btn-sm">
Add App Add App
</x-mary-button> </x-mary-button>
</x-slot:actions> </x-slot:actions>
@ -290,7 +292,7 @@ class="btn-sm btn-ghost"
@endscope @endscope
@scope('cell_duration', $row) @scope('cell_duration', $row)
{{ $row['duration'] }} days {{ Carbon::parse($row['duration'])->diffForHumans() }}
@endscope @endscope
@scope('actions', $row) @scope('actions', $row)
@ -358,27 +360,16 @@ class="btn-sm btn-error btn-soft btn-circle"
@if(!$isEditingApp) @if(!$isEditingApp)
<div> <div>
<div class="mb-1 flex items-end justify-between"> <x-shared.choices
<label class="text-sm font-semibold text-gray-700">Connected Apps</label>
<div class="flex gap-3 text-xs">
<button type="button" wire:click="selectAllApps" class="text-primary hover:underline">
Select All
</button>
<button type="button" wire:click="clearApps" class="text-error hover:underline">
Clear
</button>
</div>
</div>
<x-mary-choices
wire:model="assignForm.appIds" wire:model="assignForm.appIds"
:label="__('Connected Apps')"
:options="$appsSearchable" :options="$appsSearchable"
searchable
search-function="searchApps" search-function="searchApps"
option-label="name" select-all-action="selectAllApps"
option-value="id" clear-action="clearApps"
no-result-text="No apps found." no-result-text="No apps found."
placeholder="Search apps..." placeholder="Search apps..."
searchable
/> />
</div> </div>
@else @else
@ -390,23 +381,34 @@ class="btn-sm btn-error btn-soft btn-circle"
class="bg-gray-50 text-gray-500 cursor-not-allowed" class="bg-gray-50 text-gray-500 cursor-not-allowed"
/> />
@endif @endif
<div x-data="{ isUnlimited: @entangle('assignForm.isUnlimited') }"
class="flex gap-2 items-baseline-last">
<x-mary-input <div class="flex-1 transition-opacity duration-200"
label="Duration (days)" x-bind:class="isUnlimited ? 'opacity-50 pointer-events-none' : ''">
wire:model="assignForm.duration" <x-mary-datepicker
id="assign-duration-input" label="Date"
type="number" wire:model="assignForm.validUpto"
min="1" icon="lucide.calendar"
max="3650" x-bind:disabled="isUnlimited"
placeholder="30" x-bind:readonly="isUnlimited"
/> />
</div> </div>
<x-mary-toggle
class="toggle-secondary"
label="Unlimited"
x-model="isUnlimited"
/>
</div>
</div>
<x-slot:actions> <x-slot:actions>
<x-mary-button @click="$wire.showAddAppModal = false" class="btn-ghost"> <x-mary-button @click="$wire.showAddAppModal = false" class="btn-ghost">
Cancel Cancel
</x-mary-button> </x-mary-button>
<x-mary-button type="submit" icon="{{ $isEditingApp ? 'lucide.save' : 'lucide.link' }}" <x-mary-button type="submit" class="btn-primary"
icon="{{ $isEditingApp ? 'lucide.save' : 'lucide.link' }}"
spinner="saveApp"> spinner="saveApp">
{{ $isEditingApp ? 'Update Duration' : 'Assign Apps' }} {{ $isEditingApp ? 'Update Duration' : 'Assign Apps' }}
</x-mary-button> </x-mary-button>

View File

@ -13,3 +13,6 @@
@vite(['resources/css/app.css', 'resources/js/app.js']) @vite(['resources/css/app.css', 'resources/js/app.js'])
@livewireStyles @livewireStyles
{{--For date picker in maryui. Remove this if opt for native --}}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css">
<script src="https://cdn.jsdelivr.net/npm/flatpickr"></script>