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)
+
+
+
+ @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);
+});