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.
This commit is contained in:
= 2026-06-12 11:55:12 +00:00
parent b90dd5c092
commit c4e2cfc4bb
7 changed files with 323 additions and 219 deletions

View File

@ -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();
return redirect($this->kekaService->getExternalLoginUrl($user));
}
$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
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);
return $this->kekaService->handleCallback($user, $request);
}
$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 .= "<input type=\"hidden\" name=\"{$safeKey}\" value=\"{$safeValue}\" />\n";
}
$html = <<<HTML
<!DOCTYPE html>
<html>
<head>
<title>Forwarding to Keka...</title>
</head>
<body onload="document.forms[0].submit()">
<form method="post" action="{$callbackUrl}">
{$inputs}
<noscript>
<p>JavaScript is required to connect to Keka. Click the button below to continue.</p>
<input type="submit" value="Continue" />
</noscript>
</form>
</body>
</html>
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.');
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Services\Keka;
use App\Models\User;
use App\Services\UserAppAccessService;
class KekaAccessGuard
{
private const PROVIDER_SLUG = 'keka-hr';
public function __construct(
private readonly UserAppAccessService $userAppAccessService,
) {}
public function authorize(): User
{
$user = auth()->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;
}
}

View File

@ -0,0 +1,58 @@
<?php
declare(strict_types=1);
namespace App\Services\Keka;
use App\Models\ConnectedApp;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
class KekaConfigResolver
{
private const PROVIDER_SLUG = 'keka-hr';
private const DEFAULT_KEKA_URL = 'https://sentientgeeks.keka.com';
private const DEFAULT_PROVIDER = 'Office365';
private const DEFAULT_CALLBACK_PATH = 'signin-oidc';
public function getApp(): ConnectedApp
{
$app = ConnectedApp::whereHas('provider', function ($query): void {
$query->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
);
}
}

View File

@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace App\Services\Keka;
use App\Models\User;
use Illuminate\Http\{RedirectResponse, Request, Response};
use Illuminate\Support\Facades\Log;
class KekaIntegrationService
{
public function __construct(
private readonly KekaConfigResolver $configResolver,
private readonly KekaUrlBuilder $urlBuilder,
private readonly KekaTokenService $tokenService,
) {}
public function getExternalLoginUrl(User $user): string
{
$app = $this->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 .= "<input type=\"hidden\" name=\"{$safeKey}\" value=\"{$safeValue}\" />\n";
}
return <<<HTML
<!DOCTYPE html>
<html>
<head>
<title>Forwarding to Keka...</title>
</head>
<body onload="document.forms[0].submit()">
<form method="post" action="{$callbackUrl}">
{$inputs}
<noscript>
<p>JavaScript is required to connect to Keka. Click the button below to continue.</p>
<input type="submit" value="Continue" />
</noscript>
</form>
</body>
</html>
HTML;
}
public function disconnect(User $user): void
{
$this->tokenService->revoke($user);
Log::info('Keka integration disconnected for user.', [
'user_id' => $user->id,
]);
}
}

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace App\Services\Keka;
use App\Models\{OauthToken, User};
class KekaTokenService
{
public function store(User $user, string $code): void
{
$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 = $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();
}
}

View File

@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace App\Services\Keka;
class KekaUrlBuilder
{
private const DEFAULT_HOST = 'login.keka.com';
public function buildExternalLoginUrl(string $configuredUrl, string $provider): string
{
$parsed = parse_url($configuredUrl);
$host = $parsed['host'] ?? self::DEFAULT_HOST;
$query = [];
if (isset($parsed['query'])) {
parse_str($parsed['query'], $query);
}
$companyId = $query['companyId'] ?? $query['companyid'] ?? null;
if (! $companyId && self::DEFAULT_HOST !== $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://'.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, '/');
}
}

View File

@ -201,6 +201,7 @@ class=" btn-error"
</form>
@else
<x-mary-button
external
no-wire-navigate
icon="lucide.plug"
:link="route('entra.connect')"
@ -233,7 +234,6 @@ class="badge-soft badge-neutral text-[10px] font-semibold px-2 py-0.5">
<div class="flex items-center gap-2">
@if($this->isKekaConnected)
<x-mary-button
external
icon="lucide.external-link"
:link="route('keka.connect')"
class=" btn-primary flex-1"
@ -253,6 +253,7 @@ class=" btn-error"
</form>
@else
<x-mary-button
external
no-wire-navigate
icon="lucide.plug"
:link="route('keka.connect')"