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:
parent
495c0ea2d8
commit
c680c1ae6e
@ -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
|
||||
@ -18,5 +18,6 @@ public function __construct(
|
||||
public int $providerId,
|
||||
#[MapInputName('connection_status_id')]
|
||||
public int $statusId,
|
||||
public ?string $slug = null,
|
||||
) {}
|
||||
}
|
||||
|
||||
17
app/Data/Role/AssignAppsToRoleData.php
Normal file
17
app/Data/Role/AssignAppsToRoleData.php
Normal 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
|
||||
) {}
|
||||
}
|
||||
14
app/Data/Role/CreateRoleData.php
Normal file
14
app/Data/Role/CreateRoleData.php
Normal 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,
|
||||
) {}
|
||||
}
|
||||
31
app/Data/Role/RoleAppData.php
Normal file
31
app/Data/Role/RoleAppData.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
18
app/Data/Role/RoleData.php
Normal file
18
app/Data/Role/RoleData.php
Normal 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
|
||||
) {}
|
||||
}
|
||||
10
app/Enums/ConnectedAppPermissonEnum.php
Normal file
10
app/Enums/ConnectedAppPermissonEnum.php
Normal file
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum ConnectedAppPermissonEnum: string
|
||||
{
|
||||
case Use = 'use';
|
||||
}
|
||||
21
app/Helpers/AppPermission.php
Normal file
21
app/Helpers/AppPermission.php
Normal 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}";
|
||||
}
|
||||
}
|
||||
27
app/Livewire/Forms/AssignAppToRoleForm.php
Normal file
27
app/Livewire/Forms/AssignAppToRoleForm.php
Normal 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;
|
||||
}
|
||||
}
|
||||
13
app/Livewire/Forms/CreateRoleForm.php
Normal file
13
app/Livewire/Forms/CreateRoleForm.php
Normal 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 = '';
|
||||
}
|
||||
111
app/Services/AppRoleService.php
Normal file
111
app/Services/AppRoleService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
32
app/Services/AppsPermissionService.php
Normal file
32
app/Services/AppsPermissionService.php
Normal 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]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,7 @@
|
||||
|
||||
use App\Data\ConnectedApp\{ConnectAppRequest, ConnectedAppData};
|
||||
use App\Data\Ui\ConnectedApps\ConnectedAppData as UiConnectedAppData;
|
||||
use App\Enums\ConnectedAppPermissonEnum;
|
||||
use App\Models\ConnectedApp;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
@ -15,6 +16,10 @@
|
||||
|
||||
final class ConnectedAppService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AppsPermissionService $permissionService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Create a new connected app.
|
||||
*
|
||||
@ -23,9 +28,13 @@ final class ConnectedAppService
|
||||
public function create(ConnectAppRequest $data): void
|
||||
{
|
||||
DB::transaction(
|
||||
fn () => ConnectedApp::query()->create(
|
||||
$data->toArray()
|
||||
)
|
||||
function () use ($data): void {
|
||||
$connectedApp = ConnectedApp::query()->create(
|
||||
$data->toArray()
|
||||
);
|
||||
|
||||
$this->permissionService->add(ConnectedAppData::from($connectedApp), ConnectedAppPermissonEnum::Use);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
64
app/Services/RoleService.php
Normal file
64
app/Services/RoleService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -19,7 +19,7 @@ public function up(): void
|
||||
$table->foreignIdFor(ConnectedApp::class, 'app_id');
|
||||
$table->foreignId('role_id')->comment('role_id from spatie roles table');
|
||||
$table->integer('duration')->comment('in days');
|
||||
$table->index(['app_id', 'role_id']);
|
||||
$table->unique(['app_id', 'role_id']);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -51,6 +51,19 @@ public function navItems(): DataCollection
|
||||
], 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(
|
||||
label: 'Settings',
|
||||
icon: 'lucide.settings',
|
||||
|
||||
36
resources/views/components/shared/choices.blade.php
Normal file
36
resources/views/components/shared/choices.blade.php
Normal 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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -9,6 +9,11 @@
|
||||
Route::group(['middleware' => ['auth', 'verified']], function (): void {
|
||||
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::livewire('create', 'pages::connected-apps.create')->name('create');
|
||||
Route::livewire('{id}/edit', 'pages::connected-apps.edit')->name('edit');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user