diff --git a/app/Data/Application/StoreOIDCAppData.php b/app/Data/Application/StoreOIDCAppData.php index 483b5bf..de65d31 100644 --- a/app/Data/Application/StoreOIDCAppData.php +++ b/app/Data/Application/StoreOIDCAppData.php @@ -14,6 +14,7 @@ public function __construct( public ApplicationTypeEnum $type, public string $name, public UserAccessTypeEnum $userAccessType, + public string $accessUrl, /** @var array */ public array $signInUris = [], /** @var array */ @@ -29,6 +30,7 @@ public static function fromArray(ApplicationTypeEnum $applicationType, array $va type: $applicationType, name: $value['name'], userAccessType: UserAccessTypeEnum::from($value['userAccessOption']), + accessUrl: $value['accessUrl'], signInUris: $value['signInUris'] ?? [], signOutUris: $value['signOutUris'] ?? [], logo: $value['logo'] ?? null, diff --git a/app/Data/Application/StoreURLAppData.php b/app/Data/Application/StoreURLAppData.php new file mode 100644 index 0000000..c1b03dc --- /dev/null +++ b/app/Data/Application/StoreURLAppData.php @@ -0,0 +1,34 @@ + */ + public array $roleIds = [], + ) {} + + public static function fromArray(array $value): StoreURLAppData + { + return new self( + name: $value['name'], + userAccessType: UserAccessTypeEnum::from($value['userAccessOption']), + accessUrl: $value['accessUrl'], + isConnectedToMicrosoft: (bool) ($value['isConnectedToMicrosoft'] ?? false), + logo: $value['logo'] ?? null, + roleIds: $value['roleIds'] ?? [], + ); + } +} diff --git a/app/Data/Ui/Dashboard/ServiceAppDto.php b/app/Data/Ui/Dashboard/ServiceAppDto.php index 1179c32..d08ebb7 100644 --- a/app/Data/Ui/Dashboard/ServiceAppDto.php +++ b/app/Data/Ui/Dashboard/ServiceAppDto.php @@ -13,9 +13,12 @@ public function __construct( public string $name, public string $slug, public string $duration, - public int $days_remaining, - public bool $is_warning, - public ?string $protocol_name = null, - public ?string $provider_slug = null, + public bool $isUnlimited, + public int $daysRemaining, + public bool $isWarning, + public ?string $protocolName = null, + public ?string $providerSlug = null, + public ?string $accessUrl = null, + public bool $isConnectedToMicrosoft = false, ) {} } diff --git a/app/Enums/ApplicationTypeEnum.php b/app/Enums/ApplicationTypeEnum.php index 7b4cde0..4179b41 100644 --- a/app/Enums/ApplicationTypeEnum.php +++ b/app/Enums/ApplicationTypeEnum.php @@ -10,7 +10,7 @@ enum ApplicationTypeEnum: string implements EnumMorphsToOptionsContract { case Web = 'web'; case SPA = 'spa'; - case Machine = 'machine'; + // case Machine = 'machine'; public static function asOptions(): array { @@ -26,7 +26,7 @@ public function toLabel(): string return match ($this) { self::Web => 'Web Applications', self::SPA => 'Single Page Applications', - self::Machine => 'Microservices', + // self::Machine => 'Microservices', }; } @@ -35,7 +35,7 @@ public function toHint(): string return match ($this) { self::Web => 'Server-side rendered web application, where authentication is handled by the server.', self::SPA => 'Single Page Application where client side framework e.g. React, Vue, Angular is used, and gets the authentication token from the server.', - self::Machine => 'Application that runs on a machine, e.g. a serverless function, or a container.', + // self::Machine => 'Application that runs on a machine, e.g. a serverless function, or a container.', }; } } diff --git a/app/Enums/ConnectionProtocolEnum.php b/app/Enums/ConnectionProtocolEnum.php index a600457..9d88e04 100644 --- a/app/Enums/ConnectionProtocolEnum.php +++ b/app/Enums/ConnectionProtocolEnum.php @@ -12,7 +12,7 @@ enum ConnectionProtocolEnum: string implements EnumMorphsToOptionsContract use EnumValuesAsArray; case OIDC = 'oidc'; - case SAML = 'saml'; + // case SAML = 'saml'; case URL = 'url'; public static function asOptions(): array @@ -28,7 +28,7 @@ public function toLabel(): string { return match ($this) { self::OIDC => 'OIDC - OpenID Connect', - self::SAML => 'SAML 2.0', + // self::SAML => 'SAML 2.0', self::URL => 'Redirect URL', }; } @@ -37,8 +37,8 @@ public function toHint(): string { return match ($this) { self::OIDC => 'Modern authentication protocol built on OAuth 2.0 for secure user sign-in across web, mobile, and API-based applications.', - self::SAML => 'XML-based Single Sign-On (SSO) protocol commonly used by enterprise applications and identity providers', - self::URL => 'Simple URL that will redirect the user to the authentication page.', + // self::SAML => 'XML-based Single Sign-On (SSO) protocol commonly used by enterprise applications and identity providers', + self::URL => 'Simple URL that will redirect the user to the authentication page. E.g. Keka, Outlook, etc.', }; } } diff --git a/app/Http/Controllers/EntraController.php b/app/Http/Controllers/EntraController.php index 23d3354..8937d92 100644 --- a/app/Http/Controllers/EntraController.php +++ b/app/Http/Controllers/EntraController.php @@ -4,21 +4,27 @@ namespace App\Http\Controllers; -use App\Models\OauthToken; +use App\Models\{OauthToken, User}; use App\Services\UserAppAccessService; +use Illuminate\Container\Attributes\CurrentUser; use Illuminate\Http\{RedirectResponse, Request}; use Illuminate\Support\Facades\Http; use Illuminate\Support\Str; class EntraController extends Controller { - private const PROVIDER = 'microsoft'; + // ── OAuth config from .env ──────────────────────────────────────────────── - private const SCOPES = 'openid profile email offline_access User.Read Mail.Read Mail.ReadWrite Mail.Send'; + private const string PROVIDER = 'microsoft'; + + private const string SCOPES = 'openid profile email offline_access User.Read Mail.Read Mail.ReadWrite Mail.Send'; private string $tenantId; + private string $clientId; + private string $clientSecret; + private string $redirectUri; public function __construct() @@ -31,7 +37,7 @@ public function __construct() // ── Step 1: Redirect user to Microsoft login ────────────────────────────── - public function connect(): RedirectResponse + public function connect(#[CurrentUser] User $user): RedirectResponse { $user = auth()->user(); @@ -39,27 +45,6 @@ public function connect(): RedirectResponse abort(403, 'Unauthorized'); } - if (! $this->hasAzureAccess($user)) { - abort(403, 'You do not have permission to access the required application.'); - } - - // Already connected and token still valid → open Outlook directly - $token = $user->oauthToken(self::PROVIDER); - - if ($token && $token->expires_at->gt(now()->addMinutes(5))) { - return redirect('https://outlook.office.com/mail/'); - } - - // Token exists but expired → try silent refresh first - if ($token && $token->refresh_token) { - if ($this->refreshToken($token)) { - return redirect('https://outlook.office.com/mail/'); - } - // Refresh failed → delete and fall through to full login - $token->delete(); - } - - // No token or refresh failed → full Microsoft login $state = Str::random(40); session(['oauth_state' => $state]); @@ -73,7 +58,7 @@ public function connect(): RedirectResponse 'prompt' => 'select_account', ]); - return redirect("https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/authorize?{$query}"); + return redirect("https://login.microsoftonline.com/$this->tenantId/oauth2/v2.0/authorize?$query"); } // ── Step 2: Handle callback, exchange code for tokens ──────────────────── @@ -86,7 +71,10 @@ public function callback(Request $request): RedirectResponse abort(403, 'Unauthorized'); } - if (! $this->hasAzureAccess($user)) { + $activeServices = app(UserAppAccessService::class)->getActiveServices($user); + $hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->providerSlug); + + if (! $hasAzureFdAccess) { abort(403, 'You do not have permission to access the required application.'); } @@ -111,7 +99,7 @@ public function callback(Request $request): RedirectResponse // Exchange authorization code for tokens $response = Http::asForm()->post( - "https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token", + "https://login.microsoftonline.com/$this->tenantId/oauth2/v2.0/token", [ 'client_id' => $this->clientId, 'client_secret' => $this->clientSecret, @@ -157,7 +145,10 @@ public function openEntra(): RedirectResponse abort(403, 'Unauthorized'); } - if (! $this->hasAzureAccess($user)) { + $activeServices = app(UserAppAccessService::class)->getActiveServices($user); + $hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->providerSlug); + + if (! $hasAzureFdAccess) { abort(403, 'You do not have permission to access the required application.'); } @@ -185,26 +176,6 @@ public function openEntra(): RedirectResponse // ── Disconnect ──────────────────────────────────────────────────────────── - public function disconnect(): RedirectResponse - { - $user = auth()->user(); - - if (! $user) { - abort(403, 'Unauthorized'); - } - - if (! $this->hasAzureAccess($user)) { - abort(403, 'You do not have permission to access the required application.'); - } - - $user->oauthToken(self::PROVIDER)?->delete(); - - return redirect()->route('dashboard.user') - ->with('entra_success', 'Microsoft disconnected.'); - } - - // ── Internal: silent refresh ────────────────────────────────────────────── - private function refreshToken(OauthToken $token): bool { if (! $token->refresh_token) { @@ -212,7 +183,7 @@ private function refreshToken(OauthToken $token): bool } $response = Http::asForm()->post( - "https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token", + "https://login.microsoftonline.com/$this->tenantId/oauth2/v2.0/token", [ 'client_id' => $this->clientId, 'client_secret' => $this->clientSecret, @@ -237,12 +208,24 @@ private function refreshToken(OauthToken $token): bool return true; } - // ── Internal: permission check (DRY) ───────────────────────────────────── + // ── Internal: Refresh access token using refresh_token ─────────────────── - private function hasAzureAccess(mixed $user): bool + public function disconnect(): RedirectResponse { - $activeServices = app(UserAppAccessService::class)->getActiveServices($user); + $user = auth()->user(); + if (! $user) { + abort(403, 'Unauthorized'); + } - return $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug); + $activeServices = app(UserAppAccessService::class)->getActiveServices($user); + $hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->providerSlug); + + if (! $hasAzureFdAccess) { + abort(403, 'You do not have permission to access the required application.'); + } + + $user->oauthToken(self::PROVIDER)?->delete(); + + return redirect()->route('dashboard.user')->with('entra_success', 'Microsoft disconnected.'); } -} \ No newline at end of file +} diff --git a/app/Livewire/Forms/StoreConnectedAppFrom.php b/app/Livewire/Forms/StoreConnectedAppFrom.php index 61f6b7c..1caa559 100644 --- a/app/Livewire/Forms/StoreConnectedAppFrom.php +++ b/app/Livewire/Forms/StoreConnectedAppFrom.php @@ -5,8 +5,7 @@ namespace App\Livewire\Forms; use App\Data\ConnectedApp\ConnectedAppData; -use App\Enums\ConnectionProtocolEnum; -use App\Models\ConnectionProtocol; +use App\Models\{ConnectionProtocol, ConnectionProvider}; use App\Services\ConnectedAppService; use Livewire\Form; @@ -56,18 +55,18 @@ public function rules(): array $protocol = ConnectionProtocol::query()->find($this->protocolId); if ($protocol) { $protocolName = mb_strtolower($protocol->name); - if (ConnectionProtocolEnum::SAML->value === $protocolName) { - $rules['samlEntityId'] = 'required|string|min:3'; - $rules['samlAcsUrl'] = 'required|url'; - $rules['samlNameIdFormat'] = 'required|string|in:persistent,emailAddress,transient,unspecified'; - $rules['samlNameIdSource'] = 'required|string|in:immutable_id,email,id'; - $rules['samlAttributes'] = 'array'; - $rules['samlAttributes.*.saml_name'] = 'required|string|min:1'; - $rules['samlAttributes.*.user_field'] = 'required|string|in:email,name,immutable_id,id'; - } + // if (ConnectionProtocolEnum::SAML->value === $protocolName) { + // $rules['samlEntityId'] = 'required|string|min:3'; + // $rules['samlAcsUrl'] = 'required|url'; + // $rules['samlNameIdFormat'] = 'required|string|in:persistent,emailAddress,transient,unspecified'; + // $rules['samlNameIdSource'] = 'required|string|in:immutable_id,email,id'; + // $rules['samlAttributes'] = 'array'; + // $rules['samlAttributes.*.saml_name'] = 'required|string|min:1'; + // $rules['samlAttributes.*.user_field'] = 'required|string|in:email,name,immutable_id,id'; + // } } - $provider = \App\Models\ConnectionProvider::query()->find($this->providerId); + $provider = ConnectionProvider::query()->find($this->providerId); if ($provider && 'keka-hr' === $provider->slug) { $rules['kekaUrl'] = 'required|url'; } diff --git a/app/Livewire/Forms/StoreOIDCAppForm.php b/app/Livewire/Forms/StoreOIDCAppForm.php index d31faf9..663d785 100644 --- a/app/Livewire/Forms/StoreOIDCAppForm.php +++ b/app/Livewire/Forms/StoreOIDCAppForm.php @@ -14,6 +14,9 @@ class StoreOIDCAppForm extends Form #[Validate('required|string|max:255|min:3')] public string $name = ''; + #[Validate('required|url|max:255')] + public string $accessUrl = ''; + #[Validate('nullable|image|max:1024')] public ?TemporaryUploadedFile $logo = null; @@ -46,6 +49,7 @@ class StoreOIDCAppForm extends Form public function validationAttributes(): array { return [ + 'accessUrl' => 'Access URL', 'signInUris.*' => 'Sign-in URI', 'signOutUris.*' => 'Sign-out URI', 'roleIds.*' => 'Role', diff --git a/app/Livewire/Forms/StoreURLAppForm.php b/app/Livewire/Forms/StoreURLAppForm.php new file mode 100644 index 0000000..c399d77 --- /dev/null +++ b/app/Livewire/Forms/StoreURLAppForm.php @@ -0,0 +1,45 @@ + '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 [ + 'accessUrl' => 'Access URL', + 'roleIds.*' => 'Role', + ]; + } +} diff --git a/app/Models/AppRole.php b/app/Models/AppRole.php index eb4afde..275a90a 100644 --- a/app/Models/AppRole.php +++ b/app/Models/AppRole.php @@ -13,6 +13,7 @@ * @property int $app_id * @property int $role_id * @property string $duration Validity of the role + * @property bool $is_unlimited Whether validity of the role is unlimited */ #[Fillable(['app_id', 'role_id', 'duration', 'is_unlimited'])] class AppRole extends Pivot diff --git a/app/Models/ConnectedApp.php b/app/Models/ConnectedApp.php index 6ebd9af..7ae426c 100644 --- a/app/Models/ConnectedApp.php +++ b/app/Models/ConnectedApp.php @@ -15,6 +15,7 @@ * @property int $id * @property string $name * @property AppRole $pivot + * @property array $settings */ #[Fillable([ 'name', @@ -23,23 +24,12 @@ 'connection_provider_id', 'connection_status_id', 'settings', + 'logo', ])] class ConnectedApp extends Model { use HasFactory, LogsActivity, SoftDeletes; - /** - * Get the attributes that should be cast. - * - * @return array - */ - protected function casts(): array - { - return [ - 'settings' => 'array', - ]; - } - /** * @return HasOne */ @@ -87,4 +77,16 @@ public function roles(): BelongsToMany ->using(AppRole::class) ->withPivot('duration'); } + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'settings' => 'array', + ]; + } } diff --git a/app/Models/Role.php b/app/Models/Role.php index b164ce6..aa26678 100644 --- a/app/Models/Role.php +++ b/app/Models/Role.php @@ -32,7 +32,7 @@ public function apps(): BelongsToMany 'app_id' ) ->using(AppRole::class) - ->withPivot('id', 'duration'); + ->withPivot('id', 'duration', 'is_unlimited'); } public function getActivitylogOptions(): LogOptions diff --git a/app/Services/Applications/OIDC/Factory/AuthorizationCodeClientFactory.php b/app/Services/Applications/OIDC/Factory/AuthorizationCodeClientFactory.php index 7b7e2cc..9259e75 100644 --- a/app/Services/Applications/OIDC/Factory/AuthorizationCodeClientFactory.php +++ b/app/Services/Applications/OIDC/Factory/AuthorizationCodeClientFactory.php @@ -23,7 +23,8 @@ public function create( ): ClientCredentialsData { $client = $this->client->createAuthorizationCodeGrantClient( name: $data->name, - redirectUris: $data->signInUris, + // laravel-oidc-server expects redirect_uris to consist of sign_in and sign_out uris. + redirectUris: array_merge($data->signInUris, $data->signOutUris), confidential: ApplicationTypeEnum::Web === $data->type, ); diff --git a/app/Services/Applications/OIDC/Factory/ClientCreationFactory.php b/app/Services/Applications/OIDC/Factory/ClientCreationFactory.php index b8d9ce9..022369a 100644 --- a/app/Services/Applications/OIDC/Factory/ClientCreationFactory.php +++ b/app/Services/Applications/OIDC/Factory/ClientCreationFactory.php @@ -9,7 +9,7 @@ class ClientCreationFactory { public function __construct( - private readonly ClientCredentialsClientFactory $clientCredentialsClient, + // private readonly ClientCredentialsClientFactory $clientCredentialsClient, private readonly AuthorizationCodeClientFactory $authorizationCodeClient, ) {} @@ -17,7 +17,7 @@ public function for(ApplicationTypeEnum $applicationType): ClientCreationContrac { return match ($applicationType) { ApplicationTypeEnum::Web, ApplicationTypeEnum::SPA => $this->authorizationCodeClient, - ApplicationTypeEnum::Machine => $this->clientCredentialsClient, + // ApplicationTypeEnum::Machine => $this->clientCredentialsClient, }; } } diff --git a/app/Services/Applications/OIDC/OidcConfigResolver.php b/app/Services/Applications/OIDC/OidcConfigResolver.php index d43ce81..26bb2c9 100644 --- a/app/Services/Applications/OIDC/OidcConfigResolver.php +++ b/app/Services/Applications/OIDC/OidcConfigResolver.php @@ -5,6 +5,7 @@ namespace App\Services\Applications\OIDC; use App\Enums\ApplicationTypeEnum; +use Laravel\Passport\Client; class OidcConfigResolver { @@ -21,4 +22,26 @@ public function hasClientSecret(): bool { return ApplicationTypeEnum::SPA !== $this->applicationType; } + + public function needSignInUris(): bool + { + return true; + // return ApplicationTypeEnum::Machine !== $this->applicationType; + } + + public function needSignOutUris(): bool + { + return true; + // return ApplicationTypeEnum::Machine !== $this->applicationType; + } + + public function isConfidential(Client $client): bool + { + return $client->confidential(); + } + + public function requiresPkce(Client $client): bool + { + return ! $client->confidential(); + } } diff --git a/app/Services/Applications/OIDC/OidcService.php b/app/Services/Applications/OIDC/OidcService.php index fd4cead..11a1ffa 100644 --- a/app/Services/Applications/OIDC/OidcService.php +++ b/app/Services/Applications/OIDC/OidcService.php @@ -7,12 +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; -use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\{DB, Log}; use Illuminate\Support\Str; +use Laravel\Passport\Client; use Throwable; readonly class OidcService @@ -42,6 +42,10 @@ public function createApp(StoreOIDCAppData $data): CreatedApplicationData connectionProviderId: 0, slug: Str::slug($data->name), connectionStatusId: ConnectionStatus::query()->where('name', ConnectionStatusEnum::Connected->value)->first()->id, + settings: [ + 'access_url' => $data->accessUrl, + 'sign_out_uris' => $data->signOutUris, + ], logo: $logo, ) ); @@ -72,4 +76,34 @@ 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 getSignOutUris(ConnectedApp $app): array + { + return data_get($app->settings, 'signOutUris', []); + } + + public function updateRedirectUris(ConnectedApp $app, Client $client, array $signInUris, array $signOutUris): void + { + $mergedUris = array_merge($signInUris, $signOutUris); + /** @phpstan-ignore-next-line */ + $client->redirect_uris = $mergedUris; + $client->save(); + $settings = $app->settings ?? []; + $settings['signOutUris'] = $signOutUris; + $app->settings = $settings; + $app->save(); + } } diff --git a/app/Services/Applications/URL/UrlAppService.php b/app/Services/Applications/URL/UrlAppService.php new file mode 100644 index 0000000..35c43a7 --- /dev/null +++ b/app/Services/Applications/URL/UrlAppService.php @@ -0,0 +1,66 @@ + $data->name, + ]); + try { + DB::beginTransaction(); + $logo = $this->logoUploader->store($data->logo); + + $appData = $this->connectedAppService->create( + new ConnectAppRequest( + name: $data->name, + connectionProtocolId: ConnectionProtocol::query()->where('name', ConnectionProtocolEnum::URL->value)->first()->id, + connectionProviderId: 0, + slug: Str::slug($data->name), + connectionStatusId: ConnectionStatus::query()->where('name', ConnectionStatusEnum::Connected->value)->first()->id, + settings: [ + 'access_url' => $data->accessUrl, + 'is_connected_to_microsoft' => $data->isConnectedToMicrosoft, + ], + logo: $logo, + ) + ); + + DB::commit(); + + Log::info('URL app created', [ + 'name' => $data->name, + 'id' => $appData->id, + ]); + + return $appData; + } catch (Throwable $e) { + DB::rollBack(); + Log::error('Failed to create URL app', [ + 'name' => $data->name, + ]); + throw $e; + } + } +} diff --git a/app/Services/Keka/KekaAccessGuard.php b/app/Services/Keka/KekaAccessGuard.php index ef33fd3..3567b53 100644 --- a/app/Services/Keka/KekaAccessGuard.php +++ b/app/Services/Keka/KekaAccessGuard.php @@ -25,7 +25,7 @@ public function authorize(): User $activeServices = $this->userAppAccessService->getActiveServices($user); $hasAccess = $activeServices->contains( - fn ($service) => self::PROVIDER_SLUG === $service->provider_slug + fn ($service) => self::PROVIDER_SLUG === $service->providerSlug ); if (! $hasAccess) { diff --git a/app/Services/UserAppAccessService.php b/app/Services/UserAppAccessService.php index 1d4f212..6956d99 100644 --- a/app/Services/UserAppAccessService.php +++ b/app/Services/UserAppAccessService.php @@ -51,7 +51,7 @@ public function getActiveServices(User $user): Collection continue; } - $this->addOrUpdateAppInMap($appsMap, $app, $durationStr, $daysRemaining); + $this->addOrUpdateAppInMap($appsMap, $app, $pivot->is_unlimited, $durationStr, $daysRemaining); } } @@ -117,7 +117,7 @@ private function getDaysRemainingIfActive(string $durationStr): ?int * * * @param Collection $appsMap */ - private function addOrUpdateAppInMap(Collection $appsMap, ConnectedApp $app, string $durationStr, int $daysRemaining): void + private function addOrUpdateAppInMap(Collection $appsMap, ConnectedApp $app, bool $isUnlimited, string $durationStr, int $daysRemaining): void { /** @var ServiceAppDto|null $existing */ $existing = $appsMap->get($app->id); @@ -132,10 +132,13 @@ private function addOrUpdateAppInMap(Collection $appsMap, ConnectedApp $app, str name: $app->name, slug: $app->slug, duration: $durationStr, - days_remaining: $daysRemaining, - is_warning: $daysRemaining <= 3, - protocol_name: $protocolName, - provider_slug: $providerSlug, + isUnlimited: $isUnlimited, + daysRemaining: $daysRemaining, + isWarning: $daysRemaining <= 3, + protocolName: $protocolName, + providerSlug: $providerSlug, + accessUrl: data_get($app, 'settings.access_url'), + isConnectedToMicrosoft: data_get($app, 'settings.is_connected_to_microsoft', false), )); } } diff --git a/resources/views/components/dashboard-sidebar.blade.php b/resources/views/components/dashboard-sidebar.blade.php index 4bbdd26..9eab13d 100644 --- a/resources/views/components/dashboard-sidebar.blade.php +++ b/resources/views/components/dashboard-sidebar.blade.php @@ -8,7 +8,8 @@ RoleAndAppsPermissionEnum, SecurityPermissionEnum, UserPermissionEnum, - PermissionPermissionEnum}; + PermissionPermissionEnum +}; use Livewire\Component; use Spatie\LaravelData\DataCollection; @@ -51,18 +52,18 @@ public function navItems(): DataCollection active_pattern: 'apps.index', permission: AppPermissionEnum::Access ), - new NavSubItemData( - label: 'Providers', - route: 'apps.connectionProviders.index', - active_pattern: 'apps.connectionProviders.*', - permission: ConnectionProviderPermissionEnum::Access - ), - new NavSubItemData( - label: 'Microsoft Graph', - route: 'apps.microsoft-federation', - active_pattern: 'apps.microsoft-federation', - permission: ConnectionProviderPermissionEnum::Access - ) +// new NavSubItemData( +// label: 'Providers', +// route: 'apps.connectionProviders.index', +// active_pattern: 'apps.connectionProviders.*', +// permission: ConnectionProviderPermissionEnum::Access +// ), +// new NavSubItemData( +// label: 'Microsoft Graph', +// route: 'apps.microsoft-federation', +// active_pattern: 'apps.microsoft-federation', +// permission: ConnectionProviderPermissionEnum::Access +// ) ], DataCollection::class) ), diff --git a/resources/views/pages/apps/oidc/⚡create.blade.php b/resources/views/pages/apps/oidc/⚡create.blade.php index 0d3fa7e..a3323c0 100644 --- a/resources/views/pages/apps/oidc/⚡create.blade.php +++ b/resources/views/pages/apps/oidc/⚡create.blade.php @@ -1,9 +1,10 @@ authorize(AppPermissionEnum::Create->value); $this->form->validate(); $this->attempt( function () { $data = StoreOIDCAppData::fromArray($this->applicationType, $this->form->pull()); $appdata = $this->oidcService->createApp($data); - // Show the credentials - $this->created = true; - $this->clientId = $appdata->clientCredentials->clientId; - $this->clientSecret = $appdata->clientCredentials->clientSecret; - $this->app = $appdata->application; + $this->showCredentials($appdata); } ); } @@ -84,7 +82,7 @@ public function title(): string $title .= match ($this->applicationType) { ApplicationTypeEnum::Web => "Web Application ", ApplicationTypeEnum::SPA => "Single Page Application ", - ApplicationTypeEnum::Machine => "Microservice Application ", +// ApplicationTypeEnum::Machine => "Microservice Application ", }; $title .= "Integration"; return $title; @@ -105,6 +103,24 @@ public function clearRoles(): void $this->clearChoice('roles'); } + #[Computed] + public function hasClientSecret(): bool + { + return $this->configResolver->for($this->applicationType)->hasClientSecret(); + } + + #[Computed] + public function needSignInUris(): bool + { + return $this->configResolver->for($this->applicationType)->needSignInUris(); + } + + #[Computed] + public function needSignOutUris(): bool + { + return $this->configResolver->for($this->applicationType)->needSignOutUris(); + } + protected function choiceFields(): array { return [ @@ -116,10 +132,12 @@ protected function choiceFields(): array ]; } - #[Computed] - public function hasClientSecret(): bool + private function showCredentials(CreatedApplicationData $appData): void { - return $this->configResolver->for($this->applicationType)->hasClientSecret(); + $this->clientId = $appData->clientCredentials->clientId; + $this->clientSecret = $appData->clientCredentials->clientSecret; + $this->app = $appData->application; + $this->created = true; } }; ?> @@ -132,7 +150,7 @@ public function hasClientSecret(): bool >
-

{{$app->name}}

@@ -180,74 +198,81 @@ class="btn-primary" + + + -
- - -
-
-
-

Sign In Redirect URIs

-

- {{config('app.name')}} sends response and ID Token back to these URIs. -

-
-
- -
-
- - - -
-
-
-

Sign out Redirect URIs

-

- {{config('app.name')}} sends back the user to these URIs after logout. -

-
-
- -
-
- - - -
-
-
-

User Access

-

- Choose whether to allow all users be able to connect this app or selected ones. -

-
-
- -
- + @if($this->needSignInUris) + +
+
+
+

Sign In Redirect URIs

+

+ Allowed URIs which this app can ask to get the response back. +

+
+
+

You can add this later.

+
+ + @endif + @if($this->needSignOutUris) + +
+
+
+

Sign out Redirect URIs

+

+ Allowed URIs which this app can redirect the user back to after logout. +

+
+
+

You can add this later.

+ +
+
+ + @endif + + {{--
--}} + {{--
--}} + {{--
--}} + {{--

User Access

--}} + {{--

--}} + {{-- Choose whether to allow all users be able to connect this app or selected ones.--}} + {{--

--}} + {{--
--}} + {{--
--}} + {{-- --}} + {{--
--}} + {{-- --}} + {{--
--}} + {{--
--}} -
+ {{--
--}}
diff --git a/resources/views/pages/apps/oidc/⚡show.blade.php b/resources/views/pages/apps/oidc/⚡show.blade.php new file mode 100644 index 0000000..e53fb87 --- /dev/null +++ b/resources/views/pages/apps/oidc/⚡show.blade.php @@ -0,0 +1,645 @@ +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(); + } + ); + } +}; +?> + +
+ + +
+
+
+ +
+
+
+

