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:
parent
b90dd5c092
commit
c4e2cfc4bb
@ -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.');
|
||||
}
|
||||
|
||||
37
app/Services/Keka/KekaAccessGuard.php
Normal file
37
app/Services/Keka/KekaAccessGuard.php
Normal 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;
|
||||
}
|
||||
}
|
||||
58
app/Services/Keka/KekaConfigResolver.php
Normal file
58
app/Services/Keka/KekaConfigResolver.php
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
105
app/Services/Keka/KekaIntegrationService.php
Normal file
105
app/Services/Keka/KekaIntegrationService.php
Normal 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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
48
app/Services/Keka/KekaTokenService.php
Normal file
48
app/Services/Keka/KekaTokenService.php
Normal 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();
|
||||
}
|
||||
}
|
||||
59
app/Services/Keka/KekaUrlBuilder.php
Normal file
59
app/Services/Keka/KekaUrlBuilder.php
Normal 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, '/');
|
||||
}
|
||||
}
|
||||
@ -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')"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user