Feat: Add unlimited flag to app roles and enhance Keka integration

- Introduced `is_unlimited` field in `app_roles` table to support roles with unlimited duration.
- Enhanced front-end and back-end implementations to manage unlimited roles, including UI updates and validation.
- Refactored `KekaOutlookController` and `KekaOutlookService` for streamlined exception handling and code consistency.
- Applied linting and formatting improvements across updated files.
This commit is contained in:
= 2026-06-15 12:59:30 +00:00
parent 14a3339b3c
commit dfb47f7d19
16 changed files with 133 additions and 145 deletions

View File

@ -12,6 +12,7 @@ public function __construct(
public int $roleId,
public string $validUpto,
/** @var int[] */
public array $appIds
public array $appIds,
public bool $isUnlimited = false,
) {}
}

View File

@ -14,6 +14,7 @@ public function __construct(
public int $appId,
public string $appName,
public string $duration,
public bool $isUnlimited = false,
) {}
/**

View File

@ -5,8 +5,8 @@
namespace App\Http\Controllers;
use App\Services\KekaOutlookService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\{RedirectResponse, Request};
use RuntimeException;
final class KekaOutlookController extends Controller
{
@ -31,7 +31,7 @@ public function connect(Request $request): RedirectResponse
if ($this->kekaOutlook->isConnected($user)) {
try {
$this->kekaOutlook->getValidToken($user);
} catch (\RuntimeException) {
} catch (RuntimeException) {
// Token fully expired/revoked → re-authenticate
return redirect()->away($this->kekaOutlook->getAuthUrl());
}
@ -54,7 +54,7 @@ public function callback(Request $request): RedirectResponse
{
if ($request->filled('error')) {
return redirect()->route('dashboard')
->with('error', 'Microsoft login failed: ' . $request->get('error_description', $request->get('error')));
->with('error', 'Microsoft login failed: '.$request->get('error_description', $request->get('error')));
}
if (! $this->kekaOutlook->isValidState($request->get('state'))) {
@ -73,7 +73,7 @@ public function callback(Request $request): RedirectResponse
if (! $result['success']) {
return redirect()->route('dashboard')
->with('error', 'Connection failed: ' . ($result['message'] ?? 'Unknown error'));
->with('error', 'Connection failed: '.($result['message'] ?? 'Unknown error'));
}
// Token stored ✓ → Keka SSO will auto-login via active Microsoft session

View File

@ -14,7 +14,7 @@
* @property int $role_id
* @property string $duration Validity of the role
*/
#[Fillable(['app_id', 'role_id', 'duration'])]
#[Fillable(['app_id', 'role_id', 'duration', 'is_unlimited'])]
class AppRole extends Pivot
{
public $timestamps = false;
@ -36,4 +36,11 @@ public function role(): BelongsTo
{
return $this->belongsTo(Role::class, 'role_id');
}
protected function casts(): array
{
return [
'is_unlimited' => 'boolean',
];
}
}

View File

