= 9c9e3ca43c Feat: Update App details from view page
- Implemented the `show` page for managing URL apps with functionalities like updating name, logo, access URL, and connection status.
- Added `UrlAppViewTest` for feature testing, ensuring role-based access, and validation of edit actions.
- Updated navigation to route URL apps to their respective management views.
- Enhanced `OidcService` to handle updated app settings and redirect URIs.
2026-06-22 11:20:49 +00:00

645 lines
32 KiB
PHP

<?php
use App\Concerns\HandlesOperations;
use App\Enums\Permissions\AppPermissionEnum;
use App\Enums\ConnectionStatusEnum;
use App\Models\{ConnectedApp, ConnectionStatus};
use Livewire\Component;
use Livewire\Attributes\Title;
use Livewire\WithFileUploads;
use Laravel\Passport\Client;
use App\Services\Applications\OIDC\{OidcService, OidcConfigResolver};
use App\Services\ConnectedAppService;
use App\Services\ApplicationLogoUploader;
use App\Data\ConnectedApp\ConnectAppRequest;
use App\Livewire\Concerns\WithRepeaterFields;
new #[Title('OIDC Application Details')]
class extends Component {
use HandlesOperations, WithFileUploads, WithRepeaterFields;
public int $appId;
public ConnectedApp $app;
public ?Client $passportClient = null;
public array $signOutUris = [];
public string $selectedTab = 'general-tab';
public ?string $newSecretPlain = null;
// Edit fields
public bool $editGeneralModal = false;
public string $editName = '';
public $editLogo = null;
public bool $editRedirectsMode = false;
public array $editSignInUris = [];
public array $editSignOutUris = [];
private OidcService $oidcService;
private OidcConfigResolver $configResolver;
private ConnectedAppService $connectedAppService;
private ApplicationLogoUploader $logoUploader;
public function boot(
OidcService $oidcService,
OidcConfigResolver $configResolver,
ConnectedAppService $connectedAppService,
ApplicationLogoUploader $logoUploader
): void {
$this->oidcService = $oidcService;
$this->configResolver = $configResolver;
$this->connectedAppService = $connectedAppService;
$this->logoUploader = $logoUploader;
}
public function requiresPkce(Client $client): bool
{
return $this->configResolver->requiresPkce($client);
}
public function mount(int $id): void
{
$this->authorize(AppPermissionEnum::Read->value);
$this->appId = $id;
$this->loadAppData();
}
public function loadAppData(): void
{
$this->app = ConnectedApp::with(['protocol', 'status', 'roles'])->findOrFail($this->appId);
$this->passportClient = $this->oidcService->getPassportClient($this->app);
$this->signOutUris = $this->oidcService->getSignOutUris($this->app);
}
public function generateNewSecret(): void
{
$this->authorize(AppPermissionEnum::Update->value);
if (!$this->passportClient) {
$this->error('No corresponding Passport Client found to update.');
return;
}
$this->attempt(
action: function () {
$plainSecret = $this->oidcService->generateNewSecret($this->passportClient);
$this->newSecretPlain = $plainSecret;
$this->success('A new client secret has been generated! Make sure to copy it now.');
$this->loadAppData();
},
successMessage: 'New client secret generated successfully!',
showSuccess: false
);
}
public function changeStatus(string $statusName): void
{
$this->authorize(AppPermissionEnum::Update->value);
$this->attempt(
action: function () use ($statusName) {
$statusId = ConnectionStatus::where('name', $statusName)->firstOrFail()->id;
$request = new ConnectAppRequest(
name: $this->app->name,
connectionProtocolId: $this->app->connection_protocol_id,
connectionProviderId: $this->app->connection_provider_id,
slug: $this->app->slug,
connectionStatusId: $statusId,
settings: $this->app->settings,
logo: $this->app->logo,
);
$this->connectedAppService->update($this->app->id, $request);
$this->loadAppData();
},
successMessage: 'Application status updated successfully!'
);
}
public function goBack(): void
{
$this->redirectRoute('apps.index', navigate: true);
}
public function openEditGeneralModal(): void
{
$this->editName = $this->app->name;
$this->editLogo = null;
$this->editGeneralModal = true;
}
public function saveGeneralInfo(): void
{
$this->authorize(AppPermissionEnum::Update->value);
$this->validate([
'editName' => 'required|string|max:255',
'editLogo' => 'nullable|image|max:2048',
]);
$this->attempt(
action: function () {
$logoPath = $this->editLogo
? $this->logoUploader->store($this->editLogo)
: $this->app->logo;
$request = new ConnectAppRequest(
name: $this->editName,
connectionProtocolId: $this->app->connection_protocol_id,
connectionProviderId: $this->app->connection_provider_id,
slug: \Illuminate\Support\Str::slug($this->editName),
connectionStatusId: $this->app->connection_status_id,
settings: $this->app->settings,
logo: $logoPath,
);
$this->connectedAppService->update($this->app->id, $request);
if ($this->passportClient && $this->passportClient->name !== $this->editName) {
$this->passportClient->name = $this->editName;
$this->passportClient->save();
}
$this->editGeneralModal = false;
$this->success('Application updated successfully!');
$this->loadAppData();
}
);
}
public function startEditRedirects(): void
{
$clientUris = $this->passportClient->redirect_uris ?? [];
$this->editSignOutUris = $this->signOutUris;
$this->editSignInUris = array_values(array_filter(
$clientUris,
fn($uri) => !in_array($uri, $this->editSignOutUris, true)
));
$this->editRedirectsMode = true;
}
public function cancelEditRedirects(): void
{
$this->editRedirectsMode = false;
}
public function saveRedirectUris(): void
{
$this->authorize(AppPermissionEnum::Update->value);
if (!$this->passportClient) {
$this->error('No corresponding Passport Client found to update.');
return;
}
$signInUris = array_values(array_filter(array_map('trim', $this->editSignInUris)));
$signOutUris = array_values(array_filter(array_map('trim', $this->editSignOutUris)));
$this->attempt(
action: function () use ($signInUris, $signOutUris) {
$this->oidcService->updateRedirectUris($this->app, $this->passportClient, $signInUris, $signOutUris);
$this->editRedirectsMode = false;
$this->success('Redirect URIs updated successfully!');
$this->loadAppData();
}
);
}
};
?>
<div class="space-y-6">
<!-- Header Card -->
<x-shared.card class="p-6">
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div class="flex items-center gap-4">
<div class="relative group">
<x-mary-avatar
:image="$app->logo ? asset($app->logo) : null"
:placeholder="$app->logo ? null : 'lucide.settings'"
class="w-16 h-16 rounded-xl border border-gray-200 p-2 shadow-xs bg-gray-50 group-hover:opacity-85 transition-opacity"
/>
</div>
<div>
<div class="flex items-center gap-2">
<h1 class="text-2xl font-bold text-gray-900 ">{{ $app->name }}</h1>
<button wire:click="openEditGeneralModal" class="text-gray-400 hover:text-gray-600 ">
<x-lucide-pencil class="w-4 h-4"/>
</button>
</div>
<p class="text-sm text-gray-500 mt-0.5">Protocol: <span
class="font-semibold text-gray-700 ">{{ strtoupper($app->protocol->name) }}</span>
</p>
</div>
</div>
<div class="flex items-center gap-3">
<!-- Status Dropdown/Trigger -->
<div class="dropdown dropdown-end">
<label tabindex="0"
class="btn btn-sm btn-outline gap-1.5 font-semibold {{ $app->status->name === 'connected' ? 'border-emerald-200 text-emerald-700 bg-emerald-50/30' : ($app->status->name === 'disconnected' ? 'border-rose-200 text-rose-700 bg-rose-50/30' : 'border-gray-200 text-gray-700 bg-gray-50/30') }}">
<span
class="w-2 h-2 rounded-full {{ $app->status->name === 'connected' ? 'bg-emerald-500' : ($app->status->name === 'disconnected' ? 'bg-rose-500' : 'bg-gray-400') }}"></span>
{{ ucfirst($app->status->name) }}
<x-lucide-chevron-down class="w-3.5 h-3.5 opacity-60"/>
</label>
<ul tabindex="0"
class="dropdown-content menu p-1 shadow-md bg-base-100 rounded-box w-44 border border-gray-100 mt-1 z-30">
@foreach(App\Enums\ConnectionStatusEnum::cases() as $statusCase)
<li>
<button wire:click="changeStatus('{{ $statusCase->value }}')"
class="font-medium text-xs py-2">
<span
class="w-2 h-2 rounded-full {{ $statusCase->value === 'connected' ? 'bg-emerald-500' : ($statusCase->value === 'disconnected' ? 'bg-rose-500' : 'bg-gray-400') }}"></span>
{{ ucfirst($statusCase->value) }}
</button>
</li>
@endforeach
</ul>
</div>
<x-mary-button
icon="lucide.arrow-left"
class="btn-sm btn-ghost text-gray-500"
wire:click="goBack"
label="Back"
/>
</div>
</div>
</x-shared.card>
<!-- Main Content Area -->
<x-shared.card class="p-0 overflow-hidden">
<x-mary-tabs wire:model="selectedTab" class="px-4" label-div-class="pt-3 bg-gray-50 border-b border-gray-200"
label-class="font-bold pb-3">
<!-- General Tab -->
<x-mary-tab name="general-tab" label="General">
<div class="space-y-6">
@if($newSecretPlain)
<x-mary-alert
title="New Client Secret Generated"
description="Make sure to copy the client secret below now. You won't be able to see it again."
icon="lucide.key-round"
class="alert-warning alert-soft border border-yellow-200/80"
/>
<div x-data="{ copied: false }" class="flex items-center gap-2 max-w-xl">
<input
type="text"
readonly
value="{{ $newSecretPlain }}"
x-ref="secretInput"
class="input input-sm font-mono w-full bg-white border border-gray-200 focus:outline-none"
/>
<button
type="button"
@click="navigator.clipboard.writeText($refs.secretInput.value); copied = true; setTimeout(() => copied = false, 2000)"
class="btn btn-sm btn-square btn-outline border-gray-200 shrink-0"
>
<x-lucide-copy class="w-4 h-4 text-gray-500" x-show="!copied"/>
<x-lucide-check class="w-4 h-4 text-green-500" x-show="copied"/>
</button>
</div>
@endif
@if(!$passportClient)
<x-mary-alert
title="Passport Client Misconfigured"
description="No Laravel Passport Client is associated with this app name. Double check database or create client."
icon="lucide.alert-triangle"
class="alert-error alert-soft"
/>
@endif
<!-- Client Credentials Section -->
<x-shared.card title="Client Credentials">
<div class="p-6 space-y-6">
<!-- Client ID -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-2 md:items-center">
<span class="text-xs font-medium text-gray-500">Client ID</span>
<div class="md:col-span-3 flex items-center gap-2 max-w-lg">
<input
type="text"
readonly
value="{{ $passportClient?->id ?? 'Not found' }}"
class="input input-sm input-bordered font-mono w-full bg-gray-50 border-gray-200 focus:outline-hidden"
/>
@if($passportClient)
<x-mary-button
icon="lucide.copy"
class="btn-sm btn-square btn-outline border-gray-200"
@click="navigator.clipboard.writeText('{{ $passportClient->id }}'); alert('Client ID copied!');"
tooltip="Copy Client ID"
/>
@endif
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-2">
<div></div>
<div class="md:col-span-3 text-xs text-gray-400 -mt-2">
Public identifier for the client that is required for all OAuth flows.
</div>
</div>
<!-- Client Authentication -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-2 md:items-start">
<span class="text-xs font-medium text-gray-500">Client Authentication</span>
<div class="md:col-span-3 space-y-3">
<label class="flex items-center gap-2 cursor-pointer">
<input type="radio" checked readonly class="radio radio-primary radio-xs"/>
<span
class="text-xs font-medium text-gray-700 ">Client secret</span>
</label>
<label class="flex items-center gap-2 opacity-50 cursor-not-allowed">
<input type="radio" readonly disabled class="radio radio-xs"/>
<span class="text-xs font-medium text-gray-700">Public key / Private key</span>
</label>
</div>
</div>
<!-- PKCE -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-2 md:items-center">
<span
class="text-xs font-medium text-gray-500">Proof Key for Code Exchange (PKCE)</span>
<div class="md:col-span-3">
<label class="flex items-center gap-2">
<input
type="checkbox"
@click.prevent
@checked($passportClient && $this->requiresPkce($passportClient))
class="checkbox checkbox-xs"
/>
<span
class="text-xs text-gray-600">Require PKCE as additional verification</span>
</label>
</div>
</div>
</div>
</x-shared.card>
<!-- Client Secrets Section -->
<x-shared.card title="Client Secrets">
@if($passportClient)
<x-slot:actions>
<x-mary-button
wire:click="generateNewSecret"
class="btn-outline border-blue-200 text-blue-600 hover:bg-blue-50"
icon="lucide.plus"
label="Generate new secret"
spinner="generateNewSecret"
/>
</x-slot:actions>
@endif
<div class="overflow-x-auto">
<table class="table w-full text-xs">
<thead>
<tr class="bg-gray-50/30 text-gray-400 border-b border-gray-100">
<th class="font-semibold py-3 px-6 text-left">Creation date</th>
<th class="font-semibold py-3 px-6 text-left">Secret</th>
<th class="font-semibold py-3 px-6 text-right">Status</th>
</tr>
</thead>
<tbody>
@if($passportClient)
<tr class="hover:bg-gray-50/50 border-b border-gray-100">
<td class="py-4 px-6 text-gray-600 ">
{{ $passportClient->updated_at?->format('M j, Y H:i:s') ?? 'N/A' }}
</td>
<td class="py-4 px-6 font-mono text-gray-500 tracking-widest flex items-center gap-3">
<span>••••••••••••••••••••••••••••••••••••••••</span>
</td>
<td class="py-4 px-6 text-right">
<span
class="badge {{ !$passportClient->revoked ? 'badge-soft badge-success' : 'badge-soft badge-error' }} font-semibold text-xs px-2.5 py-1">
{{ !$passportClient->revoked ? 'Active' : 'Revoked' }}
</span>
</td>
</tr>
@else
<tr>
<td colspan="3" class="text-center py-6 text-gray-400 italic">No secrets
available
</td>
</tr>
@endif
</tbody>
</table>
</div>
</x-shared.card>
</div>
</x-mary-tab>
<!-- Sign On Tab -->
<x-mary-tab name="signon-tab" label="Sign On">
<div class="space-y-6">
<x-shared.card title="Sign In Redirect URIs">
<x-slot:actions>
@if($editRedirectsMode)
<div class="flex gap-2">
<x-mary-button
icon="lucide.check"
class="btn-primary"
label="Save"
wire:click="saveRedirectUris"
/>
<x-mary-button
icon="lucide.x"
class="btn-ghost"
label="Cancel"
wire:click="cancelEditRedirects"
/>
</div>
@else
<x-mary-button
icon="lucide.pencil"
class="btn-outline border-gray-200"
label="Edit"
wire:click="startEditRedirects"
/>
@endif
</x-slot:actions>
<div class="p-6 space-y-3">
@if($editRedirectsMode)
<x-shared.repeater-input
model="editSignInUris"
:items="$editSignInUris"
placeholder="https://app.example.com/callback"
min="0"
/>
@else
@php
$onlySignInUris = array_values(array_filter(
$passportClient?->redirect_uris ?? [],
fn($uri) => !in_array($uri, $signOutUris, true)
));
@endphp
@if(!empty($onlySignInUris))
@foreach($onlySignInUris as $uri)
<div
class="flex items-center gap-2 p-2 border border-gray-150 rounded-lg font-mono text-xs text-gray-600 bg-gray-50">
<x-lucide-link class="w-3.5 h-3.5 text-gray-400"/>
<span class="flex-1">{{ $uri }}</span>
</div>
@endforeach
@else
<span
class="text-xs text-gray-400 italic">No Sign In Redirect URIs configured</span>
@endif
@endif
</div>
</x-shared.card>
<!-- Sign Out Redirect URIs -->
<x-shared.card title="Sign Out Redirect URIs">
<div class="p-6 space-y-3">
@if($editRedirectsMode)
<x-shared.repeater-input
model="editSignOutUris"
:items="$editSignOutUris"
placeholder="https://app.example.com/logout"
min="0"
/>
@else
@if(!empty($signOutUris))
@foreach($signOutUris as $uri)
<div
class="flex items-center gap-2 p-2 border border-gray-150 rounded-lg font-mono text-xs text-gray-600 bg-gray-50">
<x-lucide-log-out class="w-3.5 h-3.5 text-gray-400"/>
<span class="flex-1">{{ $uri }}</span>
</div>
@endforeach
@else
<span
class="text-xs text-gray-400 italic">No Sign Out Redirect URIs configured</span>
@endif
@endif
</div>
</x-shared.card>
</div>
</x-mary-tab>
<!-- Assignments Tab -->
<x-mary-tab name="assignments-tab" label="Assignments">
<div class="">
<x-shared.card title="Assigned Roles">
<div class="overflow-x-auto">
<table class="table w-full text-xs">
<thead>
<tr class="bg-gray-50/30 text-gray-400 border-b border-gray-100">
<th class="font-semibold py-3 px-6 text-left">Role Name</th>
<th class="font-semibold py-3 px-6 text-left">Priority</th>
<th class="font-semibold py-3 px-6 text-right">Access Type</th>
</tr>
</thead>
<tbody>
@forelse($app->roles as $role)
<tr class="hover:bg-gray-50/50 border-b border-gray-100">
<td class="py-4 px-6 font-semibold text-gray-800 0">
{{ $role->name }}
</td>
<td class="py-4 px-6 text-gray-500">
{{ $role->priority }}
</td>
<td class="py-4 px-6 text-right">
<span
class="badge badge-soft badge-primary font-semibold text-xs px-2.5 py-1">
SSO Access
</span>
</td>
</tr>
@empty
<tr>
<td colspan="3" class="text-center py-6 text-gray-400 italic">
No specific roles assigned. Accessible by everyone depending on user access
setting.
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</x-shared.card>
</div>
</x-mary-tab>
<!-- Scopes Tab -->
<x-mary-tab name="scopes-tab" label="Scopes">
<div class="">
<x-shared.card title="Granted OAuth/OIDC Scopes">
<div class="p-6 space-y-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="flex items-start gap-3 p-3 border border-gray-100 rounded-xl bg-gray-50/50">
<span class="p-2 bg-blue-50 text-blue-600 rounded-lg mt-0.5">
<x-lucide-user class="w-4 h-4"/>
</span>
<div>
<div class="font-semibold text-xs text-gray-800">openid</div>
<p class="text-[11px] text-gray-400 mt-0.5">Allows the application to fetch ID
tokens for authenticating users.</p>
</div>
</div>
<div class="flex items-start gap-3 p-3 border border-gray-100 rounded-xl bg-gray-50/50">
<span class="p-2 bg-blue-50 text-blue-600 rounded-lg mt-0.5">
<x-lucide-id-card class="w-4 h-4"/>
</span>
<div>
<div class="font-semibold text-xs text-gray-800">profile</div>
<p class="text-[11px] text-gray-400 mt-0.5">Allows reading default profile
fields like name, picture, and locale.</p>
</div>
</div>
<div class="flex items-start gap-3 p-3 border border-gray-100 rounded-xl bg-gray-50/50">
<span class="p-2 bg-blue-50 text-blue-600 rounded-lg mt-0.5">
<x-lucide-mail class="w-4 h-4"/>
</span>
<div>
<div class="font-semibold text-xs text-gray-800">email</div>
<p class="text-[11px] text-gray-400 mt-0.5">Grants access to the user's primary
email address.</p>
</div>
</div>
<div class="flex items-start gap-3 p-3 border border-gray-100 rounded-xl bg-gray-50/50">
<span class="p-2 bg-blue-50 text-blue-600 rounded-lg mt-0.5">
<x-lucide-refresh-cw class="w-4 h-4"/>
</span>
<div>
<div class="font-semibold text-xs text-gray-800">offline_access</div>
<p class="text-[11px] text-gray-400 mt-0.5">Allows requesting refresh tokens to
maintain access offline.</p>
</div>
</div>
</div>
</div>
</x-shared.card>
</div>
</x-mary-tab>
</x-mary-tabs>
</x-shared.card>
<!-- Edit General Info Modal -->
<x-mary-modal wire:model="editGeneralModal" title="Edit App General Settings" class="backdrop-blur">
<x-mary-form wire:submit="saveGeneralInfo">
<x-mary-input
label="Application Name"
wire:model="editName"
placeholder="e.g. My App"
required
/>
<x-mary-file
label="Application Logo"
wire:model="editLogo"
accept="image/jpeg,image/png"
hint="Optional. Max 2MB."
/>
<x-slot:actions>
<x-mary-button label="Cancel" @click="$wire.editGeneralModal = false" class="btn-ghost"/>
<x-mary-button label="Save Settings" type="submit" class="btn-primary" spinner="saveGeneralInfo"/>
</x-slot:actions>
</x-mary-form>
</x-mary-modal>
</div>