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...
-
-
-
-
-
-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...
+
+
+
+
+
+ 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