From dfb47f7d19b8504ba39becdc07cda41e2bebae76 Mon Sep 17 00:00:00 2001 From: = Date: Mon, 15 Jun 2026 12:59:30 +0000 Subject: [PATCH] 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. --- app/Data/Role/AssignAppsToRoleData.php | 3 +- app/Data/Role/RoleAppData.php | 1 + .../Controllers/KekaOutlookController.php | 12 ++-- app/Models/AppRole.php | 9 ++- app/Models/User.php | 36 ++++++---- app/Services/AppRoleService.php | 6 +- app/Services/KekaOutlookService.php | 69 ++++++++++--------- config/app.php | 6 +- config/microsoft.php | 12 ++-- ...2543_add_keka_ms_fields_to_users_table.php | 10 +-- ...5836_add_keka_connected_to_users_table.php | 10 +-- ...093457_add_unlimited_flag_in_app_roles.php | 24 +++++++ .../roles-and-apps/⚡index.blade.php | 6 +- .../roles-and-apps/⚡show.blade.php | 6 +- .../views/pages/dashboards/⚡user.blade.php | 65 ----------------- routes/web.php | 3 +- 16 files changed, 133 insertions(+), 145 deletions(-) create mode 100644 database/migrations/2026_06_15_093457_add_unlimited_flag_in_app_roles.php diff --git a/app/Data/Role/AssignAppsToRoleData.php b/app/Data/Role/AssignAppsToRoleData.php index 32f0b22..83bef85 100644 --- a/app/Data/Role/AssignAppsToRoleData.php +++ b/app/Data/Role/AssignAppsToRoleData.php @@ -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, ) {} } diff --git a/app/Data/Role/RoleAppData.php b/app/Data/Role/RoleAppData.php index 2e76aa0..a747a57 100644 --- a/app/Data/Role/RoleAppData.php +++ b/app/Data/Role/RoleAppData.php @@ -14,6 +14,7 @@ public function __construct( public int $appId, public string $appName, public string $duration, + public bool $isUnlimited = false, ) {} /** diff --git a/app/Http/Controllers/KekaOutlookController.php b/app/Http/Controllers/KekaOutlookController.php index d542684..c89d446 100644 --- a/app/Http/Controllers/KekaOutlookController.php +++ b/app/Http/Controllers/KekaOutlookController.php @@ -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 @@ -90,4 +90,4 @@ public function disconnect(Request $request): RedirectResponse return redirect()->route('dashboard') ->with('success', 'Keka has been disconnected.'); } -} \ No newline at end of file +} diff --git a/app/Models/AppRole.php b/app/Models/AppRole.php index a0f5368..eb4afde 100644 --- a/app/Models/AppRole.php +++ b/app/Models/AppRole.php @@ -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', + ]; + } } diff --git a/app/Models/User.php b/app/Models/User.php index c7935cc..3a9505a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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 + * 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 */ - 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', + ]; } } diff --git a/app/Services/AppRoleService.php b/app/Services/AppRoleService.php index 3735daa..590420d 100644 --- a/app/Services/AppRoleService.php +++ b/app/Services/AppRoleService.php @@ -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, ); diff --git a/app/Services/KekaOutlookService.php b/app/Services/KekaOutlookService.php index b36d8d3..8b4b812 100644 --- a/app/Services/KekaOutlookService.php +++ b/app/Services/KekaOutlookService.php @@ -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(); } -} \ No newline at end of file +} diff --git a/config/app.php b/config/app.php index ab8c7f1..0dc5f76 100644 --- a/config/app.php +++ b/config/app.php @@ -125,12 +125,12 @@ '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. 'keka_website_url' => env('KEKA_WEBSITE_URL', 'https://app.keka.com'), - + 'keka_scopes' => [ 'openid', 'profile', diff --git a/config/microsoft.php b/config/microsoft.php index 4a78e2d..3396bfe 100644 --- a/config/microsoft.php +++ b/config/microsoft.php @@ -1,17 +1,19 @@ 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', @@ -23,4 +25,4 @@ 'Mail.ReadWrite', 'Mail.Send', ], -]; \ No newline at end of file +]; diff --git a/database/migrations/2026_06_12_122543_add_keka_ms_fields_to_users_table.php b/database/migrations/2026_06_12_122543_add_keka_ms_fields_to_users_table.php index 962f0cd..9d61aa5 100644 --- a/database/migrations/2026_06_12_122543_add_keka_ms_fields_to_users_table.php +++ b/database/migrations/2026_06_12_122543_add_keka_ms_fields_to_users_table.php @@ -1,14 +1,16 @@ 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', @@ -27,4 +29,4 @@ public function down(): void ]); }); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2026_06_12_125836_add_keka_connected_to_users_table.php b/database/migrations/2026_06_12_125836_add_keka_connected_to_users_table.php index 0f50901..96c02e3 100644 --- a/database/migrations/2026_06_12_125836_add_keka_connected_to_users_table.php +++ b/database/migrations/2026_06_12_125836_add_keka_connected_to_users_table.php @@ -1,22 +1,24 @@ 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'); }); } -}; \ No newline at end of file +}; diff --git a/database/migrations/2026_06_15_093457_add_unlimited_flag_in_app_roles.php b/database/migrations/2026_06_15_093457_add_unlimited_flag_in_app_roles.php new file mode 100644 index 0000000..b0aa8f0 --- /dev/null +++ b/database/migrations/2026_06_15_093457_add_unlimited_flag_in_app_roles.php @@ -0,0 +1,24 @@ +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'); + }); + } +}; diff --git a/resources/views/pages/access-manager/roles-and-apps/⚡index.blade.php b/resources/views/pages/access-manager/roles-and-apps/⚡index.blade.php index 38e612e..8d609fe 100644 --- a/resources/views/pages/access-manager/roles-and-apps/⚡index.blade.php +++ b/resources/views/pages/access-manager/roles-and-apps/⚡index.blade.php @@ -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(); diff --git a/resources/views/pages/access-manager/roles-and-apps/⚡show.blade.php b/resources/views/pages/access-manager/roles-and-apps/⚡show.blade.php index e9f3eab..dda2346 100644 --- a/resources/views/pages/access-manager/roles-and-apps/⚡show.blade.php +++ b/resources/views/pages/access-manager/roles-and-apps/⚡show.blade.php @@ -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) diff --git a/resources/views/pages/dashboards/⚡user.blade.php b/resources/views/pages/dashboards/⚡user.blade.php index c2c157a..9bf2694 100644 --- a/resources/views/pages/dashboards/⚡user.blade.php +++ b/resources/views/pages/dashboards/⚡user.blade.php @@ -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">
Microsoft Integration - @if($this->isEntraConnected) - - Connected - - @else - - Not Connected - - @endif
@@ -218,17 +207,6 @@ class="btn-primary flex-1" class="flex flex-col gap-3">
Keka Integration - @if($this->isKekaConnected) - - Connected - - @else - - Not Connected - - @endif
@@ -287,49 +265,6 @@ class="btn-sm btn-circle btn-soft btn-warning" /> @endif - @if($service->provider_slug === 'azure-fd') - @if($this->isEntraConnected) - - @else - - @endif - @elseif($service->provider_slug === 'keka-hr') - @if($this->isKekaConnected) - - @else - - @endif - @else - - @endif
diff --git a/routes/web.php b/routes/web.php index 5cce6e7..aa134bb 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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 {