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:
= 2026-06-18 09:48:07 +00:00
parent 05c4e5eab7
commit e791c08ce1
14 changed files with 658 additions and 215 deletions

View 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.',
};
}
}

View 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
) {}
}

View 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.

View 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();
}
}

View 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));
}
}

View 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',
];
}
}

View File

@ -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();

View File

@ -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

View 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>

View 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>

View File

@ -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);
} }

View File

@ -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>

View File

@ -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.')