From e791c08ce1d0aae05bed4b8e90309ab350df024b Mon Sep 17 00:00:00 2001 From: = Date: Thu, 18 Jun 2026 09:48:07 +0000 Subject: [PATCH] Feat: (UI) Replace connected-app creation with modular OIDC service and components - Removed the old `connected-apps.create` implementation and components, simplifying the codebase. - Introduced modular OIDC integration using reusable components and traits, such as `WithSearchableChoices` and `WithRepeaterFields`. - Added `StoreOIDCAppForm` for handling OpenID Connect app-specific fields and validation. - Updated `choices.blade.php` and related components to leverage configuration-driven, dynamic multi-select functionality. - Updated routes to support new OIDC app creation under distinct connection protocol namespaces. - Enhanced app creation flow with support for dynamic fields like URIs (`repeater-input`) and scoped user roles. --- app/Enums/UserAccessTypeEnum.php | 43 ++++ app/Livewire/Concerns/Choices/ChoiceField.php | 17 ++ app/Livewire/Concerns/Choices/README.md | 203 +++++++++++++++++ .../Choices/WithSearchableChoices.php | 53 +++++ app/Livewire/Concerns/WithRepeaterFields.php | 22 ++ app/Livewire/Forms/StoreOIDCAppForm.php | 54 +++++ bootstrap/app.php | 2 +- .../views/components/shared/choices.blade.php | 4 + .../shared/repeater-input.blade.php | 48 ++++ .../views/pages/apps/oidc/⚡create.blade.php | 196 ++++++++++++++++ .../{connected-apps => apps}/⚡edit.blade.php | 0 .../⚡index.blade.php | 4 +- .../pages/connected-apps/⚡create.blade.php | 209 ------------------ routes/apps.php | 18 +- 14 files changed, 658 insertions(+), 215 deletions(-) create mode 100644 app/Enums/UserAccessTypeEnum.php create mode 100644 app/Livewire/Concerns/Choices/ChoiceField.php create mode 100644 app/Livewire/Concerns/Choices/README.md create mode 100644 app/Livewire/Concerns/Choices/WithSearchableChoices.php create mode 100644 app/Livewire/Concerns/WithRepeaterFields.php create mode 100644 app/Livewire/Forms/StoreOIDCAppForm.php create mode 100644 resources/views/components/shared/repeater-input.blade.php create mode 100644 resources/views/pages/apps/oidc/⚡create.blade.php rename resources/views/pages/{connected-apps => apps}/⚡edit.blade.php (100%) rename resources/views/pages/{connected-apps => apps}/⚡index.blade.php (97%) delete mode 100644 resources/views/pages/connected-apps/⚡create.blade.php diff --git a/app/Enums/UserAccessTypeEnum.php b/app/Enums/UserAccessTypeEnum.php new file mode 100644 index 0000000..c10e580 --- /dev/null +++ b/app/Enums/UserAccessTypeEnum.php @@ -0,0 +1,43 @@ + + */ + public static function asOptions(): array + { + return array_map(fn (self $enum) => [ + 'name' => $enum->toLabel(), + 'value' => $enum->value, + 'hint' => $enum->toHint(), + ], self::cases()); + } + + public function toLabel(): string + { + return match ($this) { + self::Everyone => 'Everyone', + self::Role => 'Role', + }; + } + + public function toHint(): string + { + return match ($this) { + self::Everyone => 'Everyone can access the application.', + self::Role => 'Only users with the specified role can access the application.', + }; + } +} diff --git a/app/Livewire/Concerns/Choices/ChoiceField.php b/app/Livewire/Concerns/Choices/ChoiceField.php new file mode 100644 index 0000000..1bc6e75 --- /dev/null +++ b/app/Livewire/Concerns/Choices/ChoiceField.php @@ -0,0 +1,17 @@ +` multi-select to a +service-backed list (roles, permissions, users, tags, etc.). + +Instead of writing three full methods per entity, you register one config entry per +field and keep three one-line delegates. All the real logic lives once, in the trait. + +## Requirements + +- PHP 8.2+ (uses a `readonly` class with constructor promotion; first-class callable + syntax `(...)` is PHP 8.1+, so this also runs fine on 8.1 if you swap those for + closures) +- Laravel (uses the `data_set()` helper) +- Livewire 3 or 4 — nothing here is version-specific. Traits operate at the PHP class + level, so this works whether your component is a classic two-file component or a + Livewire 4 single-file `⚡component.blade.php`. + +## Files + +### `app/Livewire/Concerns/ChoiceField.php` + +Describes a single searchable field: how to search it, how to select all of it, where +its selected IDs live on the form object, and an optional side effect to run after a +change. + +```php +> */ + public array $choicesSearchable = []; + + /** @return array */ + abstract protected function choiceFields(): array; + + public function searchChoice(string $key, string $value = ''): void + { + $field = $this->resolveChoiceField($key); + + $this->choicesSearchable[$key] = ($field->search)($value); + } + + public function selectAllChoice(string $key): void + { + $field = $this->resolveChoiceField($key); + + data_set($this, $field->formPath, ($field->allIds)()); + + $this->searchChoice($key); + + $field->after?->__invoke(); + } + + public function clearChoice(string $key): void + { + $field = $this->resolveChoiceField($key); + + data_set($this, $field->formPath, []); + + $field->after?->__invoke(); + } + + protected function resolveChoiceField(string $key): ChoiceField + { + return $this->choiceFields()[$key] + ?? throw new InvalidArgumentException("Unknown choice field [{$key}]."); + } +} +``` + +Note that `choiceFields()` is a **method**, not a property. Closures can't be stored on +a public Livewire property (they're not JSON-serializable for the wire snapshot), so +keeping the config behind a method means it's rebuilt fresh each request and never +touches Livewire's hydration/dehydration cycle. + +## Usage + +### 1. Use the trait and register your fields + +```php +use App\Livewire\Concerns\{WithSearchableChoices, ChoiceField}; + +class RoleAssignmentForm extends Component +{ + use WithSearchableChoices; + + protected function choiceFields(): array + { + return [ + 'roles' => new ChoiceField( + search: fn (string $value): array => $this->roleService->searchRolesForSelect($value)->toArray(), + allIds: $this->roleService->getAllRoleIds(...), // <-- '...' is important here, as this make the closure callable + formPath: 'form.roleIds', // <-- this should be the livewire form object's property path in dot notation + after: $this->hydratePermissionInForm(...), // <-- optional side effect, that will be run after a change + ), + ]; + } + + public function searchRoles(string $value = ''): void { $this->searchChoice('roles', $value); } + public function selectAllRoles(): void { $this->selectAllChoice('roles'); } + public function clearRoles(): void { $this->clearChoice('roles'); } +} +``` + +The three delegate methods exist because Livewire requires `wire:click`/action targets +to be real, declared public methods — it won't fall through to `__call()`. Keeping them +means your Blade markup and method names don't change at all; they just forward into +the trait. + +### 2. Update the Blade options binding + +Everything stays the same except where `:options` reads from, since there's now one +shared `$choicesSearchable` array keyed by field name instead of a separate property +per entity: + +```blade + +``` + +### 3. Add another field (e.g. permissions) + +```php +protected function choiceFields(): array +{ + return [ + 'roles' => new ChoiceField(/* ... */), + + 'permissions' => new ChoiceField( + search: fn (string $value): array => $this->permissionService->searchPermissionsForSelect($value)->toArray(), + allIds: $this->permissionService->getAllPermissionIds(...), + formPath: 'form.permissionIds', + ), + ]; +} + +public function searchPermissions(string $value = ''): void { $this->searchChoice('permissions', $value); } +public function selectAllPermissions(): void { $this->selectAllChoice('permissions'); } +public function clearPermissions(): void { $this->clearChoice('permissions'); } +``` + +```blade + +``` + +## Notes & caveats + +- `formPath` is resolved with Laravel's `data_set()`, so it works against any nested + object/array path on the component (`form.roleIds`, `filters.tags`, etc.), not just a + single level. +- The `after` callback runs on **select all** and **clear**, matching the original + behavior where selecting/clearing roles re-hydrates dependent permission state. Leave + it `null` for fields with no side effects. +- This assumes `` interpolates `search-function` / `select-all-action` + / `clear-action` directly into `wire:click` / `wire:keyup`-style attributes as plain + method names. If that component does anything more elaborate internally (e.g. wraps + the search call with extra arguments), double-check the generated action string + against this trait's method signatures. diff --git a/app/Livewire/Concerns/Choices/WithSearchableChoices.php b/app/Livewire/Concerns/Choices/WithSearchableChoices.php new file mode 100644 index 0000000..a741119 --- /dev/null +++ b/app/Livewire/Concerns/Choices/WithSearchableChoices.php @@ -0,0 +1,53 @@ +> */ + public array $choicesSearchable = []; + + public function selectAllChoice(string $key): void + { + $field = $this->resolveChoiceField($key); + + data_set($this, $field->formPath, ($field->allIds)()); + + $this->searchChoice($key); + + $field->after?->__invoke(); + } + + protected function resolveChoiceField(string $key): ChoiceField + { + return $this->choiceFields()[$key] + ?? throw new InvalidArgumentException("Unknown choice field [{$key}]."); + } + + /** @return array */ + abstract protected function choiceFields(): array; + + /** + * @param string $key this should be one of the top level array key, defined in choiceFields() + * @param string $value this is the value to search for + */ + public function searchChoice(string $key, string $value = ''): void + { + $field = $this->resolveChoiceField($key); + + $this->choicesSearchable[$key] = ($field->search)($value); + } + + public function clearChoice(string $key): void + { + $field = $this->resolveChoiceField($key); + + data_set($this, $field->formPath, []); + + $field->after?->__invoke(); + } +} diff --git a/app/Livewire/Concerns/WithRepeaterFields.php b/app/Livewire/Concerns/WithRepeaterFields.php new file mode 100644 index 0000000..7a49e69 --- /dev/null +++ b/app/Livewire/Concerns/WithRepeaterFields.php @@ -0,0 +1,22 @@ + 'nullable', + 'signInUris.*' => ['nullable', 'url'], + ], message: 'The sign in URI must be a valid URL.')] + public array $signInUris = ['']; + + /** @var string[] */ + #[Validate([ + 'signOutUris' => 'nullable', + 'signOutUris.*' => ['nullable', 'url'], + ])] + public array $signOutUris = ['']; + + #[Validate([ + 'userAccessOption' => 'required|in:'.UserAccessTypeEnum::Everyone->value.','.UserAccessTypeEnum::Role->value, + ])] + public string $userAccessOption = UserAccessTypeEnum::Everyone->value; + + /** @var int[] */ + #[Validate([ + 'roleIds' => 'required_if:userAccessOption,'.UserAccessTypeEnum::Role->value, + 'roleIds.*' => ['integer', 'exists:roles,id'], + ])] + public array $roleIds = []; + + public function validationAttributes(): array + { + return [ + 'signInUris.*' => 'Sign-in URI', + 'signOutUris.*' => 'Sign-out URI', + 'roleIds.*' => 'Role', + ]; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index fc22f2b..d5247af 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -4,6 +4,7 @@ use Illuminate\Foundation\Application; use Illuminate\Foundation\Configuration\{Exceptions, Middleware}; +use Illuminate\Http\Request; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( @@ -22,4 +23,3 @@ fn (Request $request) => $request->is('api/*'), ); })->create(); - diff --git a/resources/views/components/shared/choices.blade.php b/resources/views/components/shared/choices.blade.php index 345d0f7..d5c1f30 100644 --- a/resources/views/components/shared/choices.blade.php +++ b/resources/views/components/shared/choices.blade.php @@ -1,3 +1,7 @@ +{{-- + Read /App/Livewire/Concerns/Choices/README.md first. + This will give you a resuable idea of using this component +--}} @props([ 'label' => 'Select Items', 'options', // The data collection passed from the parent diff --git a/resources/views/components/shared/repeater-input.blade.php b/resources/views/components/shared/repeater-input.blade.php new file mode 100644 index 0000000..2779501 --- /dev/null +++ b/resources/views/components/shared/repeater-input.blade.php @@ -0,0 +1,48 @@ +@props([ +'model', // property path on the host component, e.g. "uris" +'items' => [], // current array value +'label' => null, +'addLabel' => 'Add', +'placeholder' => null, +'icon' => null, +'min' => 1, +]) + +
class(['w-full']) }}> + @if ($label) + + @endif + + @foreach ($items as $index => $value) + count($items) > $min + ]) + > + @if (count($items) > $min) + + + + @endif + +
+ @endforeach + + +
diff --git a/resources/views/pages/apps/oidc/⚡create.blade.php b/resources/views/pages/apps/oidc/⚡create.blade.php new file mode 100644 index 0000000..6a7cff0 --- /dev/null +++ b/resources/views/pages/apps/oidc/⚡create.blade.php @@ -0,0 +1,196 @@ +roleService = $roleService; + } + + public function mount(): void + { + $this->applicationType = ApplicationTypeEnum::tryFrom($this->type); + } + + public function save(bool $goBack = true): void + { + $this->form->validate(); + ds($this->form->pull()); + } + + + public function goBack(): void + { + $this->redirectRoute("apps.index", navigate: true); + } + + public function title(): string + { + $title = "New "; + $title .= match ($this->applicationType) { + ApplicationTypeEnum::Web => "Web Application ", + ApplicationTypeEnum::SPA => "Single Page Application ", + ApplicationTypeEnum::Machine => "Microservice Application ", + null => "" + }; + $title .= "Integration"; + return $title; + } + + public function searchRoles(string $q = ''): void + { + $this->searchChoice('roles', $q); + } + + public function selectAllRoles(): void + { + $this->selectAllChoice('roles'); + } + + public function clearRoles(): void + { + $this->clearChoice('roles'); + } + + protected function choiceFields(): array + { + return [ + 'roles' => new ChoiceField( + search: fn(string $q): array => $this->roleService->searchRolesForSelect($q)->toArray(), + allIds: $this->roleService->getAllRoleIds(...), + formPath: 'form.roleIds', + ), + ]; + } +}; +?> + +
+ + + Go Back + + + + +
+
+

