Feat: Add OIDC app management UI and features
- Introduced a detailed view for OIDC apps (`show` page) with client credentials and sign-out URI management. - Added functionality to toggle app status, generate new client secrets, and display OAuth scopes. - Implemented service methods for handling OIDC app data, including `getPassportClient`, `generateNewSecret`, and `toggleStatus`. - Enhanced feature tests for role-based access and validation of client secret generation.
This commit is contained in:
parent
83e56e35e0
commit
6a400fc532
@ -33,4 +33,14 @@ public function needSignOutUris(): bool
|
||||
return true;
|
||||
// return ApplicationTypeEnum::Machine !== $this->applicationType;
|
||||
}
|
||||
|
||||
public function isConfidential(\Laravel\Passport\Client $client): bool
|
||||
{
|
||||
return $client->confidential();
|
||||
}
|
||||
|
||||
public function requiresPkce(\Laravel\Passport\Client $client): bool
|
||||
{
|
||||
return ! $client->confidential();
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,11 +7,12 @@
|
||||
use App\Data\Application\{CreatedApplicationData, StoreOIDCAppData};
|
||||
use App\Data\ConnectedApp\ConnectAppRequest;
|
||||
use App\Enums\{ConnectionProtocolEnum, ConnectionStatusEnum};
|
||||
use App\Models\{ConnectionProtocol, ConnectionStatus};
|
||||
use App\Models\{ConnectedApp, ConnectionProtocol, ConnectionStatus};
|
||||
use App\Services\{ApplicationLogoUploader, ConnectedAppService};
|
||||
use App\Services\Applications\OIDC\Factory\ClientCreationFactory;
|
||||
use Illuminate\Support\Facades\{DB, Log};
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Passport\Client;
|
||||
use Throwable;
|
||||
|
||||
readonly class OidcService
|
||||
@ -43,6 +44,7 @@ public function createApp(StoreOIDCAppData $data): CreatedApplicationData
|
||||
connectionStatusId: ConnectionStatus::query()->where('name', ConnectionStatusEnum::Connected->value)->first()->id,
|
||||
settings: [
|
||||
'access_url' => $data->accessUrl,
|
||||
'sign_out_uris' => $data->signOutUris,
|
||||
],
|
||||
logo: $logo,
|
||||
)
|
||||
@ -74,4 +76,33 @@ public function createApp(StoreOIDCAppData $data): CreatedApplicationData
|
||||
throw $e;
|
||||
}
|
||||
}
|
||||
|
||||
public function getPassportClient(ConnectedApp $app): ?Client
|
||||
{
|
||||
return Client::where('name', $app->name)->first();
|
||||
}
|
||||
|
||||
public function generateNewSecret(Client $client): string
|
||||
{
|
||||
$plainSecret = Str::random(40);
|
||||
$client->forceFill(['secret' => bcrypt($plainSecret)])->save();
|
||||
|
||||
return $plainSecret;
|
||||
}
|
||||
|
||||
public function toggleStatus(ConnectedApp $app): void
|
||||
{
|
||||
$statusEnum = $app->status->name === ConnectionStatusEnum::Connected->value
|
||||
? ConnectionStatusEnum::Disconnected
|
||||
: ConnectionStatusEnum::Connected;
|
||||
|
||||
$newStatus = ConnectionStatus::where('name', $statusEnum->value)->firstOrFail();
|
||||
$app->connection_status_id = $newStatus->id;
|
||||
$app->save();
|
||||
}
|
||||
|
||||
public function getSignOutUris(ConnectedApp $app): array
|
||||
{
|
||||
return data_get($app, 'settings.sign_out_uris', []);
|
||||
}
|
||||
}
|
||||
|
||||
479
resources/views/pages/apps/oidc/⚡show.blade.php
Normal file
479
resources/views/pages/apps/oidc/⚡show.blade.php
Normal file
@ -0,0 +1,479 @@
|
||||
<?php
|
||||
|
||||
use App\Concerns\HandlesOperations;
|
||||
use App\Enums\Permissions\AppPermissionEnum;
|
||||
use App\Models\{ConnectedApp, ConnectionStatus};
|
||||
use Livewire\Component;
|
||||
use Livewire\Attributes\Title;
|
||||
use Laravel\Passport\Client;
|
||||
use App\Services\Applications\OIDC\{OidcService, OidcConfigResolver};
|
||||
|
||||
new #[Title('OIDC Application Details')]
|
||||
class extends Component {
|
||||
use HandlesOperations;
|
||||
|
||||
public int $appId;
|
||||
public ConnectedApp $app;
|
||||
public ?Client $passportClient = null;
|
||||
public array $signOutUris = [];
|
||||
public string $selectedTab = 'general-tab';
|
||||
public ?string $newSecretPlain = null;
|
||||
|
||||
private OidcService $oidcService;
|
||||
private OidcConfigResolver $configResolver;
|
||||
|
||||
public function boot(OidcService $oidcService, OidcConfigResolver $configResolver): void
|
||||
{
|
||||
$this->oidcService = $oidcService;
|
||||
$this->configResolver = $configResolver;
|
||||
}
|
||||
|
||||
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 toggleStatus(): void
|
||||
{
|
||||
$this->authorize(AppPermissionEnum::Update->value);
|
||||
|
||||
$this->attempt(
|
||||
action: function () {
|
||||
$this->oidcService->toggleStatus($this->app);
|
||||
$this->loadAppData();
|
||||
},
|
||||
successMessage: 'Application status updated successfully!'
|
||||
);
|
||||
}
|
||||
|
||||
public function goBack(): void
|
||||
{
|
||||
$this->redirectRoute('apps.index', navigate: true);
|
||||
}
|
||||
|
||||
public function editApp(): void
|
||||
{
|
||||
$this->redirectRoute('apps.edit', ['id' => $this->appId], navigate: true);
|
||||
}
|
||||
|
||||
public function requiresPkce(Client $client)
|
||||
{
|
||||
return $this->configResolver->requiresPkce($client);
|
||||
}
|
||||
};
|
||||
?>
|
||||
|
||||
<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"
|
||||
/>
|
||||
<button wire:click="editApp"
|
||||
class="absolute -bottom-1 -right-1 bg-white p-1.5 rounded-full border border-gray-200 shadow-sm hover:scale-105 transition-transform"
|
||||
tooltip="Edit Logo">
|
||||
<x-lucide-pencil class="w-3.5 h-3.5 text-gray-500"/>
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex items-center gap-2">
|
||||
<h1 class="text-2xl font-bold text-gray-900 ">{{ $app->name }}</h1>
|
||||
<button wire:click="editApp" 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' : 'border-rose-200 text-rose-700 bg-rose-50/30' }}">
|
||||
<span
|
||||
class="w-2 h-2 rounded-full {{ $app->status->name === 'connected' ? 'bg-emerald-500' : 'bg-rose-500' }}"></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">
|
||||
<li>
|
||||
<button wire:click="toggleStatus" class="font-medium text-xs py-2">
|
||||
<x-lucide-refresh-cw class="w-3.5 h-3.5 opacity-60"/>
|
||||
Toggle Status
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<x-mary-button
|
||||
icon="lucide.history"
|
||||
class="btn-sm btn-outline border-gray-200"
|
||||
label="View Logs"
|
||||
tooltip="Browse audit trail"
|
||||
/>
|
||||
|
||||
<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 -->
|
||||
<div class="border border-gray-200 rounded-xl bg-white overflow-hidden shadow-2xs">
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center bg-gray-50/50">
|
||||
<h3 class="font-bold text-sm text-gray-800 ">Client Credentials</h3>
|
||||
</div>
|
||||
<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"
|
||||
readonly
|
||||
@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>
|
||||
</div>
|
||||
|
||||
<!-- Client Secrets Section -->
|
||||
<div class="border border-gray-200 rounded-xl bg-white overflow-hidden shadow-2xs">
|
||||
<div class="px-6 py-4 border-b border-gray-200 flex justify-between items-center bg-gray-50/50">
|
||||
<h3 class="font-bold text-xs uppercase tracking-wider text-gray-500">Client Secrets</h3>
|
||||
@if($passportClient)
|
||||
<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"
|
||||
/>
|
||||
@endif
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</x-mary-tab>
|
||||
|
||||
<!-- Sign On Tab -->
|
||||
<x-mary-tab name="signon-tab" label="Sign On">
|
||||
<div class="space-y-6">
|
||||
<div class="border border-gray-200 rounded-xl bg-white overflow-hidden shadow-2xs">
|
||||
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50/50">
|
||||
<h3 class="font-bold text-sm text-gray-800 ">Sign In Redirect URIs</h3>
|
||||
</div>
|
||||
<div class="p-6 space-y-3">
|
||||
@if(!empty($passportClient?->redirect_uris))
|
||||
@foreach($passportClient->redirect_uris 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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sign Out Redirect URIs -->
|
||||
<div class="border border-gray-200 rounded-xl bg-white overflow-hidden shadow-2xs">
|
||||
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50/50">
|
||||
<h3 class="font-bold text-sm text-gray-800 ">Sign Out Redirect URIs</h3>
|
||||
</div>
|
||||
<div class="p-6 space-y-3">
|
||||
@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
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</x-mary-tab>
|
||||
|
||||
<!-- Assignments Tab -->
|
||||
<x-mary-tab name="assignments-tab" label="Assignments">
|
||||
<div class="">
|
||||
<div class="border border-gray-200 rounded-xl bg-white overflow-hidden shadow-2xs">
|
||||
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50/50">
|
||||
<h3 class="font-bold text-sm text-gray-800 ">Assigned Roles</h3>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</x-mary-tab>
|
||||
|
||||
<!-- API Scopes Tab -->
|
||||
<x-mary-tab name="scopes-tab" label="API Scopes">
|
||||
<div class="">
|
||||
<div class="border border-gray-200 rounded-xl bg-white overflow-hidden shadow-2xs">
|
||||
<div class="px-6 py-4 border-b border-gray-200 bg-gray-50/50">
|
||||
<h3 class="font-bold text-sm text-gray-800 ">Granted OAuth/OIDC
|
||||
Scopes</h3>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</x-mary-tab>
|
||||
|
||||
</x-mary-tabs>
|
||||
</x-shared.card>
|
||||
</div>
|
||||
@ -7,6 +7,7 @@
|
||||
use App\Enums\ApplicationTypeEnum;
|
||||
use App\Enums\ConnectionProtocolEnum;
|
||||
use App\Livewire\Forms\CreateAppSelectionForm;
|
||||
use App\Models\ConnectedApp;
|
||||
use App\Services\ConnectedAppService;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Title;
|
||||
@ -61,6 +62,18 @@ public function edit(int $appId): void
|
||||
$this->redirectRoute('apps.edit', ['id' => $appId], navigate: true);
|
||||
}
|
||||
|
||||
public function viewApp(int $appId): void
|
||||
{
|
||||
$app = ConnectedApp::with('protocol')->findOrFail($appId);
|
||||
$protocol = mb_strtolower($app->protocol->name ?? '');
|
||||
|
||||
if ($protocol === 'oidc') {
|
||||
$this->redirectRoute('apps.oidc.show', ['id' => $appId], navigate: true);
|
||||
} else {
|
||||
$this->redirectRoute('apps.edit', ['id' => $appId], navigate: true);
|
||||
}
|
||||
}
|
||||
|
||||
public function delete(int $appId): void
|
||||
{
|
||||
$this->attempt(
|
||||
@ -101,8 +114,8 @@ public function createApp(): void
|
||||
|
||||
@scope('actions', $row)
|
||||
<div class="flex">
|
||||
<x-mary-button icon="lucide.edit" wire:click="edit({{$row->id}})" spinner="edit({{$row->id}})"
|
||||
class="mr-2 btn-sm btn-circle text-gray-500"/>
|
||||
<x-mary-button icon="lucide.eye" wire:click="viewApp({{$row->id}})" spinner="viewApp({{$row->id}})"
|
||||
class="mr-2 btn-sm btn-circle text-blue-500" tooltip="View Details"/>
|
||||
<x-mary-button icon="lucide.trash" wire:click="requireConfirmation('delete', {{$row->id}})"
|
||||
spinner="requireConfirmation('delete', {{$row->id}})"
|
||||
variant="danger"
|
||||
|
||||
@ -30,6 +30,12 @@
|
||||
'{id}/edit',
|
||||
'pages::apps.edit'
|
||||
)->middleware(PermissionMiddleware::using(AppPermissionEnum::Update))->name('edit');
|
||||
|
||||
Route::livewire(
|
||||
'oidc/{id}/view',
|
||||
'pages::apps.oidc.show'
|
||||
)->middleware(PermissionMiddleware::using(AppPermissionEnum::Read))->name('oidc.show');
|
||||
|
||||
Route::livewire('/', 'pages::apps.index')->name('index');
|
||||
});
|
||||
|
||||
|
||||
89
tests/Feature/OidcAppViewTest.php
Normal file
89
tests/Feature/OidcAppViewTest.php
Normal file
@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\{ConnectedApp, ConnectionProtocol, ConnectionStatus, User};
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Passport\Client;
|
||||
use Livewire\Livewire;
|
||||
|
||||
beforeEach(function (): void {
|
||||
$this->seed(Database\Seeders\PermissionSeeder::class);
|
||||
$this->seed(Database\Seeders\DefaultRoleWithPermissionSeeder::class);
|
||||
$this->seed(Database\Seeders\ConnectionProtocolSeeder::class);
|
||||
$this->seed(Database\Seeders\ConnectionProviderSeeder::class);
|
||||
$this->seed(Database\Seeders\ConnectionStatusSeeder::class);
|
||||
|
||||
$this->oidcProtocol = ConnectionProtocol::query()->where('name', 'oidc')->firstOrFail();
|
||||
$this->status = ConnectionStatus::query()->where('name', 'connected')->firstOrFail();
|
||||
|
||||
// Create a mock OIDC application in database
|
||||
$this->connectedApp = ConnectedApp::query()->create([
|
||||
'name' => 'Test OIDC App',
|
||||
'slug' => 'test-oidc-app',
|
||||
'connection_protocol_id' => $this->oidcProtocol->id,
|
||||
'connection_provider_id' => 0,
|
||||
'connection_status_id' => $this->status->id,
|
||||
'settings' => [
|
||||
'access_url' => 'https://test-oidc-app.example.com',
|
||||
'signOutUris' => ['https://test-oidc-app.example.com/logout'],
|
||||
],
|
||||
]);
|
||||
|
||||
// Create corresponding Passport Client
|
||||
$this->passportClient = Client::query()->create([
|
||||
'id' => (string) Str::uuid(),
|
||||
'name' => 'Test OIDC App',
|
||||
'secret' => bcrypt('old-secret-value'),
|
||||
'provider' => 'users',
|
||||
'redirect_uris' => ['https://test-oidc-app.example.com/callback'],
|
||||
'grant_types' => ['authorization_code'],
|
||||
'revoked' => false,
|
||||
]);
|
||||
});
|
||||
|
||||
it('authorizes only admin users to access the OIDC view page', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole('user'); // standard user
|
||||
|
||||
$this->actingAs($user);
|
||||
|
||||
// Should fail with 403 Forbidden
|
||||
Livewire::actingAs($user)
|
||||
->test('pages::apps.oidc.show', ['id' => $this->connectedApp->id])
|
||||
->assertForbidden();
|
||||
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('admin'); // admin user
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
// Should load successfully
|
||||
Livewire::actingAs($admin)
|
||||
->test('pages::apps.oidc.show', ['id' => $this->connectedApp->id])
|
||||
->assertOk();
|
||||
});
|
||||
|
||||
it('displays client credentials and allows generating a new client secret', function (): void {
|
||||
$admin = User::factory()->create();
|
||||
$admin->assignRole('admin');
|
||||
|
||||
$this->actingAs($admin);
|
||||
|
||||
$component = Livewire::test('pages::apps.oidc.show', ['id' => $this->connectedApp->id])
|
||||
->assertSet('appId', $this->connectedApp->id)
|
||||
->assertSet('passportClient.id', $this->passportClient->id);
|
||||
|
||||
// Call generateNewSecret
|
||||
$component->call('generateNewSecret');
|
||||
|
||||
// Get plain secret set in component
|
||||
$newSecretPlain = $component->get('newSecretPlain');
|
||||
expect($newSecretPlain)->toBeString()
|
||||
->and(mb_strlen($newSecretPlain))->toBe(40);
|
||||
|
||||
// Verify it is updated in DB
|
||||
$this->passportClient->refresh();
|
||||
expect(Hash::check($newSecretPlain, $this->passportClient->secret))->toBeTrue();
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user