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\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();
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
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
|
||||
{
|
||||
$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);
|
||||
}
|
||||
@ -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);
|
||||
|
||||
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.')
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user