diff --git a/.agents/skills/fluxui-development/SKILL.md b/.agents/skills/fluxui-development/SKILL.md deleted file mode 100644 index 4b5aabb..0000000 --- a/.agents/skills/fluxui-development/SKILL.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -name: fluxui-development -description: "Use this skill for Flux UI development in Livewire applications only. Trigger when working with 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. - - -```blade -Click me -``` - -## 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. - - -```blade -Export -``` - -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 - - -```blade - - Email - - - -``` - -### Modals - - -```blade - - Title -

Content

-
-``` - -## 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 \ No newline at end of file diff --git a/app/Data/ConnectedApp/ConnectedAppData.php b/app/Data/ConnectedApp/ConnectedAppData.php index 464de8b..d53b683 100644 --- a/app/Data/ConnectedApp/ConnectedAppData.php +++ b/app/Data/ConnectedApp/ConnectedAppData.php @@ -18,5 +18,6 @@ public function __construct( public int $providerId, #[MapInputName('connection_status_id')] public int $statusId, + public ?string $slug = null, ) {} } diff --git a/app/Data/Role/AssignAppsToRoleData.php b/app/Data/Role/AssignAppsToRoleData.php new file mode 100644 index 0000000..9ac735c --- /dev/null +++ b/app/Data/Role/AssignAppsToRoleData.php @@ -0,0 +1,17 @@ +pivot->id, + appId: $app->id, + appName: $app->name, + duration: $app->pivot->duration + ); + } +} diff --git a/app/Data/Role/RoleData.php b/app/Data/Role/RoleData.php new file mode 100644 index 0000000..ed9602d --- /dev/null +++ b/app/Data/Role/RoleData.php @@ -0,0 +1,18 @@ +slug; + + return "{$permission->value}-{$slug}"; + } +} diff --git a/app/Livewire/Forms/AssignAppToRoleForm.php b/app/Livewire/Forms/AssignAppToRoleForm.php new file mode 100644 index 0000000..a2403c2 --- /dev/null +++ b/app/Livewire/Forms/AssignAppToRoleForm.php @@ -0,0 +1,27 @@ + '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; + } +} diff --git a/app/Livewire/Forms/CreateRoleForm.php b/app/Livewire/Forms/CreateRoleForm.php new file mode 100644 index 0000000..8b378ec --- /dev/null +++ b/app/Livewire/Forms/CreateRoleForm.php @@ -0,0 +1,13 @@ +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 + */ + #[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 + */ + #[NoDiscard] + public function getAllAppIds(): array + { + // Replace 'ConnectedApp' with your actual model name + return ConnectedApp::pluck('id')->toArray(); + } +} diff --git a/app/Services/AppsPermissionService.php b/app/Services/AppsPermissionService.php new file mode 100644 index 0000000..ad412bc --- /dev/null +++ b/app/Services/AppsPermissionService.php @@ -0,0 +1,32 @@ +slug) { + $connectedApp->slug = Str::slug($connectedApp->name); + } + $permissionName = AppPermission::name($permission, $connectedApp); + DB::transaction(function () use ($permissionName): void { + Permission::firstOrCreate(['name' => $permissionName]); + }); + } +} diff --git a/app/Services/ConnectedAppService.php b/app/Services/ConnectedAppService.php index 2e93255..65e5775 100644 --- a/app/Services/ConnectedAppService.php +++ b/app/Services/ConnectedAppService.php @@ -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); + } ); } diff --git a/app/Services/RoleService.php b/app/Services/RoleService.php new file mode 100644 index 0000000..eb68e3a --- /dev/null +++ b/app/Services/RoleService.php @@ -0,0 +1,64 @@ + 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 + */ + #[NoDiscard] + public function getAll(): PaginatedDataCollection + { + $roles = Role::query() + ->with('apps') + ->orderBy('id') + ->paginate(); + + return RoleData::collect($roles, PaginatedDataCollection::class); + } +} diff --git a/database/migrations/2026_05_18_113635_app_roles.php b/database/migrations/2026_05_18_113635_app_roles.php index d3048e0..26245c0 100644 --- a/database/migrations/2026_05_18_113635_app_roles.php +++ b/database/migrations/2026_05_18_113635_app_roles.php @@ -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']); }); } diff --git a/resources/views/components/dashboard-sidebar.blade.php b/resources/views/components/dashboard-sidebar.blade.php index 9f6e911..628cfd3 100644 --- a/resources/views/components/dashboard-sidebar.blade.php +++ b/resources/views/components/dashboard-sidebar.blade.php @@ -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', diff --git a/resources/views/components/shared/choices.blade.php b/resources/views/components/shared/choices.blade.php new file mode 100644 index 0000000..f3bcaea --- /dev/null +++ b/resources/views/components/shared/choices.blade.php @@ -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.' +]) + +
+ + + +
+ + +
+
+ + +
diff --git a/resources/views/pages/access-manager/roles-and-apps/⚡index.blade.php b/resources/views/pages/access-manager/roles-and-apps/⚡index.blade.php new file mode 100644 index 0000000..399cbd7 --- /dev/null +++ b/resources/views/pages/access-manager/roles-and-apps/⚡index.blade.php @@ -0,0 +1,190 @@ +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!', + ); + } +}; +?> + +
+ + + + Add new role + + + + + @scope('cell_name', $row) + {{ $row->name }} + @endscope + + @scope('cell_apps', $row) + @foreach($row->apps as $app) + @php /** @var RoleAppData $app */ @endphp + + @endforeach + @endscope + + @scope('actions', $row) +
+ + +
+ @endscope +
+
+ + + + + + + + + + Cancel + + + Save Role + + + + +
diff --git a/resources/views/pages/access-manager/roles-and-apps/⚡show.blade.php b/resources/views/pages/access-manager/roles-and-apps/⚡show.blade.php new file mode 100644 index 0000000..d0d7dab --- /dev/null +++ b/resources/views/pages/access-manager/roles-and-apps/⚡show.blade.php @@ -0,0 +1,421 @@ +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> + */ + #[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.'); + } +}; +?> + +
+
+
+
+ +

{{ $roleName }}

+
+
+
+ +
+ + + + + Add App + + + + @if ($this->apps->count()) + + @scope('cell_app_name', $row) + {{ $row['appName'] }} + @endscope + + @scope('cell_duration', $row) + {{ $row['duration'] }} days + @endscope + + @scope('actions', $row) +
+ + +
+ @endscope +
+ @else +

+ No apps assigned to this role yet. +

+ @endif +
+ + + + + Add User + + + + @if (count($this->getRoleUsers)) + + @scope('cell_name', $row) + {{ $row['name'] }} + @endscope + + @scope('cell_email', $row) + {{ $row['email'] }} + @endscope + + @scope('actions', $row) + + @endscope + + @else +

+ No users assigned to this role yet. +

+ @endif +
+
+ + + +
+ + @if(!$isEditingApp) +
+
+ +
+ + +
+
+ + +
+ @else + + @endif + + +
+ + + + Cancel + + + {{ $isEditingApp ? 'Update Duration' : 'Assign Apps' }} + + +
+
+ + + + + + + + Cancel + + + Assign User + + + + +
diff --git a/routes/web.php b/routes/web.php index 51df829..fa7acec 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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');