@ -5,17 +5,23 @@
namespace App\Models;
// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Carbon\CarbonImmutable;
use Database\Factories\UserFactory;
use DateTime;
use Illuminate\Database\Eloquent\Attributes\{Fillable, Hidden};
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\{BelongsToMany, HasMany};
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Illuminate\Support\Str;
use Laravel\Fortify\TwoFactorAuthenticatable;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Traits\HasRoles;
/**
* @property DateTime|CarbonImmutable|null $keka_ms_token_expires_at
*/
#[Fillable(['name', 'email', 'password', 'immutable_id'])]
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
final class User extends Authenticatable
@ -27,10 +33,10 @@ final class User extends Authenticatable
use Notifiable;
use TwoFactorAuthenticatable;
public function restrictedPermissions(): \Illuminate\Database\Eloquent\Relations\BelongsToMany
public function restrictedPermissions(): BelongsToMany
{
return $this->belongsToMany(
\Spatie\Permission\Models\Permission::class,
Permission::class,
'restricted_user_permissions',
'user_id',
'permission_id'
@ -50,17 +56,11 @@ public function initials(): string
}
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
* Get the OAuth token for a specific provider.
*/
protected function casts(): array
public function oauthToken(string $provider): ?OauthToken
{
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'keka_ms_token_expires_at' => 'datetime',
];
return $this->oauthTokens()->forProvider($provider)->first();
}
/**
@ -72,10 +72,16 @@ public function oauthTokens(): HasMany
}
/**
* Get the OAuth token for a specific provider.
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
public function oauthToken(string $provider): ?OauthToken
protected function casts(): array
{
return $this->oauthTokens()->forProvider($provider)->first();
return [
'email_verified_at' => 'datetime',
'password' => 'hashed',
'keka_ms_token_expires_at' => 'datetime',
];
}
}

View File

@ -39,12 +39,13 @@ public function assign(AssignAppsToRoleData $data): void
'role_id' => $data->roleId,
'app_id' => $appId,
'duration' => $data->validUpto,
'is_unlimited' => $data->isUnlimited,
], $data->appIds);
AppRole::query()->upsert(
$records,
['role_id', 'app_id'],
['duration']
['duration', 'is_unlimited'],
);
});
}
@ -123,7 +124,7 @@ public function getAppsForRole(int $roleId): DataCollection
->with('app:id,name')
->where('role_id', $roleId)
->orderBy('id')
->get(['id', 'app_id', 'role_id', 'duration']);
->get(['id', 'app_id', 'role_id', 'duration', 'is_unlimited']);
return RoleAppData::collect(
$assignments->map(fn (AppRole $row): array => [
@ -131,6 +132,7 @@ public function getAppsForRole(int $roleId): DataCollection
'appId' => $row->app_id,
'appName' => $row->app->name ?? '',
'duration' => $row->duration,
'isUnlimited' => $row->is_unlimited,
])->all(),
DataCollection::class,
);

View File

@ -7,6 +7,7 @@
use App\Models\User;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use RuntimeException;
/**
* Handles Microsoft OAuth (delegated, authorization code flow) for Keka-Outlook integration.
@ -31,11 +32,11 @@ final class KekaOutlookService
public function __construct()
{
$this->clientId = (string) config('microsoft.client_id');
$this->clientId = (string) config('microsoft.client_id');
$this->clientSecret = (string) config('microsoft.client_secret');
$this->tenantId = (string) config('microsoft.tenant_id');
$this->redirectUri = (string) config('microsoft.keka_redirect_uri');
$this->scopes = config('microsoft.keka_scopes', [
$this->tenantId = (string) config('microsoft.tenant_id');
$this->redirectUri = (string) config('microsoft.keka_redirect_uri');
$this->scopes = config('microsoft.keka_scopes', [
'openid',
'profile',
'email',
@ -57,14 +58,14 @@ public function getAuthUrl(): string
session(['keka_ms_oauth_state' => $state]);
$query = http_build_query([
'client_id' => $this->clientId,
'client_id' => $this->clientId,
'response_type' => 'code',
'redirect_uri' => $this->redirectUri,
'redirect_uri' => $this->redirectUri,
'response_mode' => 'query',
'scope' => implode(' ', $this->scopes),
'state' => $state,
'scope' => implode(' ', $this->scopes),
'state' => $state,
// prompt=select_account forces account chooser on first connect.
'prompt' => 'select_account',
'prompt' => 'select_account',
]);
return "https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/authorize?{$query}";
@ -94,12 +95,12 @@ public function handleCallback(User $user, string $code): array
$response = Http::asForm()->post(
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
[
'client_id' => $this->clientId,
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $this->redirectUri,
'scope' => implode(' ', $this->scopes),
'grant_type' => 'authorization_code',
'code' => $code,
'redirect_uri' => $this->redirectUri,
'scope' => implode(' ', $this->scopes),
]
);
@ -133,7 +134,7 @@ public function handleCallback(User $user, string $code): array
*/
private function storeTokens(User $user, array $tokenData): void
{
$user->keka_ms_access_token = $tokenData['access_token'];
$user->keka_ms_access_token = $tokenData['access_token'];
$user->keka_ms_token_expires_at = now()->addSeconds((int) $tokenData['expires_in']);
// Microsoft only returns refresh_token if 'offline_access' scope was granted.
@ -145,24 +146,16 @@ private function storeTokens(User $user, array $tokenData): void
$user->save();
}
/**
* Returns true if the user has connected Keka/Outlook (has a refresh token stored).
*/
public function isConnected(User $user): bool
{
return ! empty($user->keka_ms_refresh_token);
}
/**
* Get a valid access token for the user.
* Silently refreshes via refresh_token if the current access token has expired.
*
* @throws \RuntimeException if the user has not connected or refresh fails.
* @throws RuntimeException if the user has not connected or refresh fails.
*/
public function getValidToken(User $user): string
{
if (! $this->isConnected($user)) {
throw new \RuntimeException('User has not connected Keka/Outlook.');
throw new RuntimeException('User has not connected Keka/Outlook.');
}
// Still valid? (1 minute buffer)
@ -177,21 +170,29 @@ public function getValidToken(User $user): string
return $this->refreshToken($user);
}
/**
* Returns true if the user has connected Keka/Outlook (has a refresh token stored).
*/
public function isConnected(User $user): bool
{
return ! empty($user->keka_ms_refresh_token);
}
/**
* Use the stored refresh_token to obtain a new access_token silently.
*
* @throws \RuntimeException if Microsoft rejects the refresh token (user must reconnect).
* @throws RuntimeException if Microsoft rejects the refresh token (user must reconnect).
*/
private function refreshToken(User $user): string
{
$response = Http::asForm()->post(
"https://login.microsoftonline.com/{$this->tenantId}/oauth2/v2.0/token",
[
'client_id' => $this->clientId,
'client_id' => $this->clientId,
'client_secret' => $this->clientSecret,
'grant_type' => 'refresh_token',
'grant_type' => 'refresh_token',
'refresh_token' => $user->keka_ms_refresh_token,
'scope' => implode(' ', $this->scopes),
'scope' => implode(' ', $this->scopes),
]
);
@ -199,7 +200,7 @@ private function refreshToken(User $user): string
// Refresh token expired/revoked -> force user to reconnect.
$this->disconnect($user);
throw new \RuntimeException(
throw new RuntimeException(
$response->json('error_description', 'Session expired. Please reconnect Keka.')
);
}
@ -215,10 +216,10 @@ private function refreshToken(User $user): string
*/
public function disconnect(User $user): void
{
$user->keka_ms_access_token = null;
$user->keka_ms_refresh_token = null;
$user->keka_ms_access_token = null;
$user->keka_ms_refresh_token = null;
$user->keka_ms_token_expires_at = null;
$user->keka_ms_email = null;
$user->keka_ms_email = null;
$user->save();
}
}