{{ $app->name }}

+ +
+

Protocol: {{ strtoupper($app->protocol->name) }} +

+
+
+ +
+ + + + +
+
+
+ + + + + + + +
+ @if($newSecretPlain) + + +
+ + +
+ @endif + + @if(!$passportClient) + + @endif + + + +
+ +
+ Client ID +
+ + @if($passportClient) + + @endif +
+
+
+
+
+ Public identifier for the client that is required for all OAuth flows. +
+
+ + +
+ Client Authentication +
+ + +
+
+ + +
+ Proof Key for Code Exchange (PKCE) +
+ +
+
+
+
+ + + + @if($passportClient) + + + + @endif +
+ + + + + + + + + + @if($passportClient) + + + + + + @else + + + + @endif + +
Creation dateSecretStatus
+ {{ $passportClient->updated_at?->format('M j, Y H:i:s') ?? 'N/A' }} + + •••••••••••••••••••••••••••••••••••••••• + + + {{ !$passportClient->revoked ? 'Active' : 'Revoked' }} + +
No secrets + available +
+
+
+
+
+ + + +
+ + + @if($editRedirectsMode) +
+ + +
+ @else + + @endif +
+ +
+ @if($editRedirectsMode) + + @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) +
+ + {{ $uri }} +
+ @endforeach + @else + No Sign In Redirect URIs configured + @endif + @endif +
+
+ + + +
+ @if($editRedirectsMode) + + @else + @if(!empty($signOutUris)) + @foreach($signOutUris as $uri) +
+ + {{ $uri }} +
+ @endforeach + @else + No Sign Out Redirect URIs configured + @endif + @endif +
+
+
+
+ + + +
+ +
+ + + + + + + + + + @forelse($app->roles as $role) + + + + + + @empty + + + + @endforelse + +
Role NamePriorityAccess Type
+ {{ $role->name }} + + {{ $role->priority }} + + + SSO Access + +
+ No specific roles assigned. Accessible by everyone depending on user access + setting. +
+
+
+
+
+ + + +
+ +
+
+
+ + + +
+
openid
+

