Feat: implement role and app management

- Added services (`RoleService`, `AppRoleService`, `AppsPermissionService`) to handle role creation, app assignments, and permissions.
- Introduced new Livewire forms for creating roles and assigning apps (`CreateRoleForm`, `AssignAppToRoleForm`).
- Built dynamic Blade views for role and app management, including modals and reusable components.
- Removed outdated `fluxui-development` skill documentation.
This commit is contained in:
= 2026-05-19 11:13:35 +00:00
parent 495c0ea2d8
commit c680c1ae6e
20 changed files with 1037 additions and 85 deletions

View File

@ -1,81 +0,0 @@
---
name: fluxui-development
description: "Use this skill for Flux UI development in Livewire applications only. Trigger when working with <flux:*> components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling."
license: MIT
metadata:
author: laravel
---
# Flux UI Development
## Documentation
Use `search-docs` for detailed Flux UI patterns and documentation.
## Basic Usage
This project uses the free edition of Flux UI, which includes all free components and variants but not Pro components.
Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize.
Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs.
<!-- Basic Button -->
```blade
<flux:button variant="primary">Click me</flux:button>
```
## Available Components (Free Edition)
Available: avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, profile, radio, select, separator, skeleton, switch, text, textarea, tooltip
## Icons
Flux includes [Heroicons](https://heroicons.com/) as its default icon set. Search for exact icon names on the Heroicons site - do not guess or invent icon names.
<!-- Icon Button -->
```blade
<flux:button icon="arrow-down-tray">Export</flux:button>
```
For icons not available in Heroicons, use [Lucide](https://lucide.dev/). Import the icons you need with the Artisan command:
```bash
php artisan flux:icon crown grip-vertical github
```
## Common Patterns
### Form Fields
<!-- Form Field -->
```blade
<flux:field>
<flux:label>Email</flux:label>
<flux:input type="email" wire:model="email" />
<flux:error name="email" />
</flux:field>
```
### Modals
<!-- Modal -->
```blade
<flux:modal wire:model="showModal">
<flux:heading>Title</flux:heading>
<p>Content</p>
</flux:modal>
```
## Verification
1. Check component renders correctly
2. Test interactive states
3. Verify mobile responsiveness
## Common Pitfalls
- Trying to use Pro-only components in the free edition
- Not checking if a Flux component exists before creating custom implementations
- Forgetting to use the `search-docs` tool for component-specific documentation
- Not following existing project patterns for Flux usage

View File

@ -18,5 +18,6 @@ public function __construct(
public int $providerId, public int $providerId,
#[MapInputName('connection_status_id')] #[MapInputName('connection_status_id')]
public int $statusId, public int $statusId,
public ?string $slug = null,
) {} ) {}
} }

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Data\Role;
use Spatie\LaravelData\Data;
final class AssignAppsToRoleData extends Data
{
public function __construct(
public int $roleId,
public int $duration,
/** @var int[] */
public array $appIds
) {}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Data\Role;
use Spatie\LaravelData\Data;
final class CreateRoleData extends Data
{
public function __construct(
public string $name,
) {}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Data\Role;
use App\Models\ConnectedApp;
use Spatie\LaravelData\Data;
final class RoleAppData extends Data
{
public function __construct(
public int $id,
public int $appId,
public string $appName,
public int $duration,
) {}
/**
* Intercept the Eloquent model and map it to the DTO properties.
*/
public static function fromModel(ConnectedApp $app): self
{
return new self(
id: $app->pivot->id,
appId: $app->id,
appName: $app->name,
duration: $app->pivot->duration
);
}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Data\Role;
use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\{Data, DataCollection};
final class RoleData extends Data
{
public function __construct(
public int $id,
public string $name,
#[DataCollectionOf(class: RoleAppData::class), ]
public ?DataCollection $apps = null
) {}
}

View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Enums;
enum ConnectedAppPermissonEnum: string
{
case Use = 'use';
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Helpers;
use App\Data\ConnectedApp\ConnectedAppData;
use App\Enums\ConnectedAppPermissonEnum;
class AppPermission
{
/**
* Generates the standard permission string.
*/
public static function name(ConnectedAppPermissonEnum $permission, string|ConnectedAppData $app): string
{
$slug = is_string($app) ? $app : $app->slug;
return "{$permission->value}-{$slug}";
}
}

View File

@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Forms;
use Livewire\Attributes\Validate;
use Livewire\Form;
class AssignAppToRoleForm extends Form
{
#[Validate([
'appIds' => 'required|array|min:1',
'appIds.*' => 'integer|exists:connected_apps,id',
])]
public array $appIds = [];
#[Validate('required|integer|min:1|max:3650')]
public int $duration = 30;
public function reset(mixed ...$properties): void
{
$this->resetErrorBag();
parent::reset(...$properties);
$this->duration = 30;
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Forms;
use Livewire\Attributes\Validate;
class CreateRoleForm extends AssignAppToRoleForm
{
#[Validate('required|string|max:255|min:3')]
public string $name = '';
}

View File

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Data\Role\{AssignAppsToRoleData, RoleAppData};
use App\Models\{AppRole, ConnectedApp};
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use NoDiscard;
use Spatie\LaravelData\DataCollection;
use Throwable;
final class AppRoleService
{
/**
* Assign connected apps to a role, updating the duration if already assigned.
*
* @throws Throwable when an error occurs during the assignment.
*/
public function assign(AssignAppsToRoleData $data): void
{
if (empty($data->appIds)) {
return;
}
$records = array_map(fn (int $appId) => [
'role_id' => $data->roleId,
'app_id' => $appId,
'duration' => $data->duration,
], $data->appIds);
AppRole::query()->upsert(
$records,
['role_id', 'app_id'],
['duration']
);
}
/**
* Remove an app-role assignment.
*
* @throws InvalidArgumentException when id is invalid.
* @throws Throwable when an error occurs during the deletion.
*/
public function detach(int $id): void
{
if ($id <= 0) {
throw new InvalidArgumentException('Invalid id');
}
DB::transaction(function () use ($id): void {
AppRole::query()->findOrFail($id)->delete();
});
}
/**
* Get all apps assigned to a given role.
*
* @return DataCollection<int, RoleAppData>
*/
#[NoDiscard]
public function getAppsForRole(int $roleId): DataCollection
{
$assignments = AppRole::query()
->with('app:id,name')
->where('role_id', $roleId)
->orderBy('id')
->get(['id', 'app_id', 'role_id', 'duration']);
return RoleAppData::collect(
$assignments->map(fn (AppRole $row): array => [
'id' => $row->id,
'appId' => $row->app_id,
'appName' => $row->app->name,
'duration' => $row->duration,
])->all(),
DataCollection::class,
);
}
/**
* Search available apps for the select dropdown.
*/
#[NoDiscard]
public function searchAppsForSelect(string $search = ''): Collection
{
return ConnectedApp::query()
->select(['id', 'name'])
->when($search, function ($query, $search): void {
$query->where('name', 'like', '%'.$search.'%');
})
->limit(50) // Limit the results so the dropdown doesn't lag
->orderBy('name')
->get(['id', 'name']);
}
/**
* Get all connected app IDs for the "Select All" feature.
*
* @return array<int>
*/
#[NoDiscard]
public function getAllAppIds(): array
{
// Replace 'ConnectedApp' with your actual model name
return ConnectedApp::pluck('id')->toArray();
}
}

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Data\ConnectedApp\ConnectedAppData;
use App\Enums\ConnectedAppPermissonEnum;
use App\Helpers\AppPermission;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Str;
use Spatie\Permission\Models\Permission;
use Throwable;
class AppsPermissionService
{
/**
* Creates a new permission for the connected app.
*
* @throws Throwable
*/
public function add(ConnectedAppData $connectedApp, ConnectedAppPermissonEnum $permission): void
{
if (! $connectedApp->slug) {
$connectedApp->slug = Str::slug($connectedApp->name);
}
$permissionName = AppPermission::name($permission, $connectedApp);
DB::transaction(function () use ($permissionName): void {
Permission::firstOrCreate(['name' => $permissionName]);
});
}
}

View File

@ -6,6 +6,7 @@
use App\Data\ConnectedApp\{ConnectAppRequest, ConnectedAppData}; use App\Data\ConnectedApp\{ConnectAppRequest, ConnectedAppData};
use App\Data\Ui\ConnectedApps\ConnectedAppData as UiConnectedAppData; use App\Data\Ui\ConnectedApps\ConnectedAppData as UiConnectedAppData;
use App\Enums\ConnectedAppPermissonEnum;
use App\Models\ConnectedApp; use App\Models\ConnectedApp;
use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
@ -15,6 +16,10 @@
final class ConnectedAppService final class ConnectedAppService
{ {
public function __construct(
private readonly AppsPermissionService $permissionService,
) {}
/** /**
* Create a new connected app. * Create a new connected app.
* *
@ -23,9 +28,13 @@ final class ConnectedAppService
public function create(ConnectAppRequest $data): void public function create(ConnectAppRequest $data): void
{ {
DB::transaction( DB::transaction(
fn () => ConnectedApp::query()->create( function () use ($data): void {
$connectedApp = ConnectedApp::query()->create(
$data->toArray() $data->toArray()
) );
$this->permissionService->add(ConnectedAppData::from($connectedApp), ConnectedAppPermissonEnum::Use);
}
); );
} }

View File

@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Services;
use App\Data\Role\{CreateRoleData, RoleData};
use App\Models\Role;
use Illuminate\Support\Facades\DB;
use InvalidArgumentException;
use NoDiscard;
use Spatie\LaravelData\PaginatedDataCollection;
use Throwable;
final class RoleService
{
/**
* Create a new role.
* Returns the created role DTO
*
* @throws Throwable when an error occurs during the creation.
*/
#[NoDiscard]
public function create(CreateRoleData $data): RoleData
{
$role = DB::transaction(fn () => Role::query()->create([
'name' => $data->name,
'guard_name' => 'web',
]));
return RoleData::from($role);
}
/**
* @throws InvalidArgumentException when id is invalid.
* @throws Throwable when an error occurs during the deletion.
*/
public function delete(int $id): void
{
if ($id <= 0) {
throw new InvalidArgumentException('Invalid id');
}
DB::transaction(function () use ($id): void {
Role::query()->findOrFail($id)->delete();
});
}
/**
* Returns a Paginated Collection of roles with their connected apps DTO
*
* @return PaginatedDataCollection<int, RoleData>
*/
#[NoDiscard]
public function getAll(): PaginatedDataCollection
{
$roles = Role::query()
->with('apps')
->orderBy('id')
->paginate();
return RoleData::collect($roles, PaginatedDataCollection::class);
}
}

View File

@ -19,7 +19,7 @@ public function up(): void
$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->integer('duration')->comment('in days');
$table->index(['app_id', 'role_id']); $table->unique(['app_id', 'role_id']);
}); });
} }

