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... - - -
- {$inputs} - -
- - -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... + + +
+ {$inputs} + +
+ + + 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