Merge branch 'feature/integrate-keka-via-azure' into subhajit_keka_chnages
This commit is contained in:
commit
14a3339b3c
39
app/Http/Controllers/KekaController.php
Normal file
39
app/Http/Controllers/KekaController.php
Normal file
@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Services\Keka\{KekaAccessGuard, KekaIntegrationService};
|
||||
use Illuminate\Http\{RedirectResponse, Request, Response};
|
||||
|
||||
class KekaController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly KekaAccessGuard $accessGuard,
|
||||
private readonly KekaIntegrationService $kekaService,
|
||||
) {}
|
||||
|
||||
public function connect(): RedirectResponse
|
||||
{
|
||||
$user = $this->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.');
|
||||
}
|
||||
}
|
||||
@ -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'] ?? [];
|
||||
|
||||
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, '/');
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
use Livewire\Component;
|
||||
|
||||
new class extends Component {
|
||||
public string $selectedTab = 'all-tab';
|
||||
public string $selectedTab = "all-tab";
|
||||
};
|
||||
?>
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
<x-shared.card title="Connected Apps" icon="lucide-blocks" class="pb-2 w-full h-min">
|
||||
<x-slot:actions>
|
||||
@can(RoleAndAppsPermissionEnum::ConnectApps->value)
|
||||
<x-mary-button wire:click="openAddAppModal" spinner="openAddAppModal" icon="lucide.plus" class="btn-sm">
|
||||
@can(RoleAndAppsPermissionEnum::ConnectApps)
|
||||
<x-mary-button wire:click="openAddAppModal" spinner="openAddAppModal" icon="lucide.plus"
|
||||
class="btn-sm">
|
||||
Add App
|
||||
</x-mary-button>
|
||||
@endcan
|
||||
@ -445,10 +444,13 @@ class="bg-gray-50 text-gray-500 cursor-not-allowed"
|
||||
/>
|
||||
@endif
|
||||
<div x-data="{ isUnlimited: @entangle('assignForm.isUnlimited') }"
|
||||
class="flex gap-2 items-baseline-last">
|
||||
class="flex gap-2 items-baseline-last"
|
||||
>
|
||||
|
||||
<div class="flex-1 transition-opacity duration-200"
|
||||
x-bind:class="isUnlimited ? 'opacity-50 pointer-events-none' : ''">
|
||||
<div
|
||||
class="flex-1 transition-opacity duration-200"
|
||||
x-bind:class="isUnlimited ? 'opacity-50 pointer-events-none' : ''"
|
||||
>
|
||||
<x-mary-datepicker
|
||||
label="Date"
|
||||
wire:model="assignForm.validUpto"
|
||||
|
||||
@ -77,6 +77,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";
|
||||
}
|
||||
|
||||
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
|
||||
/>
|
||||
<x-mary-choices
|
||||
required
|
||||
wire:model="form.providerId"
|
||||
wire:model.live="form.providerId"
|
||||
:label="__('Provider')"
|
||||
placeholder="Select"
|
||||
:single="true"
|
||||
@ -169,6 +188,14 @@ public function goBack(): void
|
||||
<x-dashboard.connected-apps.saml-configuration :form="$this->form"/>
|
||||
@endif
|
||||
|
||||
@if($this->isKekaProvider)
|
||||
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-gray-200 pt-4 mt-2">
|
||||
<h3 class="font-semibold text-sm text-blue-600 md:col-span-2">Keka Configuration</h3>
|
||||
<x-mary-input wire:model="form.kekaUrl" required :label="__('Keka Portal URL')"
|
||||
placeholder="e.g. https://sentientgeeks.keka.com" class="md:col-span-2"/>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="md:col-span-2 flex gap-x-4 font-medium justify-end">
|
||||
<x-mary-button wire:click.prevent="goBack" spinner="goBack">Cancel</x-mary-button>
|
||||
<x-mary-button wire:click.prevent="save(false)">Save & Add Another</x-mary-button>
|
||||
|
||||
@ -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<int, ConnectionStatus>
|
||||
*/
|
||||
@ -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
|
||||
/>
|
||||
<x-mary-choices
|
||||
required
|
||||
wire:model="form.providerId"
|
||||
wire:model.live="form.providerId"
|
||||
:label="__('Provider')"
|
||||
placeholder="Select"
|
||||
:single="true"
|
||||
@ -175,6 +194,14 @@ public function removeSamlAttribute(int $index): void
|
||||
@if($this->isSaml)
|
||||
<x-dashboard.connected-apps.saml-configuration :form="$this->form"/>
|
||||
@endif
|
||||
|
||||
@if($this->isKekaProvider)
|
||||
<div class="md:col-span-2 grid grid-cols-1 md:grid-cols-2 gap-4 border-t border-gray-200 pt-4 mt-2">
|
||||
<h3 class="font-semibold text-sm text-blue-600 md:col-span-2">Keka Configuration</h3>
|
||||
<x-mary-input wire:model="form.kekaUrl" required :label="__('Keka Portal URL')"
|
||||
placeholder="e.g. https://sentientgeeks.keka.com" class="md:col-span-2"/>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
<x-slot:actions class="pr-2">
|
||||
<x-mary-button :link="route('apps.index')" wire:navigate>
|
||||
|
||||
@ -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"/>
|
||||
|
||||
<div class="p-4 flex-1 flex flex-col justify-between gap-4">
|
||||
<div>
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 font-mono tracking-wider uppercase">
|
||||
{{ $service->slug }}
|
||||
</p>
|
||||
|
||||
@if($service->protocol_name === 'url' && $service->provider_slug === 'azure-fd')
|
||||
<div
|
||||
class="mt-4 pt-4 border-t border-gray-100 dark:border-gray-800 flex flex-col gap-3">
|
||||
class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-semibold text-gray-700 dark:text-gray-300">Microsoft Integration</span>
|
||||
<span class="text-xs font-semibold text-gray-700">Microsoft Integration</span>
|
||||
@if($this->isEntraConnected)
|
||||
<x-mary-badge
|
||||
class="badge-soft badge-success text-[10px] font-semibold px-2 py-0.5">
|
||||
@ -160,20 +178,13 @@ class="badge-soft badge-neutral text-[10px] font-semibold px-2 py-0.5">
|
||||
@endif
|
||||
</div>
|
||||
|
||||
@if($this->isEntraConnected && $this->entraToken)
|
||||
<div class="text-[10px] text-gray-400 flex items-center gap-1.5">
|
||||
<x-mary-icon name="lucide.clock" class="w-3.5 h-3.5"/>
|
||||
Expires {{ $this->entraToken->expires_at->diffForHumans() }}
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@if($this->isEntraConnected)
|
||||
<x-mary-button
|
||||
external
|
||||
icon="lucide.external-link"
|
||||
:link="route('entra.open')"
|
||||
class="btn-xs btn-soft btn-primary flex-1"
|
||||
class=" btn-primary flex-1"
|
||||
label="Open Outlook"
|
||||
/>
|
||||
|
||||
@ -184,16 +195,69 @@ class="inline">
|
||||
<x-mary-button
|
||||
icon="lucide.unplug"
|
||||
type="submit"
|
||||
class="btn-xs btn-soft btn-error"
|
||||
class=" btn-error"
|
||||
tooltip="Disconnect"
|
||||
/>
|
||||
</form>
|
||||
@else
|
||||
<x-mary-button
|
||||
external
|
||||
no-wire-navigate
|
||||
icon="lucide.plug"
|
||||
:link="route('entra.connect')"
|
||||
class="btn-xs btn-soft btn-primary flex-1"
|
||||
class="btn-primary flex-1"
|
||||
label="Connect"
|
||||
/>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if($service->protocol_name === 'url' && $service->provider_slug === 'keka-hr')
|
||||
<div
|
||||
class="flex flex-col gap-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs font-semibold text-gray-700">Keka Integration</span>
|
||||
@if($this->isKekaConnected)
|
||||
<x-mary-badge
|
||||
class="badge-soft badge-success text-[10px] font-semibold px-2 py-0.5">
|
||||
Connected
|
||||
</x-mary-badge>
|
||||
@else
|
||||
<x-mary-badge
|
||||
class="badge-soft badge-neutral text-[10px] font-semibold px-2 py-0.5">
|
||||
Not Connected
|
||||
</x-mary-badge>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
@if($this->isKekaConnected)
|
||||
<x-mary-button
|
||||
icon="lucide.external-link"
|
||||
:link="route('keka.connect')"
|
||||
class=" btn-primary flex-1"
|
||||
label="Open Keka"
|
||||
/>
|
||||
|
||||
<form method="POST" action="{{ route('keka.disconnect') }}"
|
||||
class="inline">
|
||||
@csrf
|
||||
@method('DELETE')
|
||||
<x-mary-button
|
||||
icon="lucide.unplug"
|
||||
type="submit"
|
||||
class=" btn-error"
|
||||
tooltip="Disconnect"
|
||||
/>
|
||||
</form>
|
||||
@else
|
||||
<x-mary-button
|
||||
external
|
||||
no-wire-navigate
|
||||
icon="lucide.plug"
|
||||
:link="route('keka.connect')"
|
||||
class="btn-primary flex-1"
|
||||
label="Connect"
|
||||
/>
|
||||
@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' }}"/>
|
||||
<span
|
||||
class="text-sm font-medium {{ $service->is_warning ? 'text-amber-600 dark:text-amber-400 font-semibold' : 'text-gray-600 dark:text-gray-400' }}">
|
||||
{{ $service->days_remaining }} {{ Str::plural('day', $service->days_remaining) }} left
|
||||
{{ Illuminate\Support\Carbon::parse($service->duration)->diffForHumans() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -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)
|
||||
<x-mary-button
|
||||
external
|
||||
icon="lucide.external-link"
|
||||
:link="route('keka.connect')"
|
||||
class="btn-sm btn-circle btn-soft btn-primary"
|
||||
tooltip="Launch Service"
|
||||
/>
|
||||
@else
|
||||
<x-mary-button
|
||||
no-wire-navigate
|
||||
icon="lucide.external-link"
|
||||
:link="route('keka.connect')"
|
||||
class="btn-sm btn-circle btn-soft btn-primary"
|
||||
tooltip="Launch Service"
|
||||
/>
|
||||
@endif
|
||||
@else
|
||||
<x-mary-button
|
||||
icon="lucide.external-link"
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
PermissionPermissionEnum,
|
||||
RoleAndAppsPermissionEnum,
|
||||
UserPermissionEnum};
|
||||
use App\Http\Controllers\EntraController;
|
||||
use App\Http\Controllers\{EntraController, KekaController};
|
||||
use App\Http\Controllers\{ResolveDashboardRouteController, SamlIdpController};
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use Spatie\Permission\Middleware\{PermissionMiddleware, RoleMiddleware};
|
||||
@ -105,4 +105,10 @@
|
||||
Route::post('/disconnect', [KekaOutlookController::class, 'disconnect'])->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';
|
||||
|
||||
14
scratch_keka.php
Normal file
14
scratch_keka.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
require 'vendor/autoload.php';
|
||||
$app = require_once 'bootstrap/app.php';
|
||||
$app->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";
|
||||
}
|
||||
264
tests/Feature/KekaOAuthTest.php
Normal file
264
tests/Feature/KekaOAuthTest.php
Normal file
@ -0,0 +1,264 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\{OauthToken, User};
|
||||
use Livewire\Livewire;
|
||||
|
||||
function assignKekaHrAccess(User $user): void
|
||||
{
|
||||
$protocol = App\Models\ConnectionProtocol::query()->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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user