diff --git a/app/Http/Controllers/KekaController.php b/app/Http/Controllers/KekaController.php
new file mode 100644
index 0000000..dbf67c3
--- /dev/null
+++ b/app/Http/Controllers/KekaController.php
@@ -0,0 +1,39 @@
+accessGuard->authorize();
+
+ return redirect($this->kekaService->getExternalLoginUrl($user));
+ }
+
+ public function callback(Request $request): Response|RedirectResponse
+ {
+ $user = $this->accessGuard->authorize();
+
+ return $this->kekaService->handleCallback($user, $request);
+ }
+
+ public function disconnect(): RedirectResponse
+ {
+ $user = $this->accessGuard->authorize();
+
+ $this->kekaService->disconnect($user);
+
+ 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/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...
+
+
+
+
+
+ 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/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/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"
+ >
-
+
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 527a2a5..c2c157a 100644
--- a/resources/views/pages/dashboards/⚡user.blade.php
+++ b/resources/views/pages/dashboards/⚡user.blade.php
@@ -7,10 +7,10 @@
use Livewire\Component;
use Mary\Traits\Toast;
-new
-#[Layout('layouts.app.sidebar')]
-#[Title('Dashboard')]
-class extends Component {
+new #[Layout("layouts.app.sidebar")]
+#[Title("Dashboard")]
+class extends
+ Component {
use Toast;
private UserAppAccessService $service;
@@ -28,17 +28,25 @@ public function mount(): void
if ($user) {
activity()
->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"));
+ }
+
+ if (session("keka_success")) {
+ $this->success(session("keka_success"));
+ }
+
+ if (session("keka_error")) {
+ $this->error(session("keka_error"));
}
}
@@ -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();
+ }
};
?>
@@ -138,15 +160,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 +178,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,16 +195,69 @@ class="inline">
@else
+ @endif
+
+
+ @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
@@ -208,7 +272,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() }}
@@ -241,6 +305,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);
+});