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'
@ -49,6 +55,22 @@ public function initials(): string
->implode('');
}
/**
* Get the OAuth token for a specific provider.
*/
public function oauthToken(string $provider): ?OauthToken
{
return $this->oauthTokens()->forProvider($provider)->first();
}
/**
* @return HasMany<OauthToken, $this>
*/
public function oauthTokens(): HasMany
{
return $this->hasMany(OauthToken::class);
}
/**
* Get the attributes that should be cast.
*
@ -62,20 +84,4 @@ protected function casts(): array
'keka_ms_token_expires_at' => 'datetime',
];
}
/**
* @return HasMany<OauthToken, $this>
*/
public function oauthTokens(): HasMany
{
return $this->hasMany(OauthToken::class);
}
/**
* Get the OAuth token for a specific provider.
*/
public function oauthToken(string $provider): ?OauthToken
{
return $this->oauthTokens()->forProvider($provider)->first();
}
}

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.
@ -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,10 +170,18 @@ 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
{
@ -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.')
);
}

View File

@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
return [
'client_id' => env('MICROSOFT_GRAPH_CLIENT_ID'),
'client_secret' => env('MICROSOFT_GRAPH_CLIENT_SECRET'),
@ -11,7 +13,7 @@
// 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_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 {