Allows the application to fetch ID + tokens for authenticating users.

+
+
+
+ + + +
+
profile
+

Allows reading default profile + fields like name, picture, and locale.

+
+
+
+ + + +
+
email
+

Grants access to the user's primary + email address.

+
+
+
+ + + +
+
offline_access
+

Allows requesting refresh tokens to + maintain access offline.

+
+
+
+
+
+
+
+ +
+
+ + + + + + + + + + + + + +
diff --git a/resources/views/pages/apps/url/⚡create.blade.php b/resources/views/pages/apps/url/⚡create.blade.php new file mode 100644 index 0000000..28dfcca --- /dev/null +++ b/resources/views/pages/apps/url/⚡create.blade.php @@ -0,0 +1,173 @@ +roleService = $roleService; + $this->urlAppService = $urlAppService; + } + + public function render() + { + /** @phpstan-ignore-next-line */ + return $this->view()->title($this->title()); + } + + public function save(bool $goBack = true): void + { + $this->authorize(AppPermissionEnum::Create->value); + $this->form->validate(); + $this->attempt( + function () use ($goBack) { + $data = StoreURLAppData::fromArray($this->form->pull()); + $this->urlAppService->createApp($data); + + if ($goBack) { + $this->goBack(); + } else { + $this->form->reset(); + } + } + ); + } + + + public function goBack(): void + { + $this->redirectRoute("apps.index", navigate: true); + } + + public function title(): string + { + return "New Redirected App Integration"; + } + + 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', + ), + ]; + } +}; +?> + +
+ + + Go Back + + + + +
+
+

