From 66ae0bad37dbeeb762232df23b0e258e7b2bd766 Mon Sep 17 00:00:00 2001 From: = Date: Thu, 11 Jun 2026 10:17:59 +0000 Subject: [PATCH 1/3] Refactor: UI Changes in App services - make the button bigger and active color - remove unnecessary info from UI - Removed redundant validation calls and improved permission handling for `ConnectApps`. --- .../connected-apps/⚡connected-apps.blade.php | 2 +- .../roles-and-apps/⚡show.blade.php | 22 +++++----- .../views/pages/dashboards/⚡user.blade.php | 43 +++++++------------ 3 files changed, 29 insertions(+), 38 deletions(-) diff --git a/resources/views/components/dashboard/connected-apps/⚡connected-apps.blade.php b/resources/views/components/dashboard/connected-apps/⚡connected-apps.blade.php index f2b2ac7..fb67f98 100644 --- a/resources/views/components/dashboard/connected-apps/⚡connected-apps.blade.php +++ b/resources/views/components/dashboard/connected-apps/⚡connected-apps.blade.php @@ -3,7 +3,7 @@ use Livewire\Component; new class extends Component { - public string $selectedTab = 'all-tab'; + public string $selectedTab = "all-tab"; }; ?> diff --git a/resources/views/pages/access-manager/roles-and-apps/⚡show.blade.php b/resources/views/pages/access-manager/roles-and-apps/⚡show.blade.php index 292cb00..e9f3eab 100644 --- a/resources/views/pages/access-manager/roles-and-apps/⚡show.blade.php +++ b/resources/views/pages/access-manager/roles-and-apps/⚡show.blade.php @@ -141,8 +141,6 @@ public function openEditAppModal(int $assignmentId): void public function saveApp(): void { - $this->assignForm->validate(); - if ($this->isEditingApp) { $this->updateApp(); } else { @@ -191,7 +189,7 @@ public function assignApp(): void public function updateApp(): void { - $this->authorize(RoleAndAppsPermissionEnum::ConnectApps->value); + $this->authorize(RoleAndAppsPermissionEnum::ConnectApps); $this->attempt( action: function (): void { @@ -285,9 +283,9 @@ public function savePriority(): void $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; $this->validate([ - 'newPriority' => 'required|integer|min:' . ($userPriority + 1), + 'newPriority' => 'required|integer|min:'.($userPriority + 1), ], [ - 'newPriority.min' => 'The priority must be greater than your own highest role priority (' . $userPriority . ').', + 'newPriority.min' => 'The priority must be greater than your own highest role priority ('.$userPriority.').', ]); $this->attempt( @@ -335,8 +333,9 @@ class="btn-sm btn-ghost btn-circle" - @can(RoleAndAppsPermissionEnum::ConnectApps->value) - + @can(RoleAndAppsPermissionEnum::ConnectApps) + Add App @endcan @@ -445,10 +444,13 @@ class="bg-gray-50 text-gray-500 cursor-not-allowed" /> @endif
+ class="flex gap-2 items-baseline-last" + > -
+
causedBy($user) - ->event('dashboard_access') - ->log('User accessed their assigned services dashboard.'); + ->event("dashboard_access") + ->log("User accessed their assigned services dashboard."); } // Show toast from OAuth redirect flash data - if (session('entra_success')) { - $this->success(session('entra_success')); + if (session("entra_success")) { + $this->success(session("entra_success")); } - if (session('entra_error')) { - $this->error(session('entra_error')); + if (session("entra_error")) { + $this->error(session("entra_error")); } } @@ -138,15 +138,11 @@ class="w-3.5 h-3.5 inline animate-pulse text-amber-600 dark:text-amber-400"/>
-

- {{ $service->slug }} -

- @if($service->protocol_name === 'url' && $service->provider_slug === 'azure-fd')
+ class="flex flex-col gap-3">
- Microsoft Integration + Microsoft Integration @if($this->isEntraConnected) @@ -160,20 +156,13 @@ class="badge-soft badge-neutral text-[10px] font-semibold px-2 py-0.5"> @endif
- @if($this->isEntraConnected && $this->entraToken) -
- - Expires {{ $this->entraToken->expires_at->diffForHumans() }} -
- @endif -
@if($this->isEntraConnected) @@ -184,7 +173,7 @@ class="inline"> @@ -193,7 +182,7 @@ class="btn-xs btn-soft btn-error" no-wire-navigate icon="lucide.plug" :link="route('entra.connect')" - class="btn-xs btn-soft btn-primary flex-1" + class="btn-primary flex-1" label="Connect" /> @endif @@ -208,7 +197,7 @@ class="btn-xs btn-soft btn-primary flex-1" class="w-4 h-4 {{ $service->is_warning ? 'text-amber-500 animate-pulse' : 'text-gray-400 dark:text-gray-500' }}"/> - {{ $service->days_remaining }} {{ Str::plural('day', $service->days_remaining) }} left + {{ Illuminate\Support\Carbon::parse($service->duration)->diffForHumans() }}
From b90dd5c092f3dddd2e3b54b980587526a1767569 Mon Sep 17 00:00:00 2001 From: = Date: Thu, 11 Jun 2026 13:40:52 +0000 Subject: [PATCH 2/3] Feat: Add Keka HR integration for OAuth and user token management - Implemented `KekaController` to manage OAuth flows for Keka HR, including connection, callback, and disconnection. - Added required routes, middleware updates, and session handling for Keka integration. - Enhanced dashboard UI to display Keka connection status and provide seamless connect/disconnect options. - Included validation and configuration for Keka Portal URL in connected apps. - Developed feature tests to ensure reliable authentication and token handling workflows for Keka. --- app/Http/Controllers/KekaController.php | 243 ++++++++++++++++ app/Livewire/Forms/StoreConnectedAppFrom.php | 8 + bootstrap/app.php | 6 +- .../pages/connected-apps/⚡create.blade.php | 29 +- .../pages/connected-apps/⚡edit.blade.php | 29 +- .../views/pages/dashboards/⚡user.blade.php | 92 ++++++ routes/web.php | 8 +- scratch_keka.php | 14 + tests/Feature/KekaOAuthTest.php | 264 ++++++++++++++++++ 9 files changed, 689 insertions(+), 4 deletions(-) create mode 100644 app/Http/Controllers/KekaController.php create mode 100644 scratch_keka.php create mode 100644 tests/Feature/KekaOAuthTest.php diff --git a/app/Http/Controllers/KekaController.php b/app/Http/Controllers/KekaController.php new file mode 100644 index 0000000..99451b9 --- /dev/null +++ b/app/Http/Controllers/KekaController.php @@ -0,0 +1,243 @@ +user(); + if (! $user) { + abort(403, 'Unauthorized'); + } + + $activeServices = app(UserAppAccessService::class)->getActiveServices($user); + $hasKekaAccess = $activeServices->contains(fn ($service) => 'keka-hr' === $service->provider_slug); + + if (! $hasKekaAccess) { + abort(403, 'You do not have permission to access the required application.'); + } + + $app = ConnectedApp::whereHas('provider', function ($query): void { + $query->where('slug', 'keka-hr'); + })->first(); + + if (! $app) { + abort(404, 'Keka HR app registration not found.'); + } + + $kekaUrl = data_get($app->settings, 'keka.keka_url') ?? data_get($app->settings, 'keka_url') ?? 'https://sentientgeeks.keka.com'; + $provider = data_get($app->settings, 'keka.provider') ?? data_get($app->settings, 'provider') ?? 'Office365'; + + $redirectUrl = $this->getKekaExternalLoginUrl((string) $kekaUrl, (string) $provider); + + Log::info('Redirecting user to Keka external login page.', [ + 'user_id' => $user->id, + 'keka_url' => $kekaUrl, + 'redirect_url' => $redirectUrl, + ]); + + return redirect($redirectUrl); + } + + /** + * Parse the configured Keka URL to build the correct external login URL. + */ + private function getKekaExternalLoginUrl(string $configuredUrl, string $provider): string + { + $parsed = parse_url($configuredUrl); + $host = $parsed['host'] ?? 'login.keka.com'; + + $query = []; + if (isset($parsed['query'])) { + parse_str($parsed['query'], $query); + } + + $companyId = $query['companyId'] ?? $query['companyid'] ?? null; + + if (! $companyId && 'login.keka.com' !== $host) { + $parts = explode('.', $host); + if (count($parts) >= 2) { + $companyId = $parts[0]; + } + } + + $buildQuery = [ + 'provider' => $provider, + ]; + + // if ($companyId) { + // $buildQuery['companyId'] = $companyId; + // } + + $queryString = http_build_query($buildQuery); + + return "https://login.keka.com/Account/ExternalLogin?{$queryString}"; + } + + /** + * Step 2: Handle the Microsoft callback redirection. + * We record/store the tokens in the database, and then pass the code/state to Keka. + */ + public function callback(Request $request) + { + $user = auth()->user(); + if (! $user) { + abort(403, 'Unauthorized'); + } + + $activeServices = app(UserAppAccessService::class)->getActiveServices($user); + $hasKekaAccess = $activeServices->contains(fn ($service) => 'keka-hr' === $service->provider_slug); + + if (! $hasKekaAccess) { + abort(403, 'You do not have permission to access the required application.'); + } + + $code = $request->input('code'); + $state = $request->input('state'); + + if (empty($code)) { + return redirect()->route('dashboard.user')->with('keka_error', 'Keka connection failed: Missing code.'); + } + + // Retrieve Keka App configurations + $app = ConnectedApp::whereHas('provider', function ($query): void { + $query->where('slug', 'keka-hr'); + })->first(); + + if (! $app) { + abort(404, 'Keka HR app registration not found.'); + } + + $kekaUrl = data_get($app->settings, 'keka.keka_url') ?? data_get($app->settings, 'keka_url') ?? 'https://sentientgeeks.keka.com'; + $callbackPath = data_get($app->settings, 'keka.callback_path') ?? 'signin-oidc'; + $callbackUrl = $this->getKekaCallbackUrl((string) $kekaUrl, (string) $callbackPath); + + // Store token details (Option B: copy user's existing Microsoft token or save code/placeholder) + $microsoftToken = $user->oauthToken('microsoft'); + + if ($microsoftToken) { + $accessToken = $microsoftToken->access_token; + $refreshToken = $microsoftToken->refresh_token; + $tokenType = $microsoftToken->token_type; + $scopes = $microsoftToken->scopes; + $expiresAt = $microsoftToken->expires_at; + } else { + $accessToken = (string) $code; + $refreshToken = 'keka-placeholder-refresh'; + $tokenType = 'Bearer'; + $scopes = 'openid profile email'; + $expiresAt = now()->addHour(); + } + + OauthToken::updateOrCreate( + [ + 'user_id' => $user->id, + 'provider' => 'keka', + ], + [ + 'access_token' => $accessToken, + 'refresh_token' => $refreshToken, + 'token_type' => $tokenType, + 'scopes' => $scopes, + 'expires_at' => $expiresAt, + ] + ); + + Log::info('Keka tokens stored successfully. Passing OIDC response to Keka.', [ + 'user_id' => $user->id, + 'callback_url' => $callbackUrl, + 'method' => $request->method(), + ]); + + if ($request->isMethod('post')) { + $inputs = ''; + foreach ($request->all() as $key => $value) { + $safeKey = htmlspecialchars((string) $key, ENT_QUOTES, 'UTF-8'); + $safeValue = htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); + $inputs .= "\n"; + } + + $html = << + + + Forwarding to Keka... + + +
+ {$inputs} + +
+ + +HTML; + + return response($html); + } + + $queryParams = $request->query(); + $queryString = http_build_query($queryParams); + + return redirect("{$callbackUrl}?{$queryString}"); + } + + /** + * Parse the configured Keka URL to construct the callback URL. + */ + private function getKekaCallbackUrl(string $configuredUrl, string $callbackPath): string + { + $parsed = parse_url($configuredUrl); + $host = $parsed['host'] ?? 'login.keka.com'; + + if ('login.keka.com' !== $host) { + $parts = explode('.', $host); + if (count($parts) >= 2 && 'keka' === $parts[1]) { + $host = 'login.keka.com'; + } + } + + $scheme = $parsed['scheme'] ?? 'https'; + + return "{$scheme}://{$host}/".mb_ltrim($callbackPath, '/'); + } + + /** + * Disconnect/Revoke the Keka integration for the user. + */ + public function disconnect(): RedirectResponse + { + $user = auth()->user(); + if (! $user) { + abort(403, 'Unauthorized'); + } + + $activeServices = app(UserAppAccessService::class)->getActiveServices($user); + $hasKekaAccess = $activeServices->contains(fn ($service) => 'keka-hr' === $service->provider_slug); + + if (! $hasKekaAccess) { + abort(403, 'You do not have permission to access the required application.'); + } + + $user->oauthToken('keka')?->delete(); + + Log::info('Keka integration disconnected for user.', [ + 'user_id' => $user->id, + ]); + + return redirect()->route('dashboard.user')->with('keka_success', 'Keka HR disconnected.'); + } +} diff --git a/app/Livewire/Forms/StoreConnectedAppFrom.php b/app/Livewire/Forms/StoreConnectedAppFrom.php index 8e956ca..61f6b7c 100644 --- a/app/Livewire/Forms/StoreConnectedAppFrom.php +++ b/app/Livewire/Forms/StoreConnectedAppFrom.php @@ -30,6 +30,8 @@ class StoreConnectedAppFrom extends Form public array $samlAttributes = []; + public string $kekaUrl = ''; + protected ConnectedAppService $service; public function boot(ConnectedAppService $service): void @@ -65,6 +67,11 @@ public function rules(): array } } + $provider = \App\Models\ConnectionProvider::query()->find($this->providerId); + if ($provider && 'keka-hr' === $provider->slug) { + $rules['kekaUrl'] = 'required|url'; + } + return $rules; } @@ -82,6 +89,7 @@ public function set(ConnectedAppData $data): void $this->samlAcsUrl = $data->settings['saml']['acs_url'] ?? ''; $this->samlNameIdFormat = $data->settings['saml']['nameid_format'] ?? 'persistent'; $this->samlNameIdSource = $data->settings['saml']['nameid_source'] ?? 'immutable_id'; + $this->kekaUrl = $data->settings['keka_url'] ?? $data->settings['keka']['keka_url'] ?? ''; $this->samlAttributes = []; $attributes = $data->settings['saml']['attributes'] ?? []; diff --git a/bootstrap/app.php b/bootstrap/app.php index b645e1c..ded1166 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -11,5 +11,9 @@ commands: __DIR__.'/../routes/console.php', health: '/up', ) - ->withMiddleware(function (Middleware $middleware): void {}) + ->withMiddleware(function (Middleware $middleware): void { + $middleware->validateCsrfTokens(except: [ + 'keka/callback', + ]); + }) ->withExceptions(function (Exceptions $exceptions): void {})->create(); diff --git a/resources/views/pages/connected-apps/⚡create.blade.php b/resources/views/pages/connected-apps/⚡create.blade.php index 0042854..202801b 100644 --- a/resources/views/pages/connected-apps/⚡create.blade.php +++ b/resources/views/pages/connected-apps/⚡create.blade.php @@ -77,6 +77,16 @@ public function isSaml(): bool return $protocol && strtolower($protocol->name) === "saml"; } + #[Computed] + public function isKekaProvider(): bool + { + $provider = $this->providers()->firstWhere( + "id", + (int) $this->form->providerId, + ); + return $provider && $provider->slug === "keka-hr"; + } + public function save(bool $goBack = true): void { $this->form->validate(); @@ -98,6 +108,15 @@ public function save(bool $goBack = true): void "attributes" => $attributes, ], ]; + } elseif ($this->isKekaProvider()) { + $settings = [ + "keka_url" => $this->form->kekaUrl, + "keka" => [ + "keka_url" => $this->form->kekaUrl, + "callback_path" => "signin-oidc", + "provider" => "Office365", + ], + ]; } $data = new ConnectAppRequest( @@ -150,7 +169,7 @@ public function goBack(): void /> @endif + @if($this->isKekaProvider) +
+