View File

@ -125,7 +125,7 @@
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
// Redirect URI registered in Azure: http://localhost:8000/keka/callback
// Redirect URI registered in Azure: http://localhost:8000/keka/callback
'keka_redirect_uri' => env('KEKA_MS_REDIRECT_URI', 'http://localhost:8000/keka/callback'),
// Where to send the user after a successful (or silent) connection.

View File

@ -1,17 +1,19 @@
<?php
declare(strict_types=1);
return [
'client_id' => env('MICROSOFT_GRAPH_CLIENT_ID'),
'client_id' => env('MICROSOFT_GRAPH_CLIENT_ID'),
'client_secret' => env('MICROSOFT_GRAPH_CLIENT_SECRET'),
'tenant_id' => env('MICROSOFT_GRAPH_TENANT_ID'),
'tenant_id' => env('MICROSOFT_GRAPH_TENANT_ID'),
// Your app's redirect URI for Keka OAuth flow
'keka_redirect_uri' => env('KEKA_MS_REDIRECT_URI', 'http://localhost:8000/keka/callback'),
// After first-time login: Keka's own Microsoft SSO URL
// After already connected: Keka dashboard
'keka_login_url' => env('KEKA_LOGIN_URL', 'https://sentientgeeks.keka.com/'),
'keka_dashboard_url'=> env('KEKA_DASHBOARD_URL', 'https://sentientgeeks.keka.com/'),
'keka_login_url' => env('KEKA_LOGIN_URL', 'https://sentientgeeks.keka.com/'),
'keka_dashboard_url' => env('KEKA_DASHBOARD_URL', 'https://sentientgeeks.keka.com/'),
'keka_scopes' => [
'openid',

View File

@ -1,14 +1,16 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
return new class() extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
Schema::table('users', function (Blueprint $table): void {
$table->text('keka_ms_access_token')->nullable();
$table->text('keka_ms_refresh_token')->nullable();
$table->timestamp('keka_ms_token_expires_at')->nullable();
@ -18,7 +20,7 @@ public function up(): void
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
Schema::table('users', function (Blueprint $table): void {
$table->dropColumn([
'keka_ms_access_token',
'keka_ms_refresh_token',

View File

@ -1,21 +1,23 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
return new class() extends Migration
{
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
Schema::table('users', function (Blueprint $table): void {
$table->boolean('keka_connected')->default(false);
});
}
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
Schema::table('users', function (Blueprint $table): void {
$table->dropColumn('keka_connected');
});
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class() extends Migration
{
public function up(): void
{
Schema::table('app_roles', function (Blueprint $table): void {
$table->boolean('is_unlimited')->default(false)->comment('Is role unlimited?');
});
}
public function down(): void
{
Schema::table('app_roles', function (Blueprint $table): void {
$table->dropColumn('is_unlimited');
});
}
};

View File

@ -2,6 +2,7 @@
use App\Concerns\Confirmation;
use App\Concerns\HandlesOperations;
use App\Livewire\Forms\AssignAppToRoleForm;
use App\Livewire\Forms\CreateRoleForm;
use App\Services\AppRoleService;
use App\Data\Role\{AssignAppsToRoleData, CreateRoleData, RoleAppData, RoleUserData};
@ -21,6 +22,7 @@ class extends Component {
protected AppRoleService $appRoleService;
public bool $showCreateModal = false;
public CreateRoleForm $form;
public AssignAppToRoleForm $assignForm;
protected RoleService $roleService;
public array $appsSearchable = [];
@ -88,7 +90,8 @@ public function save(): void
$this->form->validate();
if ((int) $this->form->priority <= $userPriority) {
$this->addError('form.priority', 'The priority must be greater than your own highest role priority (' . $userPriority . ').');
$this->addError('form.priority',
'The priority must be greater than your own highest role priority ('.$userPriority.').');
return;
}
@ -102,6 +105,7 @@ public function save(): void
roleId: $role->id,
validUpto: $this->form->isUnlimited ? now()->addYears(15) : $this->form->validUpto,
appIds: $this->form->appIds,
isUnlimited: $this->assignForm->isUnlimited
));
$this->showCreateModal = false;
$this->form->reset();

View File

@ -132,7 +132,7 @@ public function openEditAppModal(int $assignmentId): void
if ($assignment) {
$this->assignForm->appIds = [$assignment['appId'] ?? $assignment['id']];
$this->assignForm->validUpto = $assignment['duration'];
$this->assignForm->isUnlimited = $assignment['isUnlimited'];
$this->editingAppName = $assignment['appName'];
}
@ -178,6 +178,7 @@ public function assignApp(): void
roleId: $this->roleId,
validUpto: $this->assignForm->isUnlimited ? now()->addYears(15) : $this->assignForm->validUpto,
appIds: $this->assignForm->appIds,
isUnlimited: $this->assignForm->isUnlimited
));
$this->assignForm->reset();
$this->showAddAppModal = false;
@ -198,6 +199,7 @@ public function updateApp(): void
roleId: $this->roleId,
validUpto: $this->assignForm->isUnlimited ? now()->addYears(15) : $this->assignForm->validUpto,
appIds: $this->assignForm->appIds,
isUnlimited: $this->assignForm->isUnlimited
));
$this->assignForm->reset();
@ -351,7 +353,7 @@ class="btn-sm">
@endscope
@scope('cell_duration', $row)
{{ Carbon::parse($row['duration'])->diffForHumans() }}
{{ $row['isUnlimited'] ? "Unlimited" : Carbon::parse($row['duration'])->diffForHumans() }}
@endscope
@scope('actions', $row)

View File

@ -165,17 +165,6 @@ class="w-3.5 h-3.5 inline animate-pulse text-amber-600 dark:text-amber-400"/>
class="flex flex-col gap-3">
<div class="flex items-center justify-between">
<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">
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">
@ -218,17 +207,6 @@ class="btn-primary flex-1"
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">
@ -287,49 +265,6 @@ class="btn-sm btn-circle btn-soft btn-warning"
/>
@endif
@if($service->provider_slug === 'azure-fd')
@if($this->isEntraConnected)
<x-mary-button
external
icon="lucide.external-link"
:link="route('entra.open')"
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('entra.connect')"
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"
class="btn-sm btn-circle btn-soft btn-primary"
tooltip="Launch Service"
/>
@endif
</div>
</div>
</div>

View File

@ -9,10 +9,10 @@
RoleAndAppsPermissionEnum,
UserPermissionEnum};
use App\Http\Controllers\{EntraController, KekaController};
use App\Http\Controllers\KekaOutlookController;
use App\Http\Controllers\{ResolveDashboardRouteController, SamlIdpController};
use Illuminate\Support\Facades\Route;
use Spatie\Permission\Middleware\{PermissionMiddleware, RoleMiddleware};
use App\Http\Controllers\KekaOutlookController;
Route::redirect('/', '/dashboard')->name('home');
@ -96,7 +96,6 @@
// Disconnect / revoke saved token
Route::delete('disconnect', [EntraController::class, 'disconnect'])->name('disconnect');
});
Route::middleware(['auth', 'verified'])->prefix('keka')->name('keka.')->group(function (): void {