View File

@ -51,6 +51,19 @@ public function navItems(): DataCollection
], DataCollection::class) ], DataCollection::class)
), ),
new NavItemData(
label: 'Access Manager',
icon: 'lucide.shield',
active_pattern: 'access-manager.*',
sub_menu: NavSubItemData::collect([
new NavSubItemData(
label: 'Roles and Apps',
route: 'access-manager.roles-and-apps',
active_pattern: 'access-manager.roles-and-apps',
),
], DataCollection::class)
),
new NavItemData( new NavItemData(
label: 'Settings', label: 'Settings',
icon: 'lucide.settings', icon: 'lucide.settings',

View File

@ -0,0 +1,36 @@
@props([
'label' => 'Select Items',
'model', // Livewire property
'options', // The data collection passed from the parent
'searchFunction', // The parent method to call for searching
'selectAllAction', // The parent method to call for selecting all
'clearAction', // The parent method to call for clearing
'placeholder' => 'Search...',
'noResultText' => 'No items found.'
])
<fieldset class="fieldset">
<legend class="fieldset-legend w-full mb-1 flex items-end justify-between">
<label class="text-xs font-semibold text-">{{ $label }}</label>
<div class="flex gap-3 text-xs">
<button type="button" wire:click="{{ $selectAllAction }}" class="text-primary hover:underline">
Select All
</button>
<button type="button" wire:click="{{ $clearAction }}" class="text-error hover:underline">
Clear
</button>
</div>
</legend>
<x-mary-choices
wire:model="{{ $model }}"
:options="$options"
search-function="{{ $searchFunction }}"
option-label="name"
option-value="id"
:no-result-text="$noResultText"
:placeholder="$placeholder"
searchable
/>
</fieldset>

View File

@ -0,0 +1,190 @@
<?php
use App\Concerns\HandlesOperations;
use App\Livewire\Forms\CreateRoleForm;
use App\Services\AppRoleService;
use App\Data\Role\{AssignAppsToRoleData, CreateRoleData, RoleAppData};
use App\Data\Ui\TableHeader;
use App\Services\RoleService;
use Livewire\Attributes\{Computed, Layout, Title, Validate};
use Livewire\Component;
use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\DataCollection;
use Spatie\LaravelData\PaginatedDataCollection;
new
#[Title('Roles and Apps')]
class extends Component {
use HandlesOperations;
protected AppRoleService $appRoleService;
public bool $showCreateModal = false;
public CreateRoleForm $form;
protected RoleService $roleService;
public array $appsSearchable = [];
#[DataCollectionOf(TableHeader::class)]
public DataCollection $headers;
public function boot(RoleService $roleService, AppRoleService $appRoleService): void
{
$this->roleService = $roleService;
$this->appRoleService = $appRoleService;
}
public function mount(): void
{
$this->headers = TableHeader::collect([
new TableHeader(key: 'name', label: 'Name'),
new TableHeader(key: 'apps', label: 'Apps'),
new TableHeader(key: '', label: 'Users'),
], DataCollection::class);
}
#[Computed]
public function getRoles(): PaginatedDataCollection
{
return $this->roleService->getAll();
}
public function searchApps(string $value = ''): void
{
$this->appsSearchable = $this
->appRoleService
->searchAppsForSelect($value)
->toArray();
}
public function selectAllApps(): void
{
$this->form->appIds = $this->appRoleService->getAllAppIds();
$this->appsSearchable = $this->appRoleService->searchAppsForSelect('')->toArray();
}
public function clearApps(): void
{
$this->form->appIds = [];
}
public function openCreateModal(): void
{
$this->form->reset();
$this->searchApps();
$this->showCreateModal = true;
}
public function save(): void
{
$this->form->validate();
$this->attempt(
action: function (): void {
$role = $this->roleService->create(new CreateRoleData(name: $this->form->name));
$this->appRoleService->assign(
new AssignAppsToRoleData(
roleId: $role->id,
duration: $this->form->duration,
appIds: $this->form->appIds
)
);
$this->showCreateModal = false;
$this->form->reset();
unset($this->getRoles);
},
successMessage: 'Role created successfully!',
);
}
public function delete(int $roleId): void
{
$this->attempt(
action: function () use ($roleId): void {
$this->roleService->delete($roleId);
unset($this->getRoles);
},
successMessage: 'Role deleted successfully!',
);
}
};
?>
<div>
<x-shared.card title="Roles" icon="lucide-shield-check" class="pb-2">
<x-slot:actions>
<x-mary-button wire:click="openCreateModal" icon="lucide.plus">
Add new role
</x-mary-button>
</x-slot:actions>
<x-mary-table
:headers="$headers->toArray()"
:rows="$this->getRoles->items()"
>
@scope('cell_name', $row)
<span class="font-medium text-gray-900">{{ $row->name }}</span>
@endscope
@scope('cell_apps', $row)
@foreach($row->apps as $app)
@php /** @var RoleAppData $app */ @endphp
<x-mary-badge class="badge-soft badge-primary" :value="$app->appName"/>
@endforeach
@endscope
@scope('actions', $row)
<div class="flex gap-1">
<x-mary-button
icon="lucide.eye"
:link="route('access-manager.roles-and-apps.show', $row->id)"
wire:navigate
tooltip="View details"
class="btn-sm btn-circle btn-soft"
/>
<x-mary-button
icon="lucide.trash"
wire:click="delete({{ $row->id }})"
spinner="delete({{ $row->id }})"
variant="danger"
class="btn-sm btn-circle btn-error btn-soft"
/>
</div>
@endscope
</x-mary-table>
</x-shared.card>
<x-mary-modal wire:model="showCreateModal" title="Add New Role">
<x-mary-form wire:submit="save">
<x-mary-input
label="Role Name"
wire:model="form.name"
id="role-name-input"
type="text"
placeholder="e.g. editor, manager"
autofocus
/>
<x-shared.choices
model="form.appIds"
:options="$appsSearchable"
search-function="searchApps"
select-all-action="selectAllApps"
clear-action="clearApps"
label="Apps"
/>
<x-mary-input
label="Duration"
wire:model="form.duration"
type="number"
/>
<x-slot:actions>
<x-mary-button @click="$wire.showCreateModal = false" class="btn-ghost">
Cancel
</x-mary-button>
<x-mary-button type="submit" icon="lucide.save" spinner="save">
Save Role
</x-mary-button>
</x-slot:actions>
</x-mary-form>
</x-mary-modal>
</div>

View File

@ -0,0 +1,421 @@
<?php
use App\Concerns\HandlesOperations;
use App\Data\Role\{AssignAppsToRoleData, RoleAppData};
use App\Data\Ui\TableHeader;
use App\Livewire\Forms\AssignAppToRoleForm;
use App\Models\Role;
use App\Services\AppRoleService;
use Livewire\Attributes\{Computed, Layout, Title};
use Livewire\Component;
use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\DataCollection;
/**
* @property-read DataCollection $apps
*/
new
#[Layout('layouts.app.sidebar')]
#[Title('Role Details')]
class extends Component {
use HandlesOperations;
public int $roleId = 0;
public string $roleName = '';
public bool $showAddAppModal = false;
// UI State for Editing
public bool $isEditingApp = false;
public ?int $editingAssignmentId = null;
public string $editingAppName = '';
public AssignAppToRoleForm $assignForm;
public array $appsSearchable = [];
public bool $showAddUserModal = false;
public ?int $selectedUserId = null;
public array $usersSearchable = [];
protected AppRoleService $appRoleService;
#[DataCollectionOf(TableHeader::class)]
public DataCollection $appHeaders;
#[DataCollectionOf(TableHeader::class)]
public DataCollection $userHeaders;
public function boot(AppRoleService $appRoleService): void
{
$this->appRoleService = $appRoleService;
}
public function mount(int $id): void
{
$this->attempt(
action: function () use ($id): void {
$role = Role::query()->findOrFail($id);
$this->roleId = $role->id;
$this->roleName = $role->name;
},
onError: function (): void {
$this->redirectRoute('access-manager.roles-and-apps', navigate: true);
},
showSuccess: false,
);
$this->appHeaders = TableHeader::collect([
new TableHeader(key: 'app_name', label: 'App'),
new TableHeader(key: 'duration', label: 'Duration (days)'),
], DataCollection::class);
$this->userHeaders = TableHeader::collect([
new TableHeader(key: 'name', label: 'Name'),
new TableHeader(key: 'email', label: 'Email'),
], DataCollection::class);
}
#[Computed]
public function apps(): DataCollection
{
return $this->appRoleService->getAppsForRole($this->roleId);
}
/**
* TODO: Replace with a service method that returns user DTOs for this role.
*
* @return array<int, array<string, mixed>>
*/
#[Computed]
public function getRoleUsers(): array
{
return [];
}
public function openAddAppModal(): void
{
$this->isEditingApp = false;
$this->editingAssignmentId = null;
$this->editingAppName = '';
$this->assignForm->reset();
$this->resetErrorBag();
$this->searchApps('');
$this->showAddAppModal = true;
}
public function openEditAppModal(int $assignmentId): void
{
$this->isEditingApp = true;
$this->editingAssignmentId = $assignmentId;
$this->resetErrorBag();
$assignment = collect($this->apps())->firstWhere('id', $assignmentId);
if ($assignment) {
$this->assignForm->appIds = [$assignment['appId'] ?? $assignment['id']];
$this->assignForm->duration = $assignment['duration'];
$this->editingAppName = $assignment['appName'];
}
$this->showAddAppModal = true;
}
public function saveApp(): void
{
$this->assignForm->validate();
if ($this->isEditingApp) {
$this->updateApp();
} else {
$this->assignApp();
}
}
public function searchApps(string $value = ''): void
{
$this->appsSearchable = $this
->appRoleService
->searchAppsForSelect($value)
->toArray();
}
public function selectAllApps(): void
{
$this->assignForm->appIds = $this->appRoleService->getAllAppIds();
$this->appsSearchable = $this->appRoleService->searchAppsForSelect('')->toArray();
}
public function clearApps(): void
{
$this->assignForm->appIds = [];
}
public function assignApp(): void
{
$this->attempt(
action: function (): void {
$this->appRoleService->assign(new AssignAppsToRoleData(
roleId: $this->roleId,
duration: $this->assignForm->duration,
appIds: $this->assignForm->appIds,
));
$this->assignForm->reset();
$this->showAddAppModal = false;
unset($this->apps);
},
successMessage: 'Apps assigned successfully!',
);
}
public function updateApp(): void
{
$this->attempt(
action: function (): void {
$this->appRoleService->assign(new AssignAppsToRoleData(
roleId: $this->roleId,
duration: $this->assignForm->duration,
appIds: $this->assignForm->appIds,
));
$this->assignForm->reset();
$this->showAddAppModal = false;
unset($this->apps);
},
successMessage: 'Apps updated successfully!',
);
}
public function detachApp(int $assignmentId): void
{
$this->attempt(
action: function () use ($assignmentId): void {
$this->appRoleService->detach($assignmentId);
unset($this->apps);
},
successMessage: 'App removed from role.',
);
}
public function openAddUserModal(): void
{
$this->reset('selectedUserId');
$this->resetErrorBag();
$this->showAddUserModal = true;
}
public function searchUsers(string $value = ''): void
{
$this->usersSearchable = [];
}
public function assignUser(): void
{
$this->success('User assignment not implemented yet.');
}
public function detachUser(int $userId): void
{
$this->success('User removal not implemented yet.');
}
};
?>
<div>
<div class="mb-6 flex items-center justify-between">
<div>
<div class="flex items-center gap-3">
<x-mary-button
:link="route('access-manager.roles-and-apps')"
wire:navigate
icon="lucide.arrow-left"
class="btn-sm btn-ghost"
tooltip-right="Back To Roles & Apps"
/>
<h1 class="text-2xl font-semibold text-gray-900">{{ $roleName }}</h1>
</div>
</div>
</div>
<div class="flex flex-col md:flex-row gap-6 w-full">
<x-shared.card title="Connected Apps" icon="heroicon-o-squares-2x2" class="pb-2 w-full h-min">
<x-slot:actions>
<x-mary-button wire:click="openAddAppModal" icon="lucide.plus" class="btn-sm">
Add App
</x-mary-button>
</x-slot:actions>
@if ($this->apps->count())
<x-mary-table
:headers="$appHeaders->toArray()"
:rows="$this->apps->toArray()"
>
@scope('cell_app_name', $row)
<span class="font-medium text-gray-900">{{ $row['appName'] }}</span>
@endscope
@scope('cell_duration', $row)
{{ $row['duration'] }} days
@endscope
@scope('actions', $row)
<div class="flex gap-1">
<x-mary-button
icon="lucide.square-pen"
wire:click="openEditAppModal({{ $row['id'] }})"
spinner="openEditAppModal({{ $row['id'] }})"
class="btn-sm btn-soft btn-circle"
/>
<x-mary-button
icon="lucide.trash"
wire:click="detachApp({{ $row['id'] }})"
spinner="detachApp({{ $row['id'] }})"
class="btn-sm btn-error btn-soft btn-circle"
/>
</div>
@endscope
</x-mary-table>
@else
<p class="p-6 text-center text-sm text-gray-400">
No apps assigned to this role yet.
</p>
@endif
</x-shared.card>
<x-shared.card title="Users" icon="heroicon-o-users" class="pb-2 w-full h-min">
<x-slot:actions>
<x-mary-button wire:click="openAddUserModal" icon="lucide.plus" class="btn-sm">
Add User
</x-mary-button>
</x-slot:actions>
@if (count($this->getRoleUsers))
<x-mary-table
:headers="$userHeaders->toArray()"
:rows="$this->getRoleUsers"
>
@scope('cell_name', $row)
<span class="font-medium text-gray-900">{{ $row['name'] }}</span>
@endscope
@scope('cell_email', $row)
{{ $row['email'] }}
@endscope
@scope('actions', $row)
<x-mary-button
icon="lucide.trash"
wire:click="detachUser({{ $row['id'] }})"
spinner="detachUser({{ $row['id'] }})"
variant="danger"
class="btn-sm btn-error btn-soft btn-circle"
/>
@endscope
</x-mary-table>
@else
<p class="p-6 text-center text-sm text-gray-400">
No users assigned to this role yet.
</p>
@endif
</x-shared.card>
</div>
<x-mary-modal wire:model="showAddAppModal"
title="{{ $isEditingApp ? 'Edit App Duration' : 'Add Apps to ' . $roleName }}">
<x-mary-form wire:submit="saveApp">
<div class="space-y-4">
@if(!$isEditingApp)
<div>
<div class="mb-1 flex items-end justify-between">
<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"
:options="$appsSearchable"
search-function="searchApps"
option-label="name"
option-value="id"
no-result-text="No apps found."
placeholder="Search apps..."
searchable
/>
</div>
@else
<x-mary-input
label="Connected App"
wire:model="editingAppName"
readonly
disabled
class="bg-gray-50 text-gray-500 cursor-not-allowed"
/>
@endif
<x-mary-input
label="Duration (days)"
wire:model="assignForm.duration"
id="assign-duration-input"
type="number"
min="1"
max="3650"
placeholder="30"
/>
</div>
<x-slot:actions>
<x-mary-button @click="$wire.showAddAppModal = false" class="btn-ghost">
Cancel
</x-mary-button>
<x-mary-button type="submit" icon="{{ $isEditingApp ? 'lucide.save' : 'lucide.link' }}"
spinner="saveApp">
{{ $isEditingApp ? 'Update Duration' : 'Assign Apps' }}
</x-mary-button>
</x-slot:actions>
</x-mary-form>
</x-mary-modal>
<x-mary-modal wire:model="showAddUserModal" title="Add User to {{ $roleName }}">
<x-mary-form wire:submit="assignUser">
<x-mary-choices
wire:model="selectedUserId"
:options="$usersSearchable"
search-function="searchUsers"
option-label="name"
option-value="id"
no-result-text="No users found."
placeholder="Search users..."
searchable
single
label="User"
/>
<x-slot:actions>
<x-mary-button @click="$wire.showAddUserModal = false" class="btn-ghost">
Cancel
</x-mary-button>
<x-mary-button type="submit" icon="lucide.user-plus" spinner="assignUser">
Assign User
</x-mary-button>
</x-slot:actions>
</x-mary-form>
</x-mary-modal>
</div>

View File

@ -9,6 +9,11 @@
Route::group(['middleware' => ['auth', 'verified']], function (): void { Route::group(['middleware' => ['auth', 'verified']], function (): void {
Route::livewire('dashboard', 'pages::dashboard')->name('dashboard'); Route::livewire('dashboard', 'pages::dashboard')->name('dashboard');
Route::prefix('access-manager')->name('access-manager.')->group(function (): void {
Route::livewire('roles-and-apps', 'pages::access-manager.roles-and-apps.index')->name('roles-and-apps');
Route::livewire('roles-and-apps/{id}', 'pages::access-manager.roles-and-apps.show')->name('roles-and-apps.show');
});
Route::prefix('apps')->name('apps.')->group(function (): void { Route::prefix('apps')->name('apps.')->group(function (): void {
Route::livewire('create', 'pages::connected-apps.create')->name('create'); Route::livewire('create', 'pages::connected-apps.create')->name('create');
Route::livewire('{id}/edit', 'pages::connected-apps.edit')->name('edit'); Route::livewire('{id}/edit', 'pages::connected-apps.edit')->name('edit');