Keka Configuration

+ +
+ @endif +
Cancel Save & Add Another diff --git a/resources/views/pages/connected-apps/⚡edit.blade.php b/resources/views/pages/connected-apps/⚡edit.blade.php index 22f9752..b346f36 100644 --- a/resources/views/pages/connected-apps/⚡edit.blade.php +++ b/resources/views/pages/connected-apps/⚡edit.blade.php @@ -79,6 +79,16 @@ public function isSaml(): bool return $protocol && strtolower($protocol->name) === "saml"; } + #[Computed] + public function isKekaProvider(): bool + { + $provider = $this->providers()->firstWhere( + "id", + (int) $this->form->providerId, + ); + return $provider && $provider->slug === "keka-hr"; + } + /** * @returns DataCollection */ @@ -113,6 +123,15 @@ public function save(): void "attributes" => $attributes, ], ]; + } elseif ($this->isKekaProvider()) { + $settings = [ + "keka_url" => $this->form->kekaUrl, + "keka" => [ + "keka_url" => $this->form->kekaUrl, + "callback_path" => "signin-oidc", + "provider" => "Office365", + ], + ]; } $serviceData = new ConnectAppRequest( @@ -157,7 +176,7 @@ public function removeSamlAttribute(int $index): void /> isSaml) @endif + + @if($this->isKekaProvider) +
+