General Settings

+
+
+ + + +
+
+ + + +
+
+
+

Sign In Redirect URIs

+

+ {{config('app.name')}} sends response and ID Token back to these URIs. +

+
+
+ +
+
+ + + +
+
+
+

Sign out Redirect URIs

+

+ {{config('app.name')}} sends back the user to these URIs after logout. +

+
+
+ +
+
+ + + +
+
+
+

User Access

+

+ Choose whether to allow all users be able to connect this app or selected ones. +

+
+
+ +
+ +
+
+ +
+ + +
+ Cancel + Save & Add Another + + Save + +
+
+
+
diff --git a/resources/views/pages/connected-apps/⚡edit.blade.php b/resources/views/pages/apps/⚡edit.blade.php similarity index 100% rename from resources/views/pages/connected-apps/⚡edit.blade.php rename to resources/views/pages/apps/⚡edit.blade.php diff --git a/resources/views/pages/connected-apps/⚡index.blade.php b/resources/views/pages/apps/⚡index.blade.php similarity index 97% rename from resources/views/pages/connected-apps/⚡index.blade.php rename to resources/views/pages/apps/⚡index.blade.php index 85920cb..2ac96eb 100644 --- a/resources/views/pages/connected-apps/⚡index.blade.php +++ b/resources/views/pages/apps/⚡index.blade.php @@ -73,8 +73,8 @@ public function delete(int $appId): void public function createApp(): void { $this->appSelectionForm->validate(); - $this->redirectRoute('apps.create', [ - 'protocol' => $this->appSelectionForm->connectionProtocol, + $route = "apps.{$this->appSelectionForm->connectionProtocol}.create"; + $this->redirectRoute($route, [ 'type' => $this->appSelectionForm->applicationType ], navigate: true); } diff --git a/resources/views/pages/connected-apps/⚡create.blade.php b/resources/views/pages/connected-apps/⚡create.blade.php deleted file mode 100644 index 202801b..0000000 --- a/resources/views/pages/connected-apps/⚡create.blade.php +++ /dev/null @@ -1,209 +0,0 @@ -appService = $appService; - } - - #[Computed] - public function providers() - { - return ConnectionProvider::query()->get(); - } - - #[Computed] - public function protocols(): Collection - { - $protocolService = app(ConnectionProtocolService::class); - - return $protocolService - ->getAll() - ->map(function (ConnectionProtocol $protocol) { - $protocol->name = strtoupper($protocol->name); - return $protocol; - }); - } - - /** - * @returns DataCollection - */ - #[Computed] - public function connectionStatuses(): DataCollection - { - $statusService = app(ConnectionStatusService::class); - - return $statusService - ->getAll() - ->map(function (ConnectionStatusData $status) { - if ($status->name === ConnectionStatusEnum::Disconnected) { - $this->form->statusId = $status->id; - } - return $status; - }); - } - - #[Computed] - public function isSaml(): bool - { - $protocol = $this->protocols()->firstWhere( - "id", - (int) $this->form->protocolId, - ); - return $protocol && strtolower($protocol->name) === "saml"; - } - - #[Computed] - public function isKekaProvider(): bool - { - $provider = $this->providers()->firstWhere( - "id", - (int) $this->form->providerId, - ); - return $provider && $provider->slug === "keka-hr"; - } - - public function save(bool $goBack = true): void - { - $this->form->validate(); - - $settings = null; - if ($this->isSaml()) { - $attributes = []; - foreach ($this->form->samlAttributes as $attr) { - if (!empty($attr['saml_name']) && !empty($attr['user_field'])) { - $attributes[$attr['saml_name']] = $attr['user_field']; - } - } - $settings = [ - "saml" => [ - "entity_id" => $this->form->samlEntityId, - "acs_url" => $this->form->samlAcsUrl, - "nameid_format" => $this->form->samlNameIdFormat, - "nameid_source" => $this->form->samlNameIdSource, - "attributes" => $attributes, - ], - ]; - } elseif ($this->isKekaProvider()) { - $settings = [ - "keka_url" => $this->form->kekaUrl, - "keka" => [ - "keka_url" => $this->form->kekaUrl, - "callback_path" => "signin-oidc", - "provider" => "Office365", - ], - ]; - } - - $data = new ConnectAppRequest( - name: $this->form->name, - connectionProviderId: $this->form->providerId, - connectionProtocolId: $this->form->protocolId, - slug: str($this->form->name)->slug()->toString(), - connectionStatusId: $this->form->statusId, - settings: $settings, - ); - $this->attempt( - action: function () use ($data, $goBack) { - $this->appService->create($data); - $this->form->reset(); - if ($goBack) { - $this->goBack(); - } - }, - ); - } - - public function addSamlAttribute(): void - { - $this->form->addSamlAttribute(); - } - - public function removeSamlAttribute(int $index): void - { - $this->form->removeSamlAttribute($index); - } - - public function goBack(): void - { - $this->redirectRoute("apps.index", navigate: true); - } -}; -?> - -
- - - - - - - - @if($this->isSaml) - - @endif - - @if($this->isKekaProvider) -
-

Keka Configuration

- -
- @endif - -
- Cancel - Save & Add Another - - Save - -
-
-
-
diff --git a/routes/apps.php b/routes/apps.php index 9ee3ea1..91ca008 100644 --- a/routes/apps.php +++ b/routes/apps.php @@ -2,7 +2,9 @@ declare(strict_types=1); +use App\Enums\ConnectionProtocolEnum; use App\Enums\Permissions\{AppPermissionEnum, ConnectionProviderPermissionEnum}; +use Illuminate\Support\Facades\Route; use Spatie\Permission\Middleware\PermissionMiddleware; Route::prefix('apps')->name('apps.')->group(function (): void { @@ -11,9 +13,19 @@ ->name('microsoft-federation'); Route::middleware(PermissionMiddleware::using(AppPermissionEnum::Access))->group(function (): void { - Route::livewire('create', 'pages::connected-apps.create')->name('create'); - Route::livewire('{id}/edit', 'pages::connected-apps.edit')->name('edit'); - Route::livewire('/', 'pages::connected-apps.index')->name('index'); + Route::middleware(PermissionMiddleware::using(AppPermissionEnum::Create))->group(function (): void { + Route::prefix(ConnectionProtocolEnum::OIDC->value) + ->name(ConnectionProtocolEnum::OIDC->value.'.') + ->group(function (): void { + Route::livewire('create', 'pages::apps.oidc.create')->name('create'); + }); + }); + + Route::livewire( + '{id}/edit', + 'pages::apps.edit' + )->middleware(PermissionMiddleware::using(AppPermissionEnum::Update))->name('edit'); + Route::livewire('/', 'pages::apps.index')->name('index'); }); Route::prefix('connection-providers')->name('connectionProviders.')