diff --git a/app/Http/Controllers/AdminController.php b/app/Http/Controllers/AdminController.php index 09b765f..fc26961 100644 --- a/app/Http/Controllers/AdminController.php +++ b/app/Http/Controllers/AdminController.php @@ -4,7 +4,8 @@ use App\Models\User; use App\Models\App; -use App\Models\AppRestriction; +use App\Models\Role; +use App\Models\UserAppOverride; use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; @@ -17,12 +18,17 @@ public function index() { $admin = Auth::user(); - // Fetch all users to display on the dashboard for administration - $users = User::orderBy('created_at', 'desc')->get(); + // Fetch all users with their roles and overrides + $users = User::with(['roles', 'overrides.app'])->orderBy('created_at', 'desc')->get(); - // Fetch all apps and active restrictions + // Fetch all apps $apps = App::orderBy('name', 'asc')->get(); - $restrictions = AppRestriction::with('app')->orderBy('created_at', 'desc')->get(); + + // Fetch all roles + $roles = Role::with('apps')->orderBy('name', 'asc')->get(); + + // Fetch all overrides + $overrides = UserAppOverride::with(['user', 'app'])->orderBy('created_at', 'desc')->get(); // Calculate statistics $stats = [ @@ -30,9 +36,10 @@ public function index() 'admins_count' => User::where('role', 'admin')->count(), 'blocked_count' => User::where('is_blocked', true)->count(), 'apps_count' => App::count(), + 'roles_count' => Role::count(), ]; - return view('admin.dashboard', compact('admin', 'users', 'apps', 'restrictions', 'stats')); + return view('admin.dashboard', compact('admin', 'users', 'apps', 'roles', 'overrides', 'stats')); } /** @@ -73,41 +80,121 @@ public function destroyApp($id) } /** - * Restrict an app for a specific user email. + * Store a new role. */ - public function storeRestriction(Request $request) + public function storeRole(Request $request) { $request->validate([ - 'email' => 'required|email|max:255', - 'app_id' => 'required|exists:apps,id', + 'name' => 'required|string|max:255|unique:roles,name', + 'description' => 'nullable|string', + 'apps' => 'nullable|array', + 'apps.*' => 'exists:apps,id', ]); - // Prevent duplicate restrictions - $exists = AppRestriction::where('email', $request->email) + $role = Role::create([ + 'name' => $request->name, + 'description' => $request->description, + ]); + + if ($request->has('apps')) { + $role->apps()->sync($request->apps); + } + + return redirect()->route('admin.dashboard')->with('success', 'Role created successfully.'); + } + + /** + * Update an existing role. + */ + public function updateRole(Request $request, $id) + { + $role = Role::findOrFail($id); + + $request->validate([ + 'name' => 'required|string|max:255|unique:roles,name,' . $role->id, + 'description' => 'nullable|string', + 'apps' => 'nullable|array', + 'apps.*' => 'exists:apps,id', + ]); + + $role->update([ + 'name' => $request->name, + 'description' => $request->description, + ]); + + $role->apps()->sync($request->apps ?? []); + + return redirect()->route('admin.dashboard')->with('success', 'Role updated successfully.'); + } + + /** + * Delete a role. + */ + public function destroyRole($id) + { + $role = Role::findOrFail($id); + $role->delete(); + + return redirect()->route('admin.dashboard')->with('success', 'Role deleted successfully.'); + } + + /** + * Update user roles. + */ + public function updateUserRoles(Request $request, $id) + { + $user = User::findOrFail($id); + + $request->validate([ + 'roles' => 'nullable|array', + 'roles.*' => 'exists:roles,id', + ]); + + $user->roles()->sync($request->roles ?? []); + + return redirect()->route('admin.dashboard')->with('success', 'User roles updated successfully.'); + } + + /** + * Add a user-specific app override. + */ + public function storeUserOverride(Request $request) + { + $request->validate([ + 'user_id' => 'required|exists:users,id', + 'app_id' => 'required|exists:apps,id', + 'type' => 'required|in:allow,deny', + ]); + + $user = User::findOrFail($request->user_id); + + // Prevent duplicate overrides + $exists = UserAppOverride::where('user_id', $user->id) ->where('app_id', $request->app_id) ->exists(); if ($exists) { - return redirect()->route('admin.dashboard')->with('error', 'This user is already restricted from accessing this service.'); + return redirect()->route('admin.dashboard')->with('error', 'An override for this service and user already exists.'); } - AppRestriction::create([ - 'email' => $request->email, + UserAppOverride::create([ + 'user_id' => $user->id, 'app_id' => $request->app_id, + 'type' => $request->type, ]); - return redirect()->route('admin.dashboard')->with('success', 'Access restriction created successfully.'); + return redirect()->route('admin.dashboard')->with('success', 'User service override registered successfully.'); } /** - * Remove an app restriction. + * Remove a user-specific override. */ - public function destroyRestriction($id) + public function destroyUserOverride($id) { - $restriction = AppRestriction::findOrFail($id); - $restriction->delete(); + $override = UserAppOverride::findOrFail($id); + $override->delete(); - return redirect()->route('admin.dashboard')->with('success', 'Access restriction removed successfully.'); + return redirect()->route('admin.dashboard')->with('success', 'User service override removed successfully.'); } /** diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 930071c..2d060d9 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -5,6 +5,8 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Auth; +use Laravel\Socialite\Facades\Socialite; + class DashboardController extends Controller { /** @@ -14,14 +16,86 @@ public function index() { $user = Auth::user(); - // Get all apps that are NOT restricted for this user's email - $services = \App\Models\App::whereNotExists(function ($query) use ($user) { - $query->select(\DB::raw(1)) - ->from('app_restrictions') - ->whereColumn('app_restrictions.app_id', 'apps.id') - ->where('app_restrictions.email', $user->email); - })->orderBy('name', 'asc')->get(); + // Get authorized apps for this user + $services = $user->authorizedApps(); return view('dashboard', compact('user', 'services')); } + + /** + * Launch a service app using SSO. + */ + public function launchSSO($id) + { + $user = Auth::user(); + $app = \App\Models\App::findOrFail($id); + + // Check if user is authorized to access this app + $authorizedApps = $user->authorizedApps(); + if (!$authorizedApps->contains('id', $app->id)) { + return redirect()->route('dashboard')->with('error', 'Access denied. You do not have permission to access this service.'); + } + + // Check if it is a Microsoft service + $isMicrosoft = str_contains($app->url, 'microsoft.com') || + str_contains($app->url, 'office.com') || + str_contains($app->url, 'office365.com') || + str_contains($app->url, 'outlook.com') || + $app->icon_svg === 'outlook' || + $app->icon_svg === 'teams'; + + if ($isMicrosoft && $user->microsoft_id) { + // Save target app URL in session so callback knows where to redirect + session(['sso_target_url' => $app->url]); + + try { + // Redirect to Microsoft OAuth authorize page with login_hint and custom callback + return Socialite::driver('microsoft') + ->redirectUrl(route('sso.callback')) + ->with(['login_hint' => $user->email]) + ->redirect(); + } catch (\Exception $e) { + \Illuminate\Support\Facades\Log::error('Microsoft SSO launch failed', [ + 'message' => $e->getMessage() + ]); + // Fallback: direct redirect + return redirect($app->url); + } + } + + // For non-Microsoft or non-Microsoft-linked users, redirect directly + return redirect($app->url); + } + + /** + * Handle the callback for Microsoft SSO. + */ + public function handleSSOCallback(Request $request) + { + $user = Auth::user(); + + try { + $microsoftUser = Socialite::driver('microsoft') + ->redirectUrl(route('sso.callback')) + ->user(); + + if ($microsoftUser && $user) { + // Update tokens in db + $user->update([ + 'microsoft_token' => $microsoftUser->token, + 'microsoft_refresh_token' => $microsoftUser->refreshToken ?? $user->microsoft_refresh_token, + ]); + } + } catch (\Exception $e) { + \Illuminate\Support\Facades\Log::error('Microsoft SSO callback failed', [ + 'message' => $e->getMessage() + ]); + // If callback fails but we have target URL, we can still try to redirect + } + + $targetUrl = session('sso_target_url', 'https://outlook.office.com/mail/'); + session()->forget('sso_target_url'); + + return redirect($targetUrl); + } } diff --git a/app/Http/Middleware/EnsureRole.php b/app/Http/Middleware/EnsureRole.php index 0d81c56..47892a7 100644 --- a/app/Http/Middleware/EnsureRole.php +++ b/app/Http/Middleware/EnsureRole.php @@ -28,6 +28,9 @@ public function handle(Request $request, Closure $next, string $role): Response if (auth()->user()->role !== $role) { if (auth()->user()->role === 'admin') { + if ($role === 'user') { + return $next($request); + } return redirect()->route('admin.dashboard'); } return redirect()->route('dashboard')->with('error', 'Unauthorized access.'); diff --git a/app/Models/Role.php b/app/Models/Role.php new file mode 100644 index 0000000..ed66bd4 --- /dev/null +++ b/app/Models/Role.php @@ -0,0 +1,29 @@ +belongsToMany(User::class, 'role_user'); + } + + /** + * Get the apps/services assigned to this role. + */ + public function apps() + { + return $this->belongsToMany(App::class, 'app_role', 'role_id', 'app_id'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 02ace5d..5dfd31f 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -25,6 +25,53 @@ public function isAdmin(): bool return $this->role === 'admin'; } + /** + * Get the roles assigned to this user. + */ + public function roles() + { + return $this->belongsToMany(Role::class, 'role_user'); + } + + /** + * Get the app overrides (allow/deny) for this user. + */ + public function overrides() + { + return $this->hasMany(UserAppOverride::class); + } + + /** + * Get the authorized apps for this user, computed from roles and overrides. + */ + public function authorizedApps() + { + if ($this->isAdmin()) { + return App::orderBy('name', 'asc')->get(); + } + + $roleIds = $this->roles()->pluck('roles.id')->toArray(); + + // Get apps associated with user's roles + $roleAppIds = \DB::table('app_role') + ->whereIn('role_id', $roleIds) + ->pluck('app_id') + ->toArray(); + + // Get user overrides + $overrides = $this->overrides()->get(); + $allowedAppIds = $overrides->where('type', 'allow')->pluck('app_id')->toArray(); + $deniedAppIds = $overrides->where('type', 'deny')->pluck('app_id')->toArray(); + + // Compute final allowed app IDs: (Role Apps + Allowed Overrides) - Denied Overrides + $finalAppIds = array_diff( + array_unique(array_merge($roleAppIds, $allowedAppIds)), + $deniedAppIds + ); + + return App::whereIn('id', $finalAppIds)->orderBy('name', 'asc')->get(); + } + /** * Get the attributes that should be cast. * diff --git a/app/Models/UserAppOverride.php b/app/Models/UserAppOverride.php new file mode 100644 index 0000000..c93d31d --- /dev/null +++ b/app/Models/UserAppOverride.php @@ -0,0 +1,29 @@ +belongsTo(User::class); + } + + /** + * Get the app/service being overridden. + */ + public function app() + { + return $this->belongsTo(App::class); + } +} diff --git a/database/migrations/2026_06_24_135300_create_roles_and_overrides_tables.php b/database/migrations/2026_06_24_135300_create_roles_and_overrides_tables.php new file mode 100644 index 0000000..5134d67 --- /dev/null +++ b/database/migrations/2026_06_24_135300_create_roles_and_overrides_tables.php @@ -0,0 +1,73 @@ +id(); + $table->string('name')->unique(); + $table->text('description')->nullable(); + $table->timestamps(); + }); + + // 2. Create role_user pivot table for user roles (many-to-many) + Schema::create('role_user', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained('users')->onDelete('cascade'); + $table->foreignId('role_id')->constrained('roles')->onDelete('cascade'); + $table->unique(['user_id', 'role_id']); + $table->timestamps(); + }); + + // 3. Create app_role pivot table for role services (many-to-many) + Schema::create('app_role', function (Blueprint $table) { + $table->id(); + $table->foreignId('role_id')->constrained('roles')->onDelete('cascade'); + $table->foreignId('app_id')->constrained('apps')->onDelete('cascade'); + $table->unique(['role_id', 'app_id']); + $table->timestamps(); + }); + + // 4. Create user_app_overrides table (for user-specific inclusions/exclusions) + Schema::create('user_app_overrides', function (Blueprint $table) { + $table->id(); + $table->foreignId('user_id')->constrained('users')->onDelete('cascade'); + $table->foreignId('app_id')->constrained('apps')->onDelete('cascade'); + $table->enum('type', ['allow', 'deny']); // allow = add extra service, deny = revoke service + $table->unique(['user_id', 'app_id']); + $table->timestamps(); + }); + + // 5. Drop old app_restrictions table if it exists + Schema::dropIfExists('app_restrictions'); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('user_app_overrides'); + Schema::dropIfExists('app_role'); + Schema::dropIfExists('role_user'); + Schema::dropIfExists('roles'); + + // Recreate the old app_restrictions table if we roll back + Schema::create('app_restrictions', function (Blueprint $table) { + $table->id(); + $table->string('email'); + $table->foreignId('app_id')->constrained('apps')->onDelete('cascade'); + $table->unique(['email', 'app_id']); + $table->timestamps(); + }); + } +}; diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index 2213a5b..e214217 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -3,9 +3,11 @@ namespace Database\Seeders; use App\Models\User; +use App\Models\App; +use App\Models\Role; +use App\Models\UserAppOverride; use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; - use Illuminate\Support\Facades\Hash; class DatabaseSeeder extends Seeder @@ -17,8 +19,8 @@ class DatabaseSeeder extends Seeder */ public function run(): void { - // Seed default administrator - User::updateOrCreate( + // 1. Seed default administrator + $admin = User::updateOrCreate( ['email' => 'admin@company.com'], [ 'name' => 'System Administrator', @@ -27,18 +29,8 @@ public function run(): void ] ); - // Seed default standard user (without password since users use Microsoft OAuth) - User::updateOrCreate( - ['email' => 'user@company.com'], - [ - 'name' => 'John Doe User', - 'role' => 'user', - 'microsoft_id' => 'mock-microsoft-id-123', - ] - ); - - // Seed default apps - \App\Models\App::updateOrCreate( + // 2. Seed default apps + $outlook = App::updateOrCreate( ['name' => 'Outlook'], [ 'url' => 'https://outlook.office.com/mail/', @@ -49,7 +41,7 @@ public function run(): void ] ); - \App\Models\App::updateOrCreate( + $teams = App::updateOrCreate( ['name' => 'Microsoft Teams'], [ 'url' => 'https://teams.microsoft.com/v2/', @@ -60,7 +52,7 @@ public function run(): void ] ); - \App\Models\App::updateOrCreate( + $keka = App::updateOrCreate( ['name' => 'Keka HR'], [ 'url' => 'https://sentientgeeks.keka.com/', @@ -70,5 +62,92 @@ public function run(): void 'tag' => 'HR Portal' ] ); + + // 3. Seed default roles + $pmRole = Role::updateOrCreate( + ['name' => 'Project Manager'], + ['description' => 'Oversees projects, budgets, and milestones.'] + ); + + $leadRole = Role::updateOrCreate( + ['name' => 'Team Lead'], + ['description' => 'Manages dev teams and assigns work items.'] + ); + + $salesRole = Role::updateOrCreate( + ['name' => 'Sales Team'], + ['description' => 'Drives client acquisition and manages CRM leads.'] + ); + + $devRole = Role::updateOrCreate( + ['name' => 'Developer'], + ['description' => 'Writes code and builds features.'] + ); + + $internRole = Role::updateOrCreate( + ['name' => 'Intern'], + ['description' => 'Learning and assisting on project modules.'] + ); + + $hrRole = Role::updateOrCreate( + ['name' => 'HR Manager'], + ['description' => 'Handles recruitment, payroll, and employee relations.'] + ); + + // 4. Assign apps/services to specific roles + // HR has access to Outlook, Teams, and Keka + $hrRole->apps()->sync([$outlook->id, $teams->id, $keka->id]); + + // Developers have access to Outlook and Teams + $devRole->apps()->sync([$outlook->id, $teams->id]); + + // Team Leads have access to Outlook and Teams + $leadRole->apps()->sync([$outlook->id, $teams->id]); + + // Project Managers have access to Outlook and Teams + $pmRole->apps()->sync([$outlook->id, $teams->id]); + + // Sales Team has access to Teams + $salesRole->apps()->sync([$teams->id]); + + // Interns have access to Teams (no Outlook, no Keka) + $internRole->apps()->sync([$teams->id]); + + // 5. Seed default standard users and assign roles + // John Doe (Developer & Team Lead) - Test multiple roles + $user1 = User::updateOrCreate( + ['email' => 'user@company.com'], + [ + 'name' => 'John Doe User', + 'role' => 'user', + 'microsoft_id' => 'mock-microsoft-id-123', + ] + ); + $user1->roles()->sync([$devRole->id, $leadRole->id]); + + // Jane Smith (HR Manager) + $user2 = User::updateOrCreate( + ['email' => 'hr@company.com'], + [ + 'name' => 'Jane Smith HR', + 'role' => 'user', + 'microsoft_id' => 'mock-microsoft-id-456', + ] + ); + $user2->roles()->sync([$hrRole->id]); + + // 6. Seed user-specific overrides for testing + // John Doe (Developer & Team Lead - has Outlook and Teams via roles) + // Scenario A: Revoke Outlook for John Doe (even though Developer role grants it) + UserAppOverride::updateOrCreate( + ['user_id' => $user1->id, 'app_id' => $outlook->id], + ['type' => 'deny'] + ); + + // Scenario B: Add Keka HR as an extra service for John Doe (irrespective of Developer role) + UserAppOverride::updateOrCreate( + ['user_id' => $user1->id, 'app_id' => $keka->id], + ['type' => 'allow'] + ); } } diff --git a/resources/views/admin/dashboard.blade.php b/resources/views/admin/dashboard.blade.php index 19c985e..f09f86c 100644 --- a/resources/views/admin/dashboard.blade.php +++ b/resources/views/admin/dashboard.blade.php @@ -368,6 +368,113 @@ padding: 20px 15px; } } + + /* Modal Backdrop */ + .admin-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(11, 15, 25, 0.85); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + opacity: 0; + pointer-events: none; + transition: opacity 0.3s ease; + } + .admin-modal.active { + opacity: 1; + pointer-events: all; + } + .admin-modal-content { + background-color: rgba(25, 30, 45, 0.95); + border: 1px solid var(--card-border); + border-radius: 20px; + padding: 30px; + width: 90%; + max-width: 500px; + box-shadow: 0 20px 50px rgba(0, 0, 0, 0.4); + transform: scale(0.9); + transition: transform 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.1); + } + .admin-modal.active .admin-modal-content { + transform: scale(1); + } + .modal-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 20px; + border-bottom: 1px solid var(--card-border); + padding-bottom: 12px; + } + .modal-title { + font-family: 'Outfit', sans-serif; + font-size: 1.25rem; + font-weight: 600; + color: #a5b4fc; + } + .modal-close { + background: transparent; + border: none; + color: var(--text-muted); + font-size: 1.5rem; + cursor: pointer; + line-height: 1; + } + .modal-close:hover { + color: white; + } + .modal-footer { + display: flex; + justify-content: flex-end; + gap: 12px; + margin-top: 24px; + border-top: 1px solid var(--card-border); + padding-top: 16px; + } + .btn-cancel { + background: transparent; + border: 1px solid var(--card-border); + color: var(--text-muted); + padding: 8px 16px; + border-radius: 8px; + cursor: pointer; + font-size: 0.85rem; + transition: all 0.2s ease; + } + .btn-cancel:hover { + color: white; + border-color: rgba(255,255,255,0.2); + } + .checkbox-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 10px; + max-height: 150px; + overflow-y: auto; + background: rgba(0,0,0,0.2); + border: 1px solid var(--card-border); + border-radius: 8px; + padding: 12px; + margin-top: 6px; + } + .checkbox-item { + display: flex; + align-items: flex-start; + gap: 8px; + font-size: 0.85rem; + cursor: pointer; + user-select: none; + } + .checkbox-item input { + margin-top: 3px; + }
@@ -436,6 +543,14 @@ + +