Keka Configuration

+ +
+ @endif
diff --git a/resources/views/pages/dashboards/⚡user.blade.php b/resources/views/pages/dashboards/⚡user.blade.php index a3f77b1..3b3a729 100644 --- a/resources/views/pages/dashboards/⚡user.blade.php +++ b/resources/views/pages/dashboards/⚡user.blade.php @@ -40,6 +40,14 @@ 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] @@ -67,6 +75,20 @@ 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(); + } }; ?> @@ -189,6 +211,58 @@ class="btn-primary flex-1"
@endif + + @if($service->protocol_name === 'url' && $service->provider_slug === 'keka-hr') +
+
+ Keka Integration + @if($this->isKekaConnected) + + Connected + + @else + + Not Connected + + @endif +
+ +
+ @if($this->isKekaConnected) + + +
+ @csrf + @method('DELETE') + + + @else + + @endif +
+
+ @endif
@@ -230,6 +304,24 @@ class="btn-sm btn-circle btn-soft btn-primary" tooltip="Launch Service" /> @endif + @elseif($service->provider_slug === 'keka-hr') + @if($this->isKekaConnected) + + @else + + @endif @else name('disconnect'); }); +Route::middleware(['auth', 'verified'])->prefix('keka')->name('keka.')->group(function (): void { + Route::get('connect', [KekaController::class, 'connect'])->name('connect'); + Route::match(['get', 'post'], 'callback', [KekaController::class, 'callback'])->name('callback'); + Route::delete('disconnect', [KekaController::class, 'disconnect'])->name('disconnect'); +}); + require __DIR__.'/settings.php'; diff --git a/scratch_keka.php b/scratch_keka.php new file mode 100644 index 0000000..2e60500 --- /dev/null +++ b/scratch_keka.php @@ -0,0 +1,14 @@ +make(Illuminate\Contracts\Console\Kernel::class)->bootstrap(); +$kekaApp = App\Models\ConnectedApp::whereHas('provider', function ($query): void { + $query->where('slug', 'keka-hr'); +})->first(); +if ($kekaApp) { + print_r($kekaApp->toArray()); +} else { + echo "No Keka HR app found\n"; +} diff --git a/tests/Feature/KekaOAuthTest.php b/tests/Feature/KekaOAuthTest.php new file mode 100644 index 0000000..ad5580a --- /dev/null +++ b/tests/Feature/KekaOAuthTest.php @@ -0,0 +1,264 @@ +where('name', 'url')->first(); + if (! $protocol) { + $protocol = new App\Models\ConnectionProtocol(); + $protocol->name = 'url'; + $protocol->save(); + } + + $provider = App\Models\ConnectionProvider::query()->where('slug', 'keka-hr')->first(); + if (! $provider) { + $provider = new App\Models\ConnectionProvider(); + $provider->name = 'Keka HR'; + $provider->slug = 'keka-hr'; + $provider->save(); + } + + $status = App\Models\ConnectionStatus::query()->where('name', 'connected')->first(); + if (! $status) { + $status = new App\Models\ConnectionStatus(); + $status->name = 'connected'; + $status->save(); + } + + $app = App\Models\ConnectedApp::query()->create([ + 'name' => 'Keka HR Portal', + 'slug' => 'keka-hr-portal', + 'connection_protocol_id' => $protocol->id, + 'connection_provider_id' => $provider->id, + 'connection_status_id' => $status->id, + 'settings' => [ + 'keka' => [ + 'keka_url' => 'https://sentientgeeks.keka.com', + 'callback_path' => 'signin-oidc', + 'provider' => 'Office365', + ], + ], + ]); + + $role = App\Models\Role::findOrCreate('Keka User'); + $role->update(['priority' => 10]); + $role->apps()->attach($app->id, ['duration' => now()->addYear()->toDateTimeString()]); + + $user->assignRole($role); +} + +it('redirects unauthenticated users to login when connecting', function (): void { + $this->get(route('keka.connect')) + ->assertRedirect(route('login')); +}); + +it('returns 403 forbidden if user does not have permission for keka-hr app', function (): void { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('keka.connect')) + ->assertStatus(403); +}); + +it('redirects to Keka login page on connect when authorized', function (): void { + $user = User::factory()->create(); + assignKekaHrAccess($user); + + $response = $this->actingAs($user) + ->get(route('keka.connect')); + + $response->assertRedirect('https://login.keka.com/Account/ExternalLogin?provider=Office365&companyId=sentientgeeks'); +}); + +it('stores token and redirects to Keka callback on successful callback when authorized', function (): void { + $user = User::factory()->create(); + assignKekaHrAccess($user); + + // Create a microsoft token first to check if callback copies it + OauthToken::create([ + 'user_id' => $user->id, + 'provider' => 'microsoft', + 'access_token' => 'ms-access-token', + 'refresh_token' => 'ms-refresh-token', + 'token_type' => 'Bearer', + 'expires_at' => now()->addHour(), + ]); + + $response = $this->actingAs($user) + ->get(route('keka.callback', [ + 'code' => 'keka-code-value', + 'state' => 'keka-state-value', + ])); + + $response->assertRedirect('https://login.keka.com/signin-oidc?code=keka-code-value&state=keka-state-value'); + + $this->assertDatabaseHas('oauth_tokens', [ + 'user_id' => $user->id, + 'provider' => 'keka', + 'access_token' => 'ms-access-token', + 'refresh_token' => 'ms-refresh-token', + ]); +}); + +it('stores placeholder token and redirects to Keka callback when no microsoft token exists', function (): void { + $user = User::factory()->create(); + assignKekaHrAccess($user); + + $response = $this->actingAs($user) + ->get(route('keka.callback', [ + 'code' => 'keka-code-value2', + 'state' => 'keka-state-value2', + ])); + + $response->assertRedirect('https://login.keka.com/signin-oidc?code=keka-code-value2&state=keka-state-value2'); + + $this->assertDatabaseHas('oauth_tokens', [ + 'user_id' => $user->id, + 'provider' => 'keka', + 'access_token' => 'keka-code-value2', + ]); +}); + +it('redirects to dashboard with error on callback if code is missing', function (): void { + $user = User::factory()->create(); + assignKekaHrAccess($user); + + $response = $this->actingAs($user) + ->get(route('keka.callback', [ + 'state' => 'keka-state-value', + ])); + + $response->assertRedirect(route('dashboard.user')); + $response->assertSessionHas('keka_error'); +}); + +it('disconnects and removes the token when authorized', function (): void { + $user = User::factory()->create(); + assignKekaHrAccess($user); + + OauthToken::create([ + 'user_id' => $user->id, + 'provider' => 'keka', + 'access_token' => 'fake-keka-token', + 'refresh_token' => 'fake-keka-refresh', + 'token_type' => 'Bearer', + 'expires_at' => now()->addHour(), + ]); + + $response = $this->actingAs($user) + ->delete(route('keka.disconnect')); + + $response->assertRedirect(route('dashboard.user')); + $response->assertSessionHas('keka_success', 'Keka HR disconnected.'); + + $this->assertDatabaseMissing('oauth_tokens', [ + 'user_id' => $user->id, + 'provider' => 'keka', + ]); +}); + +it('requires a valid kekaUrl when creating a Keka HR connected app', function (): void { + $this->seed(Database\Seeders\ConnectionProtocolSeeder::class); + $this->seed(Database\Seeders\ConnectionProviderSeeder::class); + $this->seed(Database\Seeders\ConnectionStatusSeeder::class); + + $admin = User::factory()->create(); + $admin->assignRole(App\Models\Role::findOrCreate('Admin')); + + $protocol = App\Models\ConnectionProtocol::query()->where('name', 'url')->firstOrFail(); + $provider = App\Models\ConnectionProvider::query()->where('slug', 'keka-hr')->firstOrFail(); + $status = App\Models\ConnectionStatus::query()->where('name', 'connected')->firstOrFail(); + + $this->actingAs($admin); + + // Test validation failure + Livewire::test('pages::connected-apps.create') + ->set('form.name', 'My Keka App') + ->set('form.protocolId', $protocol->id) + ->set('form.providerId', $provider->id) + ->set('form.statusId', $status->id) + ->set('form.kekaUrl', 'not-a-valid-url') + ->call('save') + ->assertHasErrors(['form.kekaUrl' => 'url']); + + // Test validation success + Livewire::test('pages::connected-apps.create') + ->set('form.name', 'My Keka App') + ->set('form.protocolId', $protocol->id) + ->set('form.providerId', $provider->id) + ->set('form.statusId', $status->id) + ->set('form.kekaUrl', 'https://sentientgeeks.keka.com') + ->call('save') + ->assertHasNoErrors(); + + $this->assertDatabaseHas('connected_apps', [ + 'name' => 'My Keka App', + 'connection_protocol_id' => $protocol->id, + 'connection_provider_id' => $provider->id, + ]); + + $app = App\Models\ConnectedApp::query()->where('name', 'My Keka App')->firstOrFail(); + expect(data_get($app->settings, 'keka_url'))->toBe('https://sentientgeeks.keka.com'); + expect(data_get($app->settings, 'keka.provider'))->toBe('Office365'); +}); + +it('successfully edits a Keka HR connected app', function (): void { + $this->seed(Database\Seeders\ConnectionProtocolSeeder::class); + $this->seed(Database\Seeders\ConnectionProviderSeeder::class); + $this->seed(Database\Seeders\ConnectionStatusSeeder::class); + + $admin = User::factory()->create(); + $admin->assignRole(App\Models\Role::findOrCreate('Admin')); + + $protocol = App\Models\ConnectionProtocol::query()->where('name', 'url')->firstOrFail(); + $provider = App\Models\ConnectionProvider::query()->where('slug', 'keka-hr')->firstOrFail(); + $status = App\Models\ConnectionStatus::query()->where('name', 'connected')->firstOrFail(); + + $app = App\Models\ConnectedApp::query()->create([ + 'name' => 'My Keka App', + 'slug' => 'my-keka-app', + 'connection_protocol_id' => $protocol->id, + 'connection_provider_id' => $provider->id, + 'connection_status_id' => $status->id, + 'settings' => [ + 'keka_url' => 'https://old.keka.com', + 'keka' => [ + 'keka_url' => 'https://old.keka.com', + 'provider' => 'OpenIdConnect', + ], + ], + ]); + + $this->actingAs($admin); + + Livewire::test('pages::connected-apps.edit', ['id' => $app->id]) + ->set('form.kekaUrl', 'https://new.keka.com') + ->call('save') + ->assertHasNoErrors(); + + $app->refresh(); + expect(data_get($app->settings, 'keka_url'))->toBe('https://new.keka.com'); + expect(data_get($app->settings, 'keka.provider'))->toBe('Office365'); +}); + +it('forwards POST callback response via an auto-submitting HTML form', function (): void { + $user = User::factory()->create(); + assignKekaHrAccess($user); + + $response = $this->actingAs($user) + ->post(route('keka.callback'), [ + 'code' => 'post-code-value', + 'state' => 'post-state-value', + 'session_state' => 'session-state-value', + ]); + + $response->assertStatus(200); + $response->assertSee('action="https://login.keka.com/signin-oidc"', false); + $response->assertSee('name="code" value="post-code-value"', false); + $response->assertSee('name="state" value="post-state-value"', false); + $response->assertSee('name="session_state" value="session-state-value"', false); +}); From c4e2cfc4bb3088adca2cbb27cb09d6151602444b Mon Sep 17 00:00:00 2001 From: = Date: Fri, 12 Jun 2026 11:55:12 +0000 Subject: [PATCH 3/3] Feat: Modularize Keka HR integration with services - Introduced `KekaAccessGuard`, `KekaConfigResolver`, `KekaIntegrationService`, `KekaTokenService`, and `KekaUrlBuilder` to streamline Keka HR functionality. - Refactored `KekaController` to delegate key operations to modular services, improving maintainability. - Updated dashboard UI to ensure alignment with the new service-based architecture. - Enhanced error handling, logging, and code reusability for Keka integration workflows. --- app/Http/Controllers/KekaController.php | 232 ++---------------- app/Services/Keka/KekaAccessGuard.php | 37 +++ app/Services/Keka/KekaConfigResolver.php | 58 +++++ app/Services/Keka/KekaIntegrationService.php | 105 ++++++++ app/Services/Keka/KekaTokenService.php | 48 ++++ app/Services/Keka/KekaUrlBuilder.php | 59 +++++ .../views/pages/dashboards/⚡user.blade.php | 3 +- 7 files changed, 323 insertions(+), 219 deletions(-) create mode 100644 app/Services/Keka/KekaAccessGuard.php create mode 100644 app/Services/Keka/KekaConfigResolver.php create mode 100644 app/Services/Keka/KekaIntegrationService.php create mode 100644 app/Services/Keka/KekaTokenService.php create mode 100644 app/Services/Keka/KekaUrlBuilder.php diff --git a/app/Http/Controllers/KekaController.php b/app/Http/Controllers/KekaController.php index 99451b9..dbf67c3 100644 --- a/app/Http/Controllers/KekaController.php +++ b/app/Http/Controllers/KekaController.php @@ -4,239 +4,35 @@ namespace App\Http\Controllers; -use App\Models\{ConnectedApp, OauthToken}; -use App\Services\UserAppAccessService; -use Illuminate\Http\{RedirectResponse, Request}; -use Illuminate\Support\Facades\Log; +use App\Services\Keka\{KekaAccessGuard, KekaIntegrationService}; +use Illuminate\Http\{RedirectResponse, Request, Response}; class KekaController extends Controller { - /** - * Step 1: Redirect the user to Keka's external login page. - */ + public function __construct( + private readonly KekaAccessGuard $accessGuard, + private readonly KekaIntegrationService $kekaService, + ) {} + public function connect(): RedirectResponse { - $user = auth()->user(); - if (! $user) { - abort(403, 'Unauthorized'); - } + $user = $this->accessGuard->authorize(); - $activeServices = app(UserAppAccessService::class)->getActiveServices($user); - $hasKekaAccess = $activeServices->contains(fn ($service) => 'keka-hr' === $service->provider_slug); - - if (! $hasKekaAccess) { - abort(403, 'You do not have permission to access the required application.'); - } - - $app = ConnectedApp::whereHas('provider', function ($query): void { - $query->where('slug', 'keka-hr'); - })->first(); - - if (! $app) { - abort(404, 'Keka HR app registration not found.'); - } - - $kekaUrl = data_get($app->settings, 'keka.keka_url') ?? data_get($app->settings, 'keka_url') ?? 'https://sentientgeeks.keka.com'; - $provider = data_get($app->settings, 'keka.provider') ?? data_get($app->settings, 'provider') ?? 'Office365'; - - $redirectUrl = $this->getKekaExternalLoginUrl((string) $kekaUrl, (string) $provider); - - Log::info('Redirecting user to Keka external login page.', [ - 'user_id' => $user->id, - 'keka_url' => $kekaUrl, - 'redirect_url' => $redirectUrl, - ]); - - return redirect($redirectUrl); + return redirect($this->kekaService->getExternalLoginUrl($user)); } - /** - * Parse the configured Keka URL to build the correct external login URL. - */ - private function getKekaExternalLoginUrl(string $configuredUrl, string $provider): string + public function callback(Request $request): Response|RedirectResponse { - $parsed = parse_url($configuredUrl); - $host = $parsed['host'] ?? 'login.keka.com'; + $user = $this->accessGuard->authorize(); - $query = []; - if (isset($parsed['query'])) { - parse_str($parsed['query'], $query); - } - - $companyId = $query['companyId'] ?? $query['companyid'] ?? null; - - if (! $companyId && 'login.keka.com' !== $host) { - $parts = explode('.', $host); - if (count($parts) >= 2) { - $companyId = $parts[0]; - } - } - - $buildQuery = [ - 'provider' => $provider, - ]; - - // if ($companyId) { - // $buildQuery['companyId'] = $companyId; - // } - - $queryString = http_build_query($buildQuery); - - return "https://login.keka.com/Account/ExternalLogin?{$queryString}"; + return $this->kekaService->handleCallback($user, $request); } - /** - * Step 2: Handle the Microsoft callback redirection. - * We record/store the tokens in the database, and then pass the code/state to Keka. - */ - public function callback(Request $request) - { - $user = auth()->user(); - if (! $user) { - abort(403, 'Unauthorized'); - } - - $activeServices = app(UserAppAccessService::class)->getActiveServices($user); - $hasKekaAccess = $activeServices->contains(fn ($service) => 'keka-hr' === $service->provider_slug); - - if (! $hasKekaAccess) { - abort(403, 'You do not have permission to access the required application.'); - } - - $code = $request->input('code'); - $state = $request->input('state'); - - if (empty($code)) { - return redirect()->route('dashboard.user')->with('keka_error', 'Keka connection failed: Missing code.'); - } - - // Retrieve Keka App configurations - $app = ConnectedApp::whereHas('provider', function ($query): void { - $query->where('slug', 'keka-hr'); - })->first(); - - if (! $app) { - abort(404, 'Keka HR app registration not found.'); - } - - $kekaUrl = data_get($app->settings, 'keka.keka_url') ?? data_get($app->settings, 'keka_url') ?? 'https://sentientgeeks.keka.com'; - $callbackPath = data_get($app->settings, 'keka.callback_path') ?? 'signin-oidc'; - $callbackUrl = $this->getKekaCallbackUrl((string) $kekaUrl, (string) $callbackPath); - - // Store token details (Option B: copy user's existing Microsoft token or save code/placeholder) - $microsoftToken = $user->oauthToken('microsoft'); - - if ($microsoftToken) { - $accessToken = $microsoftToken->access_token; - $refreshToken = $microsoftToken->refresh_token; - $tokenType = $microsoftToken->token_type; - $scopes = $microsoftToken->scopes; - $expiresAt = $microsoftToken->expires_at; - } else { - $accessToken = (string) $code; - $refreshToken = 'keka-placeholder-refresh'; - $tokenType = 'Bearer'; - $scopes = 'openid profile email'; - $expiresAt = now()->addHour(); - } - - OauthToken::updateOrCreate( - [ - 'user_id' => $user->id, - 'provider' => 'keka', - ], - [ - 'access_token' => $accessToken, - 'refresh_token' => $refreshToken, - 'token_type' => $tokenType, - 'scopes' => $scopes, - 'expires_at' => $expiresAt, - ] - ); - - Log::info('Keka tokens stored successfully. Passing OIDC response to Keka.', [ - 'user_id' => $user->id, - 'callback_url' => $callbackUrl, - 'method' => $request->method(), - ]); - - if ($request->isMethod('post')) { - $inputs = ''; - foreach ($request->all() as $key => $value) { - $safeKey = htmlspecialchars((string) $key, ENT_QUOTES, 'UTF-8'); - $safeValue = htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); - $inputs .= "\n"; - } - - $html = << - - - Forwarding to Keka... - - -
- {$inputs} - -
- - -HTML; - - return response($html); - } - - $queryParams = $request->query(); - $queryString = http_build_query($queryParams); - - return redirect("{$callbackUrl}?{$queryString}"); - } - - /** - * Parse the configured Keka URL to construct the callback URL. - */ - private function getKekaCallbackUrl(string $configuredUrl, string $callbackPath): string - { - $parsed = parse_url($configuredUrl); - $host = $parsed['host'] ?? 'login.keka.com'; - - if ('login.keka.com' !== $host) { - $parts = explode('.', $host); - if (count($parts) >= 2 && 'keka' === $parts[1]) { - $host = 'login.keka.com'; - } - } - - $scheme = $parsed['scheme'] ?? 'https'; - - return "{$scheme}://{$host}/".mb_ltrim($callbackPath, '/'); - } - - /** - * Disconnect/Revoke the Keka integration for the user. - */ public function disconnect(): RedirectResponse { - $user = auth()->user(); - if (! $user) { - abort(403, 'Unauthorized'); - } + $user = $this->accessGuard->authorize(); - $activeServices = app(UserAppAccessService::class)->getActiveServices($user); - $hasKekaAccess = $activeServices->contains(fn ($service) => 'keka-hr' === $service->provider_slug); - - if (! $hasKekaAccess) { - abort(403, 'You do not have permission to access the required application.'); - } - - $user->oauthToken('keka')?->delete(); - - Log::info('Keka integration disconnected for user.', [ - 'user_id' => $user->id, - ]); + $this->kekaService->disconnect($user); return redirect()->route('dashboard.user')->with('keka_success', 'Keka HR disconnected.'); } diff --git a/app/Services/Keka/KekaAccessGuard.php b/app/Services/Keka/KekaAccessGuard.php new file mode 100644 index 0000000..ef33fd3 --- /dev/null +++ b/app/Services/Keka/KekaAccessGuard.php @@ -0,0 +1,37 @@ +user(); + + if (! $user) { + abort(403, 'Unauthorized'); + } + + $activeServices = $this->userAppAccessService->getActiveServices($user); + $hasAccess = $activeServices->contains( + fn ($service) => self::PROVIDER_SLUG === $service->provider_slug + ); + + if (! $hasAccess) { + abort(403, 'You do not have permission to access the required application.'); + } + + return $user; + } +} diff --git a/app/Services/Keka/KekaConfigResolver.php b/app/Services/Keka/KekaConfigResolver.php new file mode 100644 index 0000000..7c70586 --- /dev/null +++ b/app/Services/Keka/KekaConfigResolver.php @@ -0,0 +1,58 @@ +where('slug', self::PROVIDER_SLUG); + })->first(); + + if (! $app) { + throw new NotFoundHttpException('Keka HR app registration not found.'); + } + + return $app; + } + + public function getKekaUrl(ConnectedApp $app): string + { + return (string) ( + data_get($app->settings, 'keka.keka_url') + ?? data_get($app->settings, 'keka_url') + ?? self::DEFAULT_KEKA_URL + ); + } + + public function getProvider(ConnectedApp $app): string + { + return (string) ( + data_get($app->settings, 'keka.provider') + ?? data_get($app->settings, 'provider') + ?? self::DEFAULT_PROVIDER + ); + } + + public function getCallbackPath(ConnectedApp $app): string + { + return (string) ( + data_get($app->settings, 'keka.callback_path') + ?? self::DEFAULT_CALLBACK_PATH + ); + } +} diff --git a/app/Services/Keka/KekaIntegrationService.php b/app/Services/Keka/KekaIntegrationService.php new file mode 100644 index 0000000..f4128d5 --- /dev/null +++ b/app/Services/Keka/KekaIntegrationService.php @@ -0,0 +1,105 @@ +configResolver->getApp(); + + $kekaUrl = $this->configResolver->getKekaUrl($app); + $provider = $this->configResolver->getProvider($app); + + $redirectUrl = $this->urlBuilder->buildExternalLoginUrl($kekaUrl, $provider); + + Log::info('Redirecting user to Keka external login page.', [ + 'user_id' => $user->id, + 'keka_url' => $kekaUrl, + 'redirect_url' => $redirectUrl, + ]); + + return $redirectUrl; + } + + public function handleCallback(User $user, Request $request): Response|RedirectResponse + { + $code = $request->input('code'); + + if (empty($code)) { + return redirect() + ->route('dashboard.user') + ->with('keka_error', 'Keka connection failed: Missing code.'); + } + + $app = $this->configResolver->getApp(); + $kekaUrl = $this->configResolver->getKekaUrl($app); + $callbackPath = $this->configResolver->getCallbackPath($app); + $callbackUrl = $this->urlBuilder->buildCallbackUrl($kekaUrl, $callbackPath); + + $this->tokenService->store($user, (string) $code); + + Log::info('Keka tokens stored successfully. Passing OIDC response to Keka.', [ + 'user_id' => $user->id, + 'callback_url' => $callbackUrl, + 'method' => $request->method(), + ]); + + if ($request->isMethod('post')) { + return response($this->renderAutoSubmitForm($callbackUrl, $request->all())); + } + + $queryString = http_build_query($request->query()); + + return redirect("{$callbackUrl}?{$queryString}"); + } + + private function renderAutoSubmitForm(string $callbackUrl, array $inputsData): string + { + $inputs = ''; + foreach ($inputsData as $key => $value) { + $safeKey = htmlspecialchars((string) $key, ENT_QUOTES, 'UTF-8'); + $safeValue = htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8'); + $inputs .= "\n"; + } + + return << + + + Forwarding to Keka... + + +
+ {$inputs} + +
+ + + HTML; + } + + public function disconnect(User $user): void + { + $this->tokenService->revoke($user); + + Log::info('Keka integration disconnected for user.', [ + 'user_id' => $user->id, + ]); + } +} diff --git a/app/Services/Keka/KekaTokenService.php b/app/Services/Keka/KekaTokenService.php new file mode 100644 index 0000000..48806af --- /dev/null +++ b/app/Services/Keka/KekaTokenService.php @@ -0,0 +1,48 @@ +oauthToken('microsoft'); + + if ($microsoftToken) { + $accessToken = $microsoftToken->access_token; + $refreshToken = $microsoftToken->refresh_token; + $tokenType = $microsoftToken->token_type; + $scopes = $microsoftToken->scopes; + $expiresAt = $microsoftToken->expires_at; + } else { + $accessToken = $code; + $refreshToken = 'keka-placeholder-refresh'; + $tokenType = 'Bearer'; + $scopes = 'openid profile email'; + $expiresAt = now()->addHour(); + } + + OauthToken::updateOrCreate( + [ + 'user_id' => $user->id, + 'provider' => 'keka', + ], + [ + 'access_token' => $accessToken, + 'refresh_token' => $refreshToken, + 'token_type' => $tokenType, + 'scopes' => $scopes, + 'expires_at' => $expiresAt, + ] + ); + } + + public function revoke(User $user): void + { + $user->oauthToken('keka')?->delete(); + } +} diff --git a/app/Services/Keka/KekaUrlBuilder.php b/app/Services/Keka/KekaUrlBuilder.php new file mode 100644 index 0000000..d0118c1 --- /dev/null +++ b/app/Services/Keka/KekaUrlBuilder.php @@ -0,0 +1,59 @@ += 2) { + $companyId = $parts[0]; + } + } + + $buildQuery = [ + 'provider' => $provider, + ]; + + // if ($companyId) { + // $buildQuery['companyId'] = $companyId; + // } + + $queryString = http_build_query($buildQuery); + + return 'https://'.self::DEFAULT_HOST."/Account/ExternalLogin?{$queryString}"; + } + + public function buildCallbackUrl(string $configuredUrl, string $callbackPath): string + { + $parsed = parse_url($configuredUrl); + $host = $parsed['host'] ?? self::DEFAULT_HOST; + + if (self::DEFAULT_HOST !== $host) { + $parts = explode('.', $host); + if (count($parts) >= 2 && 'keka' === $parts[1]) { + $host = self::DEFAULT_HOST; + } + } + + $scheme = $parsed['scheme'] ?? 'https'; + + return "{$scheme}://{$host}/".mb_ltrim($callbackPath, '/'); + } +} diff --git a/resources/views/pages/dashboards/⚡user.blade.php b/resources/views/pages/dashboards/⚡user.blade.php index 3b3a729..c2c157a 100644 --- a/resources/views/pages/dashboards/⚡user.blade.php +++ b/resources/views/pages/dashboards/⚡user.blade.php @@ -201,6 +201,7 @@ class=" btn-error" @else
@if($this->isKekaConnected) @else