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 07692f8..d08ebb7 100644 --- a/app/Data/Ui/Dashboard/ServiceAppDto.php +++ b/app/Data/Ui/Dashboard/ServiceAppDto.php @@ -13,10 +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/Http/Controllers/EntraController.php b/app/Http/Controllers/EntraController.php index bc0a727..abd3cbb 100644 --- a/app/Http/Controllers/EntraController.php +++ b/app/Http/Controllers/EntraController.php @@ -4,7 +4,9 @@ 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; @@ -13,6 +15,10 @@ class EntraController extends Controller { // ── OAuth config from .env ──────────────────────────────────────────────── + 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; @@ -21,10 +27,6 @@ class EntraController extends Controller private string $redirectUri; - private const PROVIDER = 'microsoft'; - - private const SCOPES = 'openid profile email offline_access User.Read Mail.Read Mail.ReadWrite Mail.Send'; - public function __construct() { $this->tenantId = config('services.microsoft.tenant_id'); @@ -35,20 +37,13 @@ public function __construct() // ── Step 1: Redirect user to Microsoft login ────────────────────────────── - public function connect(): RedirectResponse + public function connect(#[CurrentUser] User $user): RedirectResponse { $user = auth()->user(); if (! $user) { abort(403, 'Unauthorized'); } - $activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user); - $hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug); - - if (! $hasAzureFdAccess) { - abort(403, 'You do not have permission to access the required application.'); - } - $state = Str::random(40); session(['oauth_state' => $state]); @@ -62,7 +57,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 ──────────────────── @@ -74,8 +69,8 @@ public function callback(Request $request): RedirectResponse abort(403, 'Unauthorized'); } - $activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user); - $hasAzureFdAccess = $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.'); @@ -92,7 +87,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, @@ -135,8 +130,8 @@ public function openEntra(): RedirectResponse abort(403, 'Unauthorized'); } - $activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user); - $hasAzureFdAccess = $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.'); @@ -166,27 +161,6 @@ public function openEntra(): RedirectResponse // ── Disconnect ──────────────────────────────────────────────────────────── - public function disconnect(): RedirectResponse - { - $user = auth()->user(); - if (! $user) { - abort(403, 'Unauthorized'); - } - - $activeServices = app(\App\Services\UserAppAccessService::class)->getActiveServices($user); - $hasAzureFdAccess = $activeServices->contains(fn ($service) => 'azure-fd' === $service->provider_slug); - - 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.'); - } - - // ── Internal: Refresh access token using refresh_token ─────────────────── - private function refreshToken(OauthToken $token): bool { if (! $token->refresh_token) { @@ -194,7 +168,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, @@ -218,4 +192,25 @@ private function refreshToken(OauthToken $token): bool return true; } + + // ── Internal: Refresh access token using refresh_token ─────────────────── + + public function disconnect(): RedirectResponse + { + $user = auth()->user(); + if (! $user) { + abort(403, 'Unauthorized'); + } + + $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.'); + } } 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/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/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 ef3fb8f..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,11 +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/pages/apps/oidc/⚡create.blade.php b/resources/views/pages/apps/oidc/⚡create.blade.php index e54c535..a3323c0 100644 --- a/resources/views/pages/apps/oidc/⚡create.blade.php +++ b/resources/views/pages/apps/oidc/⚡create.blade.php @@ -243,36 +243,36 @@ class="btn-primary" @endif -
-
-
-

User Access

-

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

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

User Access

--}} + {{--

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

--}} + {{--
--}} + {{--
--}} + {{-- --}} + {{--
--}} + {{-- --}} + {{--
--}} + {{--
--}} -
+ {{--
--}}
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/dashboards/⚡user.blade.php b/resources/views/pages/dashboards/⚡user.blade.php index 2fcb5ef..e2b0c93 100644 --- a/resources/views/pages/dashboards/⚡user.blade.php +++ b/resources/views/pages/dashboards/⚡user.blade.php @@ -40,14 +40,6 @@ public function mount(): void if (session("entra_error")) { $this->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 -
-
- @endif - - @if($service->protocol_name === 'oidc') + @if($service->protocolName === 'oidc' || ( $service->protocolName === 'url' && !$service->isConnectedToMicrosoft))
+ 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) appAccessService->getActiveServices($user) - ->filter(fn($app) => $app->is_warning); + ->filter(fn($app) => $app->isWarning); } /** diff --git a/routes/apps.php b/routes/apps.php index 91ca008..ca24138 100644 --- a/routes/apps.php +++ b/routes/apps.php @@ -19,6 +19,11 @@ ->group(function (): void { Route::livewire('create', 'pages::apps.oidc.create')->name('create'); }); + Route::prefix(ConnectionProtocolEnum::URL->value) + ->name(ConnectionProtocolEnum::URL->value.'.') + ->group(function (): void { + Route::livewire('create', 'pages::apps.url.create')->name('create'); + }); }); Route::livewire( 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/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 ), ]));