General Settings

+
+
+
+
+ +
+
+ +
+
+ + + + +
+
+ + + {{--
--}} + {{--
--}} + {{--
--}} + {{--

User Access

--}} + {{--

--}} + {{-- Choose whether to allow all users be able to connect this app or selected ones.--}} + {{--

--}} + {{--
--}} + {{--
--}} + {{-- --}} + {{--
--}} + {{-- --}} + {{--
--}} + {{--
--}} + + {{--
--}} + + +
+ Cancel + Save & Add Another + + Save + +
+
+
+
diff --git a/resources/views/pages/apps/url/⚡show.blade.php b/resources/views/pages/apps/url/⚡show.blade.php new file mode 100644 index 0000000..ae87d95 --- /dev/null +++ b/resources/views/pages/apps/url/⚡show.blade.php @@ -0,0 +1,347 @@ +appService = $service; + $this->logoUploader = $logoUploader; + } + + 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); + } + + 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->appService->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->appService->update($this->app->id, $request); + + $this->editGeneralModal = false; + $this->success('Application updated successfully!'); + $this->loadAppData(); + } + ); + } + + public function startEditAccessUrl(): void + { + $this->editAccessUrl = data_get($this->app, 'settings.access_url', ''); + $this->editAccessUrlMode = true; + } + + public function cancelEditAccessUrl(): void + { + $this->editAccessUrlMode = false; + } + + public function saveAccessUrl(): void + { + $this->authorize(AppPermissionEnum::Update->value); + + $this->validate([ + 'editAccessUrl' => 'required|url|max:255', + ]); + + $this->attempt( + action: function () { + $settings = $this->app->settings ?? []; + $settings['access_url'] = $this->editAccessUrl; + + $request = new ConnectAppRequest( + name: $this->app->name, + connectionProtocolId: $this->app->connection_protocol_id, + connectionProviderId: $this->app->connection_provider_id, + slug: $this->app->slug, + connectionStatusId: $this->app->connection_status_id, + settings: $settings, + logo: $this->app->logo, + ); + + $this->appService->update($this->app->id, $request); + + $this->editAccessUrlMode = false; + $this->success('Access URL updated successfully!'); + $this->loadAppData(); + } + ); + } +}; +?> + +
+ + +
+
+
+ +
+
+
+

