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.
This commit is contained in:
parent
05c4e5eab7
commit
e791c08ce1
43
app/Enums/UserAccessTypeEnum.php
Normal file
43
app/Enums/UserAccessTypeEnum.php
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Enums;
|
||||||
|
|
||||||
|
use App\Concerns\EnumValuesAsArray;
|
||||||
|
use App\Contracts\EnumMorphsToOptionsContract;
|
||||||
|
|
||||||
|
enum UserAccessTypeEnum: string implements EnumMorphsToOptionsContract
|
||||||
|
{
|
||||||
|
use EnumValuesAsArray;
|
||||||
|
case Everyone = 'everyone';
|
||||||
|
case Role = 'role';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<array{name: string, value:string, hint:string}>
|
||||||
|
*/
|
||||||
|
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.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
17
app/Livewire/Concerns/Choices/ChoiceField.php
Normal file
17
app/Livewire/Concerns/Choices/ChoiceField.php
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Livewire\Concerns\Choices;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
|
||||||
|
final readonly class ChoiceField
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public Closure $search, // fn(string $value): array
|
||||||
|
public Closure $allIds, // fn(): array
|
||||||
|
public string $formPath, // e.g. 'form.roleIds'
|
||||||
|
public ?Closure $after = null, // optional side effect, e.g. re-hydrate permissions
|
||||||
|
) {}
|
||||||
|
}
|
||||||
203
app/Livewire/Concerns/Choices/README.md
Normal file
203
app/Livewire/Concerns/Choices/README.md
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
# WithSearchableChoices
|
||||||
|
|
||||||
|
A small Livewire trait that removes the repeated `search{X}` / `selectAll{X}` / `clear{X}`
|
||||||
|
boilerplate that shows up every time you wire a `<x-shared.choices>` 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
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Livewire\Concerns;
|
||||||
|
|
||||||
|
final readonly class ChoiceField
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
public \Closure $search, // fn(string $value): array
|
||||||
|
public \Closure $allIds, // fn(): array
|
||||||
|
public string $formPath, // e.g. 'form.roleIds'
|
||||||
|
public ?\Closure $after = null, // optional side effect, e.g. re-hydrate permissions
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `app/Livewire/Concerns/WithSearchableChoices.php`
|
||||||
|
|
||||||
|
The trait. Holds one searchable-options property (keyed by field name) instead of one
|
||||||
|
property per entity, plus the three generic operations.
|
||||||
|
|
||||||
|
```php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Livewire\Concerns;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
trait WithSearchableChoices
|
||||||
|
{
|
||||||
|
/** @var array<string, array<int, mixed>> */
|
||||||
|
public array $choicesSearchable = [];
|
||||||
|
|
||||||
|
/** @return array<string, ChoiceField> */
|
||||||
|
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
|
||||||
|
<x-shared.choices
|
||||||
|
wire:model.live="form.roleIds"
|
||||||
|
:options="$choicesSearchable['roles'] ?? []"
|
||||||
|
search-function="searchRoles"
|
||||||
|
select-all-action="selectAllRoles"
|
||||||
|
clear-action="clearRoles"
|
||||||
|
label="Roles"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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
|
||||||
|
<x-shared.choices
|
||||||
|
wire:model.live="form.permissionIds"
|
||||||
|
:options="$choicesSearchable['permissions'] ?? []"
|
||||||
|
search-function="searchPermissions"
|
||||||
|
select-all-action="selectAllPermissions"
|
||||||
|
clear-action="clearPermissions"
|
||||||
|
label="Permissions"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 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 `<x-shared.choices>` 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.
|
||||||
53
app/Livewire/Concerns/Choices/WithSearchableChoices.php
Normal file
53
app/Livewire/Concerns/Choices/WithSearchableChoices.php
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Livewire\Concerns\Choices;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
|
||||||
|
trait WithSearchableChoices
|
||||||
|
{
|
||||||
|
/** @var array<string, array<int, mixed>> */
|
||||||
|
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<string, ChoiceField> */
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
22
app/Livewire/Concerns/WithRepeaterFields.php
Normal file
22
app/Livewire/Concerns/WithRepeaterFields.php
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Livewire\Concerns;
|
||||||
|
|
||||||
|
trait WithRepeaterFields
|
||||||
|
{
|
||||||
|
public function addRepeaterItem(string $property, mixed $default = ''): void
|
||||||
|
{
|
||||||
|
$items = data_get($this, $property, []);
|
||||||
|
$items[] = $default;
|
||||||
|
data_set($this, $property, $items);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function removeRepeaterItem(string $property, int $index): void
|
||||||
|
{
|
||||||
|
$items = data_get($this, $property, []);
|
||||||
|
unset($items[$index]);
|
||||||
|
data_set($this, $property, array_values($items));
|
||||||
|
}
|
||||||
|
}
|
||||||
54
app/Livewire/Forms/StoreOIDCAppForm.php
Normal file
54
app/Livewire/Forms/StoreOIDCAppForm.php
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Livewire\Forms;
|
||||||
|
|
||||||
|
use App\Enums\UserAccessTypeEnum;
|
||||||
|
use Livewire\Attributes\Validate;
|
||||||
|
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
|
||||||
|
use Livewire\Form;
|
||||||
|
|
||||||
|
class StoreOIDCAppForm extends Form
|
||||||
|
{
|
||||||
|
#[Validate('required|string|max:255|min:3')]
|
||||||
|
public string $name = '';
|
||||||
|
|
||||||
|
#[Validate('nullable|image|max:1024')]
|
||||||
|
public ?TemporaryUploadedFile $logo = null;
|
||||||
|
|
||||||
|
/** @var string[] */
|
||||||
|
#[Validate([
|
||||||
|
'signInUris' => '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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use Illuminate\Foundation\Application;
|
use Illuminate\Foundation\Application;
|
||||||
use Illuminate\Foundation\Configuration\{Exceptions, Middleware};
|
use Illuminate\Foundation\Configuration\{Exceptions, Middleware};
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
|
||||||
return Application::configure(basePath: dirname(__DIR__))
|
return Application::configure(basePath: dirname(__DIR__))
|
||||||
->withRouting(
|
->withRouting(
|
||||||
@ -22,4 +23,3 @@
|
|||||||
fn (Request $request) => $request->is('api/*'),
|
fn (Request $request) => $request->is('api/*'),
|
||||||
);
|
);
|
||||||
})->create();
|
})->create();
|
||||||
|
|
||||||
|
|||||||
@ -1,3 +1,7 @@
|
|||||||
|
{{--
|
||||||
|
Read /App/Livewire/Concerns/Choices/README.md first.
|
||||||
|
This will give you a resuable idea of using this component
|
||||||
|
--}}
|
||||||
@props([
|
@props([
|
||||||
'label' => 'Select Items',
|
'label' => 'Select Items',
|
||||||
'options', // The data collection passed from the parent
|
'options', // The data collection passed from the parent
|
||||||
|
|||||||
48
resources/views/components/shared/repeater-input.blade.php
Normal file
48
resources/views/components/shared/repeater-input.blade.php
Normal file
@ -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,
|
||||||
|
])
|
||||||
|
|
||||||
|
<div {{ $attributes->class(['w-full']) }}>
|
||||||
|
@if ($label)
|
||||||
|
<label class="label pb-1">
|
||||||
|
<span class="label-text font-semibold">{{ $label }}</span>
|
||||||
|
</label>
|
||||||
|
@endif
|
||||||
|
|
||||||
|
@foreach ($items as $index => $value)
|
||||||
|
<x-mary-input
|
||||||
|
wire:key="{{ $model }}-row-{{ $index }}"
|
||||||
|
wire:model="{{ $model }}.{{ $index }}"
|
||||||
|
placeholder="{{ $placeholder }}"
|
||||||
|
icon="{{ $icon }}"
|
||||||
|
@class([
|
||||||
|
'border-r-red-400' => count($items) > $min
|
||||||
|
])
|
||||||
|
>
|
||||||
|
@if (count($items) > $min)
|
||||||
|
<x-slot:append>
|
||||||
|
<x-mary-button
|
||||||
|
icon="o-x-mark"
|
||||||
|
wire:click="removeRepeaterItem('{{ $model }}', {{ $index }})"
|
||||||
|
class="join-item btn-outline btn-error"
|
||||||
|
spinner
|
||||||
|
/>
|
||||||
|
</x-slot:append>
|
||||||
|
@endif
|
||||||
|
</x-mary-input>
|
||||||
|
<div class="mb-4"></div>
|
||||||
|
@endforeach
|
||||||
|
|
||||||
|
<x-mary-button
|
||||||
|
:label="$addLabel"
|
||||||
|
icon="o-plus"
|
||||||
|
wire:click="addRepeaterItem('{{ $model }}')"
|
||||||
|
class="btn-primary btn-outline"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
196
resources/views/pages/apps/oidc/⚡create.blade.php
Normal file
196
resources/views/pages/apps/oidc/⚡create.blade.php
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Concerns\HandlesOperations;
|
||||||
|
use App\Data\ConnectedApp\{ConnectAppRequest, ConnectionStatusData};
|
||||||
|
use App\Enums\{ApplicationTypeEnum, ConnectionProtocolEnum, ConnectionStatusEnum, UserAccessTypeEnum};
|
||||||
|
use App\Livewire\Forms\StoreOIDCAppForm;
|
||||||
|
use App\Livewire\Concerns\Choices\{ChoiceField, WithSearchableChoices};
|
||||||
|
use App\Livewire\Concerns\WithRepeaterFields;
|
||||||
|
use App\Models\{ConnectionProtocol, ConnectionProvider, ConnectionStatus};
|
||||||
|
use App\Services\{ConnectedAppService,
|
||||||
|
ConnectionProtocolService,
|
||||||
|
ConnectionProviderService,
|
||||||
|
ConnectionStatusService,
|
||||||
|
RoleService
|
||||||
|
};
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Livewire\Attributes\{Computed, Title, Url};
|
||||||
|
use Livewire\Component;
|
||||||
|
use Livewire\WithFileUploads;
|
||||||
|
use Mary\Traits\Toast;
|
||||||
|
use Spatie\LaravelData\DataCollection;
|
||||||
|
|
||||||
|
new #[Title("Create a service")]
|
||||||
|
class extends Component {
|
||||||
|
use HandlesOperations, WithRepeaterFields, WithSearchableChoices, WithFileUploads;
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public string $protocol = '';
|
||||||
|
|
||||||
|
#[Url]
|
||||||
|
public ?string $type = null;
|
||||||
|
|
||||||
|
public StoreOIDCAppForm $form;
|
||||||
|
public ConnectionProtocolEnum $connectionProtocol = ConnectionProtocolEnum::OIDC;
|
||||||
|
public ?ApplicationTypeEnum $applicationType = null;
|
||||||
|
private RoleService $roleService;
|
||||||
|
|
||||||
|
public function boot(RoleService $roleService): void
|
||||||
|
{
|
||||||
|
$this->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',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<x-shared.card :title="$this->title()" icon="lucide-package-plus">
|
||||||
|
<x-slot:actions>
|
||||||
|
<x-mary-button wire:click="goBack" icon="lucide.corner-up-left" class="btn-ghost">Go Back
|
||||||
|
</x-mary-button>
|
||||||
|
</x-slot:actions>
|
||||||
|
<x-mary-form wire:submit="save" class="p-4 gap-y-8">
|
||||||
|
<!-- General settings start -->
|
||||||
|
<div class="flex gap-4 flex-col w-full md:flex-row">
|
||||||
|
<article class="w-full md:w-2/5">
|
||||||
|
<h2 class="font-bold">General Settings</h2>
|
||||||
|
</article>
|
||||||
|
<div class="w-full md:w-3/5">
|
||||||
|
<x-mary-input required label="App Name" wire:model="form.name" placeholder="Keka, MS 365 etc."
|
||||||
|
hint="A basic name that matches the actual application"/>
|
||||||
|
<x-mary-file wire:model="form.logo" label="Logo" hint="Optional"
|
||||||
|
accept="application/jpg, application/png, image/jpeg, image/png"/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- General settings end -->
|
||||||
|
|
||||||
|
<!-- Sign-in uris start -->
|
||||||
|
<hr>
|
||||||
|
<div class="flex gap-4 flex-col w-full md:flex-row">
|
||||||
|
<article class="w-full md:w-2/5">
|
||||||
|
<h2 class="font-bold">Sign In Redirect URIs</h2>
|
||||||
|
<p class="text-sm text-gray-500 max-w-70 mt-4">
|
||||||
|
{{config('app.name')}} sends response and ID Token back to these URIs.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<div class="w-full md:w-3/5">
|
||||||
|
<x-shared.repeater-input model="form.signInUris" :items="$form->signInUris"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Sign-in uris end -->
|
||||||
|
|
||||||
|
<!-- Sign-out uris start -->
|
||||||
|
<hr>
|
||||||
|
<div class="flex gap-4 flex-col w-full md:flex-row">
|
||||||
|
<article class="w-full md:w-2/5">
|
||||||
|
<h2 class="font-bold">Sign out Redirect URIs</h2>
|
||||||
|
<p class="text-sm text-gray-500 max-w-70 mt-4">
|
||||||
|
{{config('app.name')}} sends back the user to these URIs after logout.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<div class="w-full md:w-3/5">
|
||||||
|
<x-shared.repeater-input model="form.signOutUris" :items="$form->signOutUris"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- Sign-out uris end -->
|
||||||
|
|
||||||
|
<!-- Access start -->
|
||||||
|
<hr>
|
||||||
|
<div class="flex gap-4 flex-col w-full md:flex-row">
|
||||||
|
<article class="w-full md:w-2/5">
|
||||||
|
<h2 class="font-bold">User Access</h2>
|
||||||
|
<p class="text-sm text-gray-500 max-w-70 mt-4">
|
||||||
|
Choose whether to allow all users be able to connect this app or selected ones.
|
||||||
|
</p>
|
||||||
|
</article>
|
||||||
|
<div class="w-full md:w-3/5">
|
||||||
|
<x-mary-radio
|
||||||
|
:options="UserAccessTypeEnum::asOptions()"
|
||||||
|
option-value="value"
|
||||||
|
wire:model.change.live="form.userAccessOption"
|
||||||
|
/>
|
||||||
|
<div class=""
|
||||||
|
x-show="$wire.form.userAccessOption === '{{UserAccessTypeEnum::Role->value}}'"
|
||||||
|
x-cloak
|
||||||
|
>
|
||||||
|
<x-shared.choices
|
||||||
|
wire:model.live="form.roleIds"
|
||||||
|
:options="$choicesSearchable['roles'] ?? []"
|
||||||
|
search-function="searchRoles"
|
||||||
|
select-all-action="selectAllRoles"
|
||||||
|
clear-action="clearRoles"
|
||||||
|
label="Roles"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<!-- Access end -->
|
||||||
|
|
||||||
|
<div class="flex gap-x-4 font-medium justify-end">
|
||||||
|
<x-mary-button wire:click.prevent="goBack" spinner="goBack">Cancel</x-mary-button>
|
||||||
|
<x-mary-button wire:click.prevent="save(false)">Save & Add Another</x-mary-button>
|
||||||
|
<x-mary-button type="submit"
|
||||||
|
class="btn-primary">
|
||||||
|
Save
|
||||||
|
</x-mary-button>
|
||||||
|
</div>
|
||||||
|
</x-mary-form>
|
||||||
|
</x-shared.card>
|
||||||
|
</div>
|
||||||
@ -73,8 +73,8 @@ public function delete(int $appId): void
|
|||||||
public function createApp(): void
|
public function createApp(): void
|
||||||
{
|
{
|
||||||
$this->appSelectionForm->validate();
|
$this->appSelectionForm->validate();
|
||||||
$this->redirectRoute('apps.create', [
|
$route = "apps.{$this->appSelectionForm->connectionProtocol}.create";
|
||||||
'protocol' => $this->appSelectionForm->connectionProtocol,
|
$this->redirectRoute($route, [
|
||||||
'type' => $this->appSelectionForm->applicationType
|
'type' => $this->appSelectionForm->applicationType
|
||||||
], navigate: true);
|
], navigate: true);
|
||||||
}
|
}
|
||||||
@ -1,209 +0,0 @@
|
|||||||
<?php
|
|
||||||
|
|
||||||
use App\Concerns\HandlesOperations;
|
|
||||||
use App\Data\ConnectedApp\ConnectAppRequest;
|
|
||||||
use App\Data\ConnectedApp\ConnectionStatusData;
|
|
||||||
use App\Enums\ConnectionStatusEnum;
|
|
||||||
use App\Livewire\Forms\StoreConnectedAppFrom;
|
|
||||||
use App\Models\{ConnectionProtocol, ConnectionProvider, ConnectionStatus};
|
|
||||||
use App\Services\{
|
|
||||||
ConnectedAppService,
|
|
||||||
ConnectionProtocolService,
|
|
||||||
ConnectionProviderService,
|
|
||||||
ConnectionStatusService,
|
|
||||||
};
|
|
||||||
use Illuminate\Support\Collection;
|
|
||||||
use Livewire\Attributes\{Computed, Title};
|
|
||||||
use Livewire\Component;
|
|
||||||
use Mary\Traits\Toast;
|
|
||||||
use Spatie\LaravelData\DataCollection;
|
|
||||||
|
|
||||||
new #[Title("Create a service")]
|
|
||||||
class extends Component {
|
|
||||||
use HandlesOperations;
|
|
||||||
|
|
||||||
public StoreConnectedAppFrom $form;
|
|
||||||
protected ConnectedAppService $appService;
|
|
||||||
|
|
||||||
public function boot(ConnectedAppService $appService): void
|
|
||||||
{
|
|
||||||
$this->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<int, ConnectionStatus>
|
|
||||||
*/
|
|
||||||
#[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);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
?>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<x-shared.card :title="__('Create a service')" class="">
|
|
||||||
<x-mary-form wire:submit="save" class="p-4 grid grid-cols-1 gap-4 md:grid-cols-2">
|
|
||||||
<x-mary-input wire:model.live.blur="form.name" required :label="__('Name')"/>
|
|
||||||
<x-mary-choices
|
|
||||||
required
|
|
||||||
wire:model.live="form.protocolId"
|
|
||||||
:label="__('Protocol')"
|
|
||||||
:single="true"
|
|
||||||
:options="$this->protocols"
|
|
||||||
placeholder="Select"
|
|
||||||
/>
|
|
||||||
<x-mary-choices
|
|
||||||
required
|
|
||||||
wire:model.live="form.providerId"
|
|
||||||
:label="__('Provider')"
|
|
||||||
placeholder="Select"
|
|
||||||
:single="true"
|
|
||||||
:options="$this->providers"
|
|
||||||
/>
|
|
||||||
<x-mary-choices
|
|
||||||
required
|
|
||||||
wire:model="form.statusId"
|
|
||||||
:label="__('Status')"
|
|
||||||
:single="true"
|
|
||||||
:options="$this->connectionStatuses->toArray()"
|
|
||||||
placeholder="Select"
|
|
||||||
/>
|
|
||||||
|
|
||||||
@if($this->isSaml)
|
|
||||||
<x-dashboard.connected-apps.saml-configuration :form="$this->form"/>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
@if($this->isKekaProvider)
|
|
||||||
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-gray-200 pt-4 mt-2">
|
|
||||||
<h3 class="font-semibold text-sm text-blue-600 md:col-span-2">Keka Configuration</h3>
|
|
||||||
<x-mary-input wire:model="form.kekaUrl" required :label="__('Keka Portal URL')"
|
|
||||||
placeholder="e.g. https://sentientgeeks.keka.com" class="md:col-span-2"/>
|
|
||||||
</div>
|
|
||||||
@endif
|
|
||||||
|
|
||||||
<div class="md:col-span-2 flex gap-x-4 font-medium justify-end">
|
|
||||||
<x-mary-button wire:click.prevent="goBack" spinner="goBack">Cancel</x-mary-button>
|
|
||||||
<x-mary-button wire:click.prevent="save(false)">Save & Add Another</x-mary-button>
|
|
||||||
<x-mary-button type="submit"
|
|
||||||
class="btn-primary">
|
|
||||||
Save
|
|
||||||
</x-mary-button>
|
|
||||||
</div>
|
|
||||||
</x-mary-form>
|
|
||||||
</x-shared.card>
|
|
||||||
</div>
|
|
||||||
@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Enums\ConnectionProtocolEnum;
|
||||||
use App\Enums\Permissions\{AppPermissionEnum, ConnectionProviderPermissionEnum};
|
use App\Enums\Permissions\{AppPermissionEnum, ConnectionProviderPermissionEnum};
|
||||||
|
use Illuminate\Support\Facades\Route;
|
||||||
use Spatie\Permission\Middleware\PermissionMiddleware;
|
use Spatie\Permission\Middleware\PermissionMiddleware;
|
||||||
|
|
||||||
Route::prefix('apps')->name('apps.')->group(function (): void {
|
Route::prefix('apps')->name('apps.')->group(function (): void {
|
||||||
@ -11,9 +13,19 @@
|
|||||||
->name('microsoft-federation');
|
->name('microsoft-federation');
|
||||||
|
|
||||||
Route::middleware(PermissionMiddleware::using(AppPermissionEnum::Access))->group(function (): void {
|
Route::middleware(PermissionMiddleware::using(AppPermissionEnum::Access))->group(function (): void {
|
||||||
Route::livewire('create', 'pages::connected-apps.create')->name('create');
|
Route::middleware(PermissionMiddleware::using(AppPermissionEnum::Create))->group(function (): void {
|
||||||
Route::livewire('{id}/edit', 'pages::connected-apps.edit')->name('edit');
|
Route::prefix(ConnectionProtocolEnum::OIDC->value)
|
||||||
Route::livewire('/', 'pages::connected-apps.index')->name('index');
|
->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.')
|
Route::prefix('connection-providers')->name('connectionProviders.')
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user