{{ $app->name }}

+ +
+

Protocol: {{ strtoupper($app->protocol->name) }} +

+
+
+ +
+ + + + +
+
+
+ + + + + + + +
+ + + @if($editAccessUrlMode) +
+ + +
+ @else + + @endif +
+
+ @if($editAccessUrlMode) + + @else + + @endif +
+
+
+
+ + + +
+ +
+ + + + + + + + + + @forelse($app->roles as $role) + + + + + + @empty + + + + @endforelse + +
Role NamePriorityAccess Type
+ {{ $role->name }} + + {{ $role->priority }} + + + SSO Access + +
+ No specific roles assigned. Accessible by everyone depending on user access + setting. +
+
+
+
+
+
+
+ + + + + + + + + + + + + +
diff --git a/resources/views/pages/apps/⚡index.blade.php b/resources/views/pages/apps/⚡index.blade.php index 2ac96eb..6f5cf4a 100644 --- a/resources/views/pages/apps/⚡index.blade.php +++ b/resources/views/pages/apps/⚡index.blade.php @@ -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; @@ -42,7 +43,6 @@ private function initTableHeaders(): void { $this->headers = TableHeader::collect([ new TableHeader(key: 'name', label: 'Name',), - new TableHeader(key: 'provider', label: 'Provider'), new TableHeader(key: 'protocol', label: 'Protocol'), new TableHeader(key: 'status', label: 'Status'), ], DataCollection::class); @@ -62,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.url.show', ['id' => $appId], navigate: true); + } + } + public function delete(int $appId): void { $this->attempt( @@ -102,8 +114,8 @@ public function createApp(): void @scope('actions', $row)
- + error(session("entra_error")); } - - if (session("keka_success")) { - $this->success(session("keka_success")); - } - - if (session("keka_error")) { - $this->error(session("keka_error")); - } } #[Computed] @@ -75,20 +67,6 @@ public function isEntraConnected(): bool return $token !== null && !$token->isExpired(); } - - #[Computed] - public function kekaToken(): ?OauthToken - { - return auth()->user()?->oauthToken('keka'); - } - - #[Computed] - public function isKekaConnected(): bool - { - $token = $this->kekaToken(); - - return $token !== null && !$token->isExpired(); - } }; ?> @@ -141,10 +119,10 @@ class="w-12 h-12 text-gray-600 animate-pulse" - @if($service->is_warning) + @if($service->isWarning)
- @if($service->protocol_name === 'url' && $service->provider_slug === 'azure-fd') + @if($service->isConnectedToMicrosoft)
-
- Microsoft Integration -
-
@if($this->isEntraConnected) @endif - @if($service->protocol_name === 'url' && $service->provider_slug === 'keka-hr') -
-
- Keka Integration -
- -
- @if($this->isKekaConnected) - - -
- @csrf - @method('DELETE') - - - @else - - @endif -
-
+ @if($service->protocolName === 'oidc' || ( $service->protocolName === 'url' && !$service->isConnectedToMicrosoft)) + @endif
+ class="w-4 h-4 {{ $service->isWarning ? 'text-amber-500 animate-pulse' : 'text-gray-400 dark:text-gray-500' }}"/> - {{ Illuminate\Support\Carbon::parse($service->duration)->diffForHumans() }} + class="text-sm font-medium {{ $service->isWarning ? 'text-amber-600 dark:text-amber-400 font-semibold' : 'text-gray-600 dark:text-gray-400' }}"> + {{ $service->isUnlimited ? 'Unlimited' : Illuminate\Support\Carbon::parse($service->duration)->diffForHumans() }}
- @if($service->is_warning) + @if($service->isWarning) raise) { + $this->drawerOpen = true; + if ($this->appId) { + $this->form->connectedAppId = $this->appId; + } + } + // Pre-populate dynamic choice lists $this->searchApps(); $this->searchRoles(); @@ -100,14 +108,6 @@ public function mount(): void $this->form->notifiedRoleIds = [$defaultRole->id]; } - // Auto-open drawer if requested from URL - if ($this->raise) { - $this->drawerOpen = true; - if ($this->appId) { - $this->form->connectedAppId = $this->appId; - } - } - // Auto-open ticket if requested from URL if ($this->ticketParam && (int) $this->ticketParam > 0) { $this->showTicket((int) $this->ticketParam); @@ -140,17 +140,12 @@ public function rendering(): void public function tickets(): PaginatedDataCollection { $user = auth()->user(); - if (!$user) { - return TicketListData::collect(collect(), PaginatedDataCollection::class); - } - $search = trim($this->search); $search = empty($search) ? null : $search; - if ($user->hasRole('Admin') || $user->hasRole('admin') || $user->can(TicketPermissionEnum::Read->value)) { + if ($user->hasRole(DefaultUserRole::Admin) || $user->can(TicketPermissionEnum::Read->value)) { return $this->ticketService->getList($search, $this->statusFilter, 10); } - return $this->ticketService->getListForUser($user->id, $search, $this->statusFilter, 10); } @@ -166,7 +161,7 @@ public function eligibleApps(): Collection } return $this->appAccessService->getActiveServices($user) - ->filter(fn($app) => $app->is_warning); + ->filter(fn($app) => $app->isWarning); } /** @@ -193,14 +188,14 @@ public function searchApps(string $value = ''): void } } - $this->appsSearchable = $apps + $this->appsSearchable = array_values($apps ->when('' !== $value, fn($col) => $col->filter(fn($app) => str_contains(strtolower($app->name), strtolower($value)))) ->map(fn($app) => [ 'id' => $app->id, 'name' => $app->name.(isset($app->days_remaining) ? ' ('.$app->days_remaining.' days left)' : ''), ]) - ->toArray(); + ->toArray()); } /** @@ -210,7 +205,7 @@ public function searchRoles(string $value = ''): void { $selectedIds = $this->form->notifiedRoleIds; - $this->rolesSearchable = Role::query() + $this->rolesSearchable = array_values(Role::query() ->where(function ($query) use ($value, $selectedIds): void { if ('' !== $value) { $query->where('name', 'like', "%{$value}%"); @@ -221,7 +216,7 @@ public function searchRoles(string $value = ''): void }) ->select('id', 'name') ->get() - ->toArray(); + ->toArray()); } /** @@ -231,7 +226,7 @@ public function searchUsers(string $value = ''): void { $selectedIds = $this->form->notifiedUserIds; - $this->usersSearchable = User::query() + $this->usersSearchable = array_values(User::query() ->where('id', '!=', auth()->id()) ->where(function ($query) use ($value, $selectedIds): void { if ('' !== $value) { @@ -244,7 +239,7 @@ public function searchUsers(string $value = ''): void }) ->select('id', 'name') ->get() - ->toArray(); + ->toArray()); } /** @@ -522,7 +517,7 @@ public function toggleDrawer(): void ?>
- @if(!auth()->user()->hasRole('Admin') && !auth()->user()->hasRole('admin') && $this->eligibleApps->isEmpty()) + @if(!auth()->user()->hasRole(DefaultUserRole::Admin) && $this->eligibleApps->isEmpty()) You do not currently have any active services expiring within 3 days. You can still submit a general inquiry ticket without choosing a service. @@ -672,14 +667,14 @@ class="w-11/12 md:w-5/12 p-6 bg-white" - name(ConnectionProtocolEnum::OIDC->value.'.') ->group(function (): void { Route::livewire('create', 'pages::apps.oidc.create')->name('create'); + Route::livewire( + '{id}/view', + 'pages::apps.oidc.show' + )->middleware(PermissionMiddleware::using(AppPermissionEnum::Read))->name('show'); + + }); + Route::prefix(ConnectionProtocolEnum::URL->value) + ->name(ConnectionProtocolEnum::URL->value.'.') + ->group(function (): void { + Route::livewire('create', 'pages::apps.url.create')->name('create'); + Route::livewire('{id}/view', 'pages::apps.url.show')->name('show'); }); }); @@ -25,6 +36,7 @@ '{id}/edit', 'pages::apps.edit' )->middleware(PermissionMiddleware::using(AppPermissionEnum::Update))->name('edit'); + Route::livewire('/', 'pages::apps.index')->name('index'); }); diff --git a/tests/Feature/OidcAppCreationTest.php b/tests/Feature/OidcAppCreationTest.php new file mode 100644 index 0000000..9920576 --- /dev/null +++ b/tests/Feature/OidcAppCreationTest.php @@ -0,0 +1,56 @@ +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(); +}); + +it('successfully creates an OIDC connected app with access URL', function (): void { + $admin = User::factory()->create(); + $admin->assignRole('admin'); + + $this->actingAs($admin); + + Livewire::test('pages::apps.oidc.create', ['type' => 'web']) + ->set('form.name', 'My OIDC App') + ->set('form.accessUrl', 'https://oidc-app.example.com') + ->set('form.signInUris', ['https://oidc-app.example.com/callback']) + ->set('form.signOutUris', ['https://oidc-app.example.com/logout']) + ->call('save'); + + $this->assertDatabaseHas('connected_apps', [ + 'name' => 'My OIDC App', + 'connection_protocol_id' => $this->oidcProtocol->id, + ]); + + $app = ConnectedApp::query()->where('name', 'My OIDC App')->firstOrFail(); + expect($app->settings)->toBeArray() + ->and(data_get($app->settings, 'access_url'))->toBe('https://oidc-app.example.com'); +}); + +it('validates access URL format when creating an OIDC app', function (): void { + $admin = User::factory()->create(); + $admin->assignRole('admin'); + + $this->actingAs($admin); + + Livewire::test('pages::apps.oidc.create', ['type' => 'web']) + ->set('form.name', 'Invalid OIDC App') + ->set('form.accessUrl', 'not-a-valid-url') + ->call('save') + ->assertHasErrors(['form.accessUrl' => 'url']); +}); diff --git a/tests/Feature/OidcAppViewTest.php b/tests/Feature/OidcAppViewTest.php new file mode 100644 index 0000000..73b1f07 --- /dev/null +++ b/tests/Feature/OidcAppViewTest.php @@ -0,0 +1,162 @@ +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(); +}); + +it('allows updating app name and logo', function (): void { + $admin = User::factory()->create(); + $admin->assignRole('admin'); + + $this->actingAs($admin); + + $logo = Illuminate\Http\UploadedFile::fake()->image('logo.png'); + + Livewire::test('pages::apps.oidc.show', ['id' => $this->connectedApp->id]) + ->call('openEditGeneralModal') + ->assertSet('editName', 'Test OIDC App') + ->set('editName', 'Updated OIDC App Name') + ->set('editLogo', $logo) + ->call('saveGeneralInfo') + ->assertHasNoErrors(); + + // Verify it updated in Database + $this->connectedApp->refresh(); + expect($this->connectedApp->name)->toBe('Updated OIDC App Name') + ->and($this->connectedApp->logo)->not->toBeNull(); + + // Verify Passport client name was updated + $this->passportClient->refresh(); + expect($this->passportClient->name)->toBe('Updated OIDC App Name'); +}); + +it('allows updating connection status via changeStatus', function (): void { + $admin = User::factory()->create(); + $admin->assignRole('admin'); + + $this->actingAs($admin); + + // Initial status should be 'connected' + expect($this->connectedApp->status->name)->toBe('connected'); + + Livewire::test('pages::apps.oidc.show', ['id' => $this->connectedApp->id]) + ->call('changeStatus', 'disconnected') + ->assertHasNoErrors(); + + // Verify status updated in DB + $this->connectedApp->refresh(); + expect($this->connectedApp->status->name)->toBe('disconnected'); +}); + +it('allows updating redirect URIs', function (): void { + $admin = User::factory()->create(); + $admin->assignRole('admin'); + + $this->actingAs($admin); + + Livewire::test('pages::apps.oidc.show', ['id' => $this->connectedApp->id]) + ->call('startEditRedirects') + ->assertSet('editSignInUris', ['https://test-oidc-app.example.com/callback']) + ->assertSet('editSignOutUris', ['https://test-oidc-app.example.com/logout']) + // Add a new sign-in URI + ->call('addRepeaterItem', 'editSignInUris') + ->set('editSignInUris.1', 'https://test-oidc-app.example.com/callback2') + // Remove a sign-out URI + ->call('removeRepeaterItem', 'editSignOutUris', 0) + ->call('saveRedirectUris') + ->assertHasNoErrors(); + + // Verify changes in Passport Client and Connected App settings + $this->passportClient->refresh(); + expect($this->passportClient->redirect_uris)->toBe([ + 'https://test-oidc-app.example.com/callback', + 'https://test-oidc-app.example.com/callback2', + ]); + + $this->connectedApp->refresh(); + expect($this->connectedApp->settings['signOutUris'] ?? [])->toBe([]); +}); diff --git a/tests/Feature/UrlAppCreationTest.php b/tests/Feature/UrlAppCreationTest.php new file mode 100644 index 0000000..16fcca9 --- /dev/null +++ b/tests/Feature/UrlAppCreationTest.php @@ -0,0 +1,56 @@ +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->urlProtocol = ConnectionProtocol::query()->where('name', 'url')->firstOrFail(); + $this->status = ConnectionStatus::query()->where('name', 'connected')->firstOrFail(); +}); + +it('successfully creates a URL connected app with access URL and microsoft flag', function (): void { + $admin = User::factory()->create(); + $admin->assignRole('admin'); + + $this->actingAs($admin); + + Livewire::test('pages::apps.url.create') + ->set('form.name', 'My URL App') + ->set('form.accessUrl', 'https://url-app.example.com') + ->set('form.isConnectedToMicrosoft', true) + ->call('save'); + + $this->assertDatabaseHas('connected_apps', [ + 'name' => 'My URL App', + 'connection_protocol_id' => $this->urlProtocol->id, + ]); + + $app = ConnectedApp::query()->where('name', 'My URL App')->firstOrFail(); + expect($app->settings)->toBeArray() + ->and(data_get($app->settings, 'access_url'))->toBe('https://url-app.example.com') + ->and(data_get($app->settings, 'is_connected_to_microsoft'))->toBeTrue(); +}); + +it('validates access URL format when creating a URL app', function (): void { + $admin = User::factory()->create(); + $admin->assignRole('admin'); + + $this->actingAs($admin); + + Livewire::test('pages::apps.url.create') + ->set('form.name', 'Invalid URL App') + ->set('form.accessUrl', 'not-a-valid-url') + ->call('save') + ->assertHasErrors(['form.accessUrl' => 'url']); +}); diff --git a/tests/Feature/UrlAppViewTest.php b/tests/Feature/UrlAppViewTest.php new file mode 100644 index 0000000..f8fd68a --- /dev/null +++ b/tests/Feature/UrlAppViewTest.php @@ -0,0 +1,102 @@ +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->urlProtocol = ConnectionProtocol::query()->where('name', 'url')->firstOrFail(); + $this->status = ConnectionStatus::query()->where('name', 'connected')->firstOrFail(); + + $this->connectedApp = ConnectedApp::query()->create([ + 'name' => 'Test URL App', + 'slug' => 'test-url-app', + 'connection_protocol_id' => $this->urlProtocol->id, + 'connection_provider_id' => 0, + 'connection_status_id' => $this->status->id, + 'settings' => [ + 'access_url' => 'https://test-url-app.example.com', + 'is_connected_to_microsoft' => false, + ], + ]); +}); + +it('authorizes only admin users to view the URL show page', function (): void { + $user = User::factory()->create(); + $user->assignRole('user'); + + Livewire::actingAs($user) + ->test('pages::apps.url.show', ['id' => $this->connectedApp->id]) + ->assertForbidden(); + + $admin = User::factory()->create(); + $admin->assignRole('admin'); + + Livewire::actingAs($admin) + ->test('pages::apps.url.show', ['id' => $this->connectedApp->id]) + ->assertOk(); +}); + +it('allows updating name and logo', function (): void { + $admin = User::factory()->create(); + $admin->assignRole('admin'); + + $this->actingAs($admin); + + $logo = Illuminate\Http\UploadedFile::fake()->image('logo.png'); + + Livewire::test('pages::apps.url.show', ['id' => $this->connectedApp->id]) + ->call('openEditGeneralModal') + ->assertSet('editName', 'Test URL App') + ->set('editName', 'Updated URL App Name') + ->set('editLogo', $logo) + ->call('saveGeneralInfo') + ->assertHasNoErrors(); + + $this->connectedApp->refresh(); + expect($this->connectedApp->name)->toBe('Updated URL App Name') + ->and($this->connectedApp->logo)->not->toBeNull(); +}); + +it('allows updating access URL in place', function (): void { + $admin = User::factory()->create(); + $admin->assignRole('admin'); + + $this->actingAs($admin); + + Livewire::test('pages::apps.url.show', ['id' => $this->connectedApp->id]) + ->call('startEditAccessUrl') + ->assertSet('editAccessUrl', 'https://test-url-app.example.com') + ->set('editAccessUrl', 'https://updated-url-app.example.com') + ->call('saveAccessUrl') + ->assertHasNoErrors(); + + $this->connectedApp->refresh(); + expect(data_get($this->connectedApp->settings, 'access_url'))->toBe('https://updated-url-app.example.com'); +}); + +it('allows updating connection status via changeStatus', function (): void { + $admin = User::factory()->create(); + $admin->assignRole('admin'); + + $this->actingAs($admin); + + expect($this->connectedApp->status->name)->toBe('connected'); + + Livewire::test('pages::apps.url.show', ['id' => $this->connectedApp->id]) + ->call('changeStatus', 'disconnected') + ->assertHasNoErrors(); + + $this->connectedApp->refresh(); + expect($this->connectedApp->status->name)->toBe('disconnected'); +}); diff --git a/tests/Feature/UrlProtocolTest.php b/tests/Feature/UrlProtocolTest.php index 4f348d3..7fff0ef 100644 --- a/tests/Feature/UrlProtocolTest.php +++ b/tests/Feature/UrlProtocolTest.php @@ -80,8 +80,8 @@ expect($activeServices->count())->toBe(1); $dto = $activeServices->first(); - expect($dto->protocol_name)->toBe('url') - ->and($dto->provider_slug)->toBe('azure-fd'); + expect($dto->protocolName)->toBe('url') + ->and($dto->providerSlug)->toBe('azure-fd'); }); it('shows newly created connected apps with no roles in searchAppsForSelect dropdown', function (): void { diff --git a/tests/Unit/SamlIdpControllerTest.php b/tests/Unit/SamlIdpControllerTest.php index 2b5a851..62fb3bf 100644 --- a/tests/Unit/SamlIdpControllerTest.php +++ b/tests/Unit/SamlIdpControllerTest.php @@ -178,8 +178,8 @@ name: 'Active SAML App', slug: 'active-saml-app', duration: '2026-12-31', - days_remaining: 300, - is_warning: false + daysRemaining: 300, + isWarning: false ), ]));