Compare commits

..

2 Commits

Author SHA1 Message Date
a2e3ad20e7 fix admin login 2026-06-24 17:36:36 +05:30
7f5e0fadf7 role feature implement 2026-06-24 14:21:01 +05:30
14 changed files with 960 additions and 109 deletions

View File

@ -4,7 +4,8 @@
use App\Models\User; use App\Models\User;
use App\Models\App; use App\Models\App;
use App\Models\AppRestriction; use App\Models\Role;
use App\Models\UserAppOverride;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -17,12 +18,17 @@ public function index()
{ {
$admin = Auth::user(); $admin = Auth::user();
// Fetch all users to display on the dashboard for administration // Fetch all users with their roles and overrides
$users = User::orderBy('created_at', 'desc')->get(); $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(); $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 // Calculate statistics
$stats = [ $stats = [
@ -30,9 +36,10 @@ public function index()
'admins_count' => User::where('role', 'admin')->count(), 'admins_count' => User::where('role', 'admin')->count(),
'blocked_count' => User::where('is_blocked', true)->count(), 'blocked_count' => User::where('is_blocked', true)->count(),
'apps_count' => App::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([ $request->validate([
'email' => 'required|email|max:255', 'name' => 'required|string|max:255|unique:roles,name',
'app_id' => 'required|exists:apps,id', 'description' => 'nullable|string',
'apps' => 'nullable|array',
'apps.*' => 'exists:apps,id',
]); ]);
// Prevent duplicate restrictions $role = Role::create([
$exists = AppRestriction::where('email', $request->email) '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) ->where('app_id', $request->app_id)
->exists(); ->exists();
if ($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([ UserAppOverride::create([
'email' => $request->email, 'user_id' => $user->id,
'app_id' => $request->app_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); $override = UserAppOverride::findOrFail($id);
$restriction->delete(); $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.');
} }
/** /**

View File

@ -5,6 +5,8 @@
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
use Laravel\Socialite\Facades\Socialite;
class DashboardController extends Controller class DashboardController extends Controller
{ {
/** /**
@ -14,14 +16,94 @@ public function index()
{ {
$user = Auth::user(); $user = Auth::user();
// Get all apps that are NOT restricted for this user's email // Get authorized apps for this user
$services = \App\Models\App::whereNotExists(function ($query) use ($user) { $services = $user->authorizedApps();
$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();
return view('dashboard', compact('user', 'services')); 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 {
// Determine domain hint based on user's email domain to skip account type prompt
$domainHint = (str_ends_with($user->email, '.onmicrosoft.com') || (str_contains($user->email, '@') && !str_ends_with($user->email, 'outlook.com') && !str_ends_with($user->email, 'hotmail.com') && !str_ends_with($user->email, 'live.com')))
? 'organizations'
: 'consumers';
// Redirect to Microsoft OAuth authorize page with login_hint, domain_hint and custom callback
return Socialite::driver('microsoft')
->redirectUrl(route('sso.callback'))
->with([
'login_hint' => $user->email,
'domain_hint' => $domainHint
])
->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);
}
} }

View File

@ -28,6 +28,9 @@ public function handle(Request $request, Closure $next, string $role): Response
if (auth()->user()->role !== $role) { if (auth()->user()->role !== $role) {
if (auth()->user()->role === 'admin') { if (auth()->user()->role === 'admin') {
if ($role === 'user') {
return $next($request);
}
return redirect()->route('admin.dashboard'); return redirect()->route('admin.dashboard');
} }
return redirect()->route('dashboard')->with('error', 'Unauthorized access.'); return redirect()->route('dashboard')->with('error', 'Unauthorized access.');

29
app/Models/Role.php Normal file
View File

@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Attributes\Fillable;
#[Fillable(['name', 'description'])]
class Role extends Model
{
use HasFactory;
/**
* Get the users assigned to this role.
*/
public function users()
{
return $this->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');
}
}

View File

@ -25,6 +25,53 @@ public function isAdmin(): bool
return $this->role === 'admin'; 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. * Get the attributes that should be cast.
* *

View File

@ -0,0 +1,29 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Attributes\Fillable;
#[Fillable(['user_id', 'app_id', 'type'])]
class UserAppOverride extends Model
{
use HasFactory;
/**
* Get the user this override belongs to.
*/
public function user()
{
return $this->belongsTo(User::class);
}
/**
* Get the app/service being overridden.
*/
public function app()
{
return $this->belongsTo(App::class);
}
}

View File

@ -0,0 +1,73 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
// 1. Create roles table
Schema::create('roles', function (Blueprint $table) {
$table->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();
});
}
};

View File

@ -3,9 +3,11 @@
namespace Database\Seeders; namespace Database\Seeders;
use App\Models\User; 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\Console\Seeds\WithoutModelEvents;
use Illuminate\Database\Seeder; use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
class DatabaseSeeder extends Seeder class DatabaseSeeder extends Seeder
@ -17,8 +19,8 @@ class DatabaseSeeder extends Seeder
*/ */
public function run(): void public function run(): void
{ {
// Seed default administrator // 1. Seed default administrator
User::updateOrCreate( $admin = User::updateOrCreate(
['email' => 'admin@company.com'], ['email' => 'admin@company.com'],
[ [
'name' => 'System Administrator', 'name' => 'System Administrator',
@ -27,18 +29,8 @@ public function run(): void
] ]
); );
// Seed default standard user (without password since users use Microsoft OAuth) // 2. Seed default apps
User::updateOrCreate( $outlook = App::updateOrCreate(
['email' => 'user@company.com'],
[
'name' => 'John Doe User',
'role' => 'user',
'microsoft_id' => 'mock-microsoft-id-123',
]
);
// Seed default apps
\App\Models\App::updateOrCreate(
['name' => 'Outlook'], ['name' => 'Outlook'],
[ [
'url' => 'https://outlook.office.com/mail/', 'url' => 'https://outlook.office.com/mail/',
@ -49,7 +41,7 @@ public function run(): void
] ]
); );
\App\Models\App::updateOrCreate( $teams = App::updateOrCreate(
['name' => 'Microsoft Teams'], ['name' => 'Microsoft Teams'],
[ [
'url' => 'https://teams.microsoft.com/v2/', 'url' => 'https://teams.microsoft.com/v2/',
@ -60,7 +52,7 @@ public function run(): void
] ]
); );
\App\Models\App::updateOrCreate( $keka = App::updateOrCreate(
['name' => 'Keka HR'], ['name' => 'Keka HR'],
[ [
'url' => 'https://sentientgeeks.keka.com/', 'url' => 'https://sentientgeeks.keka.com/',
@ -70,5 +62,92 @@ public function run(): void
'tag' => 'HR Portal' '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']
);
} }
} }

BIN
public/Screenshot_2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 473 KiB

View File

@ -368,6 +368,113 @@
padding: 20px 15px; 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;
}
</style> </style>
</head> </head>
<body> <body>
@ -436,6 +543,14 @@
</div> </div>
<div class="stat-icon" style="color: #fbbf24;">📦</div> <div class="stat-icon" style="color: #fbbf24;">📦</div>
</div> </div>
<div class="stat-card">
<div>
<div class="stat-label">System Roles</div>
<div class="stat-value">{{ $stats['roles_count'] }}</div>
</div>
<div class="stat-icon" style="color: #a5b4fc;">🏷️</div>
</div>
</section> </section>
<!-- Alert messages in dashboard --> <!-- Alert messages in dashboard -->
@ -453,8 +568,8 @@
</div> </div>
@endif @endif
<!-- App Management & Access Restrictions Grid --> <!-- App Management, Roles Management, & User Overrides Grid -->
<section class="management-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 20px; margin-bottom: 40px;"> <section class="management-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 20px; margin-bottom: 40px;">
<!-- App Management Card --> <!-- App Management Card -->
<div class="admin-card" style="background-color: var(--card-bg); border: 1px solid var(--card-border); border-radius: 20px; padding: 30px; backdrop-filter: blur(16px); box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);"> <div class="admin-card" style="background-color: var(--card-bg); border: 1px solid var(--card-border); border-radius: 20px; padding: 30px; backdrop-filter: blur(16px); box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);">
@ -491,7 +606,7 @@
<label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:6px; font-weight:500;">Service Description</label> <label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:6px; font-weight:500;">Service Description</label>
<input type="text" name="desc" placeholder="Brief description of the service..." class="form-input" style="width:100%; padding:10px; background:rgba(255,255,255,0.03); border:1px solid var(--card-border); border-radius:8px; color:white;"> <input type="text" name="desc" placeholder="Brief description of the service..." class="form-input" style="width:100%; padding:10px; background:rgba(255,255,255,0.03); border:1px solid var(--card-border); border-radius:8px; color:white;">
</div> </div>
<button type="submit" class="btn" style="background-color: var(--accent-primary); color: white; width:100%; font-size:0.85rem; border-radius:8px; padding:10px;">+ Register Service App</button> <button type="submit" class="btn" style="background-color: var(--accent-primary); color: white; width:100%; font-size:0.85rem; border-radius:8px; padding:10px; border:none; cursor:pointer; font-weight:600;">+ Register Service App</button>
</form> </form>
<!-- Registered Apps List --> <!-- Registered Apps List -->
@ -502,7 +617,6 @@
<tr> <tr>
<th>Service</th> <th>Service</th>
<th>Tag</th> <th>Tag</th>
<th>URL</th>
<th style="text-align: right;">Action</th> <th style="text-align: right;">Action</th>
</tr> </tr>
</thead> </thead>
@ -516,9 +630,8 @@
</div> </div>
</td> </td>
<td><span class="badge" style="background-color:rgba(255,255,255,0.05); color:#cbd5e1; border:1px solid rgba(255,255,255,0.1);">{{ $app->tag }}</span></td> <td><span class="badge" style="background-color:rgba(255,255,255,0.05); color:#cbd5e1; border:1px solid rgba(255,255,255,0.1);">{{ $app->tag }}</span></td>
<td><span style="color:var(--text-muted); display:inline-block; max-width:150px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">{{ $app->url }}</span></td>
<td style="text-align: right;"> <td style="text-align: right;">
<form action="{{ route('admin.apps.destroy', $app->id) }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this service? All access restrictions for it will be removed.')" style="display:inline;"> <form action="{{ route('admin.apps.destroy', $app->id) }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this service?')" style="display:inline;">
@csrf @csrf
@method('DELETE') @method('DELETE')
<button type="submit" class="action-btn" style="color:#ef4444; border-color:rgba(239,68,68,0.2); font-size:0.75rem; padding:4px 8px;">Delete</button> <button type="submit" class="action-btn" style="color:#ef4444; border-color:rgba(239,68,68,0.2); font-size:0.75rem; padding:4px 8px;">Delete</button>
@ -527,7 +640,7 @@
</tr> </tr>
@empty @empty
<tr> <tr>
<td colspan="4" style="text-align: center; color: var(--text-muted); padding: 20px;">No registered service apps.</td> <td colspan="3" style="text-align: center; color: var(--text-muted); padding: 20px;">No registered service apps.</td>
</tr> </tr>
@endforelse @endforelse
</tbody> </tbody>
@ -535,65 +648,173 @@
</div> </div>
</div> </div>
<!-- Access Restrictions Card --> <!-- Role Management Card -->
<div class="admin-card" style="background-color: var(--card-bg); border: 1px solid var(--card-border); border-radius: 20px; padding: 30px; backdrop-filter: blur(16px); box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);"> <div class="admin-card" style="background-color: var(--card-bg); border: 1px solid var(--card-border); border-radius: 20px; padding: 30px; backdrop-filter: blur(16px); box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);">
<h3 style="font-family: 'Outfit', sans-serif; font-size: 1.25rem; font-weight: 600; margin-bottom: 16px; color: #a5b4fc;">Restrict User Access</h3> <h3 style="font-family: 'Outfit', sans-serif; font-size: 1.25rem; font-weight: 600; margin-bottom: 16px; color: #a5b4fc;">Manage Roles & Services</h3>
<!-- Add Restriction Form --> <!-- Add Role Form -->
<form action="{{ route('admin.restrictions.store') }}" method="POST" style="margin-bottom: 30px;"> <form action="{{ route('admin.roles.store') }}" method="POST" style="margin-bottom: 30px;">
@csrf @csrf
<div style="margin-bottom: 12px;"> <div style="margin-bottom: 12px;">
<label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:6px; font-weight:500;">User Email Address</label> <label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:6px; font-weight:500;">Role Name</label>
<input type="email" name="email" required placeholder="user@sentientgeeks.com" class="form-input" style="width:100%; padding:10px; background:rgba(255,255,255,0.03); border:1px solid var(--card-border); border-radius:8px; color:white;"> <input type="text" name="name" required placeholder="e.g. Developer, HR" class="form-input" style="width:100%; padding:10px; background:rgba(255,255,255,0.03); border:1px solid var(--card-border); border-radius:8px; color:white;">
</div>
<div style="margin-bottom: 12px;">
<label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:6px; font-weight:500;">Role Description</label>
<input type="text" name="description" placeholder="Description of this role..." class="form-input" style="width:100%; padding:10px; background:rgba(255,255,255,0.03); border:1px solid var(--card-border); border-radius:8px; color:white;">
</div> </div>
<div style="margin-bottom: 16px;"> <div style="margin-bottom: 16px;">
<label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:6px; font-weight:500;">Restrict Service App</label> <label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:6px; font-weight:500;">Assign Services Access</label>
<select name="app_id" required class="form-input" style="width:100%; padding:10px; background:rgba(11,15,25,0.9); border:1px solid var(--card-border); border-radius:8px; color:white;"> <div class="checkbox-grid">
<option value="">-- Select App to Restrict --</option>
@foreach($apps as $app) @foreach($apps as $app)
<option value="{{ $app->id }}">{{ $app->name }}</option> <label class="checkbox-item">
<input type="checkbox" name="apps[]" value="{{ $app->id }}">
{{ $app->name }}
</label>
@endforeach @endforeach
</select> </div>
</div> </div>
<button type="submit" class="btn" style="background-color: #fbbf24; color: #0b0f19; width:100%; font-size:0.85rem; border-radius:8px; padding:10px; font-weight:700;">Restrict Access</button> <button type="submit" class="btn" style="background-color: var(--accent-primary); color: white; width:100%; font-size:0.85rem; border-radius:8px; padding:10px; border:none; cursor:pointer; font-weight:600;">+ Create Role</button>
</form> </form>
<!-- Active Restrictions List --> <!-- Registered Roles List -->
<h4 style="font-size:0.9rem; font-weight:600; margin-bottom:12px; border-top:1px solid var(--card-border); padding-top:20px; color:#9ca3af;">Active Access Restrictions</h4> <h4 style="font-size:0.9rem; font-weight:600; margin-bottom:12px; border-top:1px solid var(--card-border); padding-top:20px; color:#9ca3af;">System Application Roles</h4>
<div class="table-responsive" style="max-height: 250px; overflow-y: auto;"> <div class="table-responsive" style="max-height: 250px; overflow-y: auto;">
<table class="users-table" style="font-size:0.8rem;"> <table class="users-table" style="font-size:0.8rem;">
<thead> <thead>
<tr> <tr>
<th>User Email</th> <th>Role Name</th>
<th>Restricted App</th> <th>Assigned Services</th>
<th style="text-align: right;">Action</th> <th style="text-align: right;">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@forelse($restrictions as $res) @forelse($roles as $role)
<tr> <tr>
<td><strong style="color:#fca5a5;">{{ $res->email }}</strong></td>
<td> <td>
@if($res->app) <div>
<span style="display:flex; align-items:center; gap:6px;"> <strong>{{ $role->name }}</strong>
<div style="width:8px; height:8px; border-radius:50%; background-color:{{ $res->app->color }};"></div> @if($role->description)
{{ $res->app->name }} <div style="font-size:0.7rem; color:var(--text-muted); margin-top:2px;">{{ $role->description }}</div>
</span> @endif
@else </div>
<span style="color:#ef4444;">App Deleted</span>
@endif
</td> </td>
<td style="text-align: right;"> <td>
<form action="{{ route('admin.restrictions.destroy', $res->id) }}" method="POST" style="display:inline;"> <div style="display:flex; flex-wrap:wrap; gap:4px;">
@forelse($role->apps as $app)
<span class="badge" style="background-color:{{ $app->color }}20; color:{{ $app->color }}; border:1px solid {{ $app->color }}40; font-size:0.65rem;">{{ $app->name }}</span>
@empty
<span style="color:var(--text-muted); font-size:0.7rem;">None</span>
@endforelse
</div>
</td>
<td style="text-align: right; white-space: nowrap;">
<button class="action-btn" onclick="openEditRoleModal({{ $role->id }}, '{{ addslashes($role->name) }}', '{{ addslashes($role->description) }}', {{ json_encode($role->apps->pluck('id')) }})" style="color:var(--info-color); border-color:rgba(6,182,212,0.2); font-size:0.75rem; padding:4px 8px; margin-right:4px;">Edit</button>
<form action="{{ route('admin.roles.destroy', $role->id) }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this role?')" style="display:inline;">
@csrf @csrf
@method('DELETE') @method('DELETE')
<button type="submit" class="action-btn" style="color:#fbbf24; border-color:rgba(245,158,11,0.2); font-size:0.75rem; padding:4px 8px;">Remove</button> <button type="submit" class="action-btn" style="color:#ef4444; border-color:rgba(239,68,68,0.2); font-size:0.75rem; padding:4px 8px;">Delete</button>
</form> </form>
</td> </td>
</tr> </tr>
@empty @empty
<tr> <tr>
<td colspan="3" style="text-align: center; color: var(--text-muted); padding: 20px;">No active restrictions. All apps visible to all users.</td> <td colspan="3" style="text-align: center; color: var(--text-muted); padding: 20px;">No roles defined.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<!-- User Service Overrides Card -->
<div class="admin-card" style="background-color: var(--card-bg); border: 1px solid var(--card-border); border-radius: 20px; padding: 30px; backdrop-filter: blur(16px); box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);">
<h3 style="font-family: 'Outfit', sans-serif; font-size: 1.25rem; font-weight: 600; margin-bottom: 16px; color: #a5b4fc;">User Service Overrides</h3>
<!-- Add Override Form -->
<form action="{{ route('admin.overrides.store') }}" method="POST" style="margin-bottom: 30px;">
@csrf
<div style="margin-bottom: 12px;">
<label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:6px; font-weight:500;">Select User Account</label>
<select name="user_id" required class="form-input" style="width:100%; padding:10px; background:rgba(11,15,25,0.9); border:1px solid var(--card-border); border-radius:8px; color:white;">
<option value="">-- Choose User --</option>
@foreach($users as $usr)
@if(!$usr->isAdmin())
<option value="{{ $usr->id }}">{{ $usr->name }} ({{ $usr->email }})</option>
@endif
@endforeach
</select>
</div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 16px;">
<div>
<label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:6px; font-weight:500;">Service App</label>
<select name="app_id" required class="form-input" style="width:100%; padding:10px; background:rgba(11,15,25,0.9); border:1px solid var(--card-border); border-radius:8px; color:white;">
<option value="">-- Choose App --</option>
@foreach($apps as $app)
<option value="{{ $app->id }}">{{ $app->name }}</option>
@endforeach
</select>
</div>
<div>
<label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:6px; font-weight:500;">Override Mode</label>
<select name="type" required class="form-input" style="width:100%; padding:10px; background:rgba(11,15,25,0.9); border:1px solid var(--card-border); border-radius:8px; color:white;">
<option value="allow">ALLOW (Add extra service)</option>
<option value="deny">DENY (Revoke service)</option>
</select>
</div>
</div>
<button type="submit" class="btn" style="background-color: #fbbf24; color: #0b0f19; width:100%; font-size:0.85rem; border-radius:8px; padding:10px; font-weight:700; border:none; cursor:pointer;">Apply Override</button>
</form>
<!-- Active Overrides List -->
<h4 style="font-size:0.9rem; font-weight:600; margin-bottom:12px; border-top:1px solid var(--card-border); padding-top:20px; color:#9ca3af;">Active Service Overrides</h4>
<div class="table-responsive" style="max-height: 250px; overflow-y: auto;">
<table class="users-table" style="font-size:0.8rem;">
<thead>
<tr>
<th>User</th>
<th>App</th>
<th>Type</th>
<th style="text-align: right;">Action</th>
</tr>
</thead>
<tbody>
@forelse($overrides as $override)
<tr>
<td>
<div>
<strong>{{ $override->user->name }}</strong>
<div style="font-size:0.7rem; color:var(--text-muted);">{{ $override->user->email }}</div>
</div>
</td>
<td>
@if($override->app)
<span style="display:flex; align-items:center; gap:6px;">
<div style="width:8px; height:8px; border-radius:50%; background-color:{{ $override->app->color }};"></div>
{{ $override->app->name }}
</span>
@else
<span style="color:#ef4444;">App Deleted</span>
@endif
</td>
<td>
@if($override->type === 'allow')
<span class="badge" style="background-color:rgba(16,185,129,0.12); color:#10b981; border:1px solid rgba(16,185,129,0.3); font-size:0.65rem; padding:2px 6px;">ADDITIONAL</span>
@else
<span class="badge" style="background-color:rgba(239,68,68,0.12); color:#ef4444; border:1px solid rgba(239,68,68,0.3); font-size:0.65rem; padding:2px 6px;">REVOKED</span>
@endif
</td>
<td style="text-align: right;">
<form action="{{ route('admin.overrides.destroy', $override->id) }}" method="POST" style="display:inline;">
@csrf
@method('DELETE')
<button type="submit" class="action-btn" style="color:#ef4444; border-color:rgba(239,68,68,0.2); font-size:0.75rem; padding:4px 8px;">Remove</button>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="4" style="text-align: center; color: var(--text-muted); padding: 20px;">No custom overrides active. Services resolved by roles.</td>
</tr> </tr>
@endforelse @endforelse
</tbody> </tbody>
@ -652,7 +873,14 @@
@if($usr->role === 'admin') @if($usr->role === 'admin')
<span class="badge badge-admin">Admin</span> <span class="badge badge-admin">Admin</span>
@else @else
<span class="badge badge-user">User</span> <span class="badge badge-user">User Portal</span>
@endif
@if($usr->roles->isNotEmpty())
<div style="display:flex; flex-wrap:wrap; gap:4px; margin-top:6px;">
@foreach($usr->roles as $role)
<span class="badge" style="background-color:rgba(99,102,241,0.1); color:#818cf8; border:1px solid rgba(99,102,241,0.25); font-size:0.65rem;">{{ $role->name }}</span>
@endforeach
</div>
@endif @endif
</td> </td>
<td> <td>
@ -675,14 +903,17 @@
{{ $usr->created_at ? $usr->created_at->format('Y-m-d H:i') : 'N/A' }} {{ $usr->created_at ? $usr->created_at->format('Y-m-d H:i') : 'N/A' }}
</span> </span>
</td> </td>
<td style="text-align: right;"> <td style="text-align: right; white-space: nowrap;">
@if($usr->id !== $admin->id) @if($usr->id !== $admin->id)
@if(!$usr->isAdmin())
<button class="action-btn" onclick="openAssignRolesModal({{ $usr->id }}, '{{ addslashes($usr->name) }}', {{ json_encode($usr->roles->pluck('id')) }})" style="color:var(--accent-primary); border-color:rgba(99,102,241,0.2); font-size:0.75rem; padding:6px 12px; margin-right:4px;">Assign Roles</button>
@endif
<form action="{{ route('admin.users.toggle-block', $usr->id) }}" method="POST" style="display:inline;"> <form action="{{ route('admin.users.toggle-block', $usr->id) }}" method="POST" style="display:inline;">
@csrf @csrf
<button type="submit" class="action-btn" <button type="submit" class="action-btn"
style="font-size:0.75rem; padding:6px 12px; border-radius:6px; font-weight:600; cursor:pointer; transition:all 0.2s ease; style="font-size:0.75rem; padding:6px 12px; border-radius:6px; font-weight:600; cursor:pointer; transition:all 0.2s ease;
{{ $usr->is_blocked ? 'color:#10b981; border-color:rgba(16,185,129,0.2); background-color:rgba(16,185,129,0.05);' : 'color:#ef4444; border-color:rgba(239,68,68,0.2); background-color:rgba(239,68,68,0.05);' }}"> {{ $usr->is_blocked ? 'color:#10b981; border-color:rgba(16,185,129,0.2); background-color:rgba(16,185,129,0.05);' : 'color:#ef4444; border-color:rgba(239,68,68,0.2); background-color:rgba(239,68,68,0.05);' }}">
{{ $usr->is_blocked ? 'Unblock User' : 'Block User' }} {{ $usr->is_blocked ? 'Unblock' : 'Block' }}
</button> </button>
</form> </form>
@else @else
@ -704,6 +935,114 @@
</main> </main>
<!-- Edit Role Modal -->
<div id="edit-role-modal" class="admin-modal">
<div class="admin-modal-content">
<div class="modal-header">
<h3 class="modal-title">Edit Application Role</h3>
<button class="modal-close" onclick="closeModal('edit-role-modal')">&times;</button>
</div>
<form id="edit-role-form" method="POST">
@csrf
<div style="margin-bottom: 16px;">
<label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:6px; font-weight:500;">Role Name</label>
<input type="text" id="edit-role-name" name="name" required class="form-input" style="width:100%; padding:10px; background:rgba(255,255,255,0.03); border:1px solid var(--card-border); border-radius:8px; color:white;">
</div>
<div style="margin-bottom: 16px;">
<label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:6px; font-weight:500;">Description</label>
<input type="text" id="edit-role-description" name="description" class="form-input" style="width:100%; padding:10px; background:rgba(255,255,255,0.03); border:1px solid var(--card-border); border-radius:8px; color:white;">
</div>
<div style="margin-bottom: 16px;">
<label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:6px; font-weight:500;">Services Access</label>
<div class="checkbox-grid">
@foreach($apps as $app)
<label class="checkbox-item">
<input type="checkbox" name="apps[]" value="{{ $app->id }}" class="edit-role-app-checkbox" id="edit-role-app-{{ $app->id }}">
{{ $app->name }}
</label>
@endforeach
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-cancel" onclick="closeModal('edit-role-modal')">Cancel</button>
<button type="submit" class="action-btn" style="background-color:var(--accent-primary); color:white; border-color:transparent; font-weight:600; padding:8px 16px; border-radius:8px; cursor:pointer;">Save Changes</button>
</div>
</form>
</div>
</div>
<!-- Assign User Roles Modal -->
<div id="assign-roles-modal" class="admin-modal">
<div class="admin-modal-content">
<div class="modal-header">
<h3 class="modal-title">Assign Roles to <span id="assign-user-name">User</span></h3>
<button class="modal-close" onclick="closeModal('assign-roles-modal')">&times;</button>
</div>
<form id="assign-roles-form" method="POST">
@csrf
<div style="margin-bottom: 16px;">
<label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:6px; font-weight:500;">Select Roles for User</label>
<div style="background:rgba(0,0,0,0.2); border:1px solid var(--card-border); border-radius:8px; padding:12px; max-height:200px; overflow-y:auto; display:flex; flex-direction:column; gap:8px;">
@foreach($roles as $role)
<label class="checkbox-item">
<input type="checkbox" name="roles[]" value="{{ $role->id }}" class="assign-user-role-checkbox" id="assign-user-role-{{ $role->id }}">
<div>
<strong>{{ $role->name }}</strong>
@if($role->description)
<div style="font-size:0.7rem; color:var(--text-muted);">{{ $role->description }}</div>
@endif
</div>
</label>
@endforeach
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-cancel" onclick="closeModal('assign-roles-modal')">Cancel</button>
<button type="submit" class="action-btn" style="background-color:var(--accent-primary); color:white; border-color:transparent; font-weight:600; padding:8px 16px; border-radius:8px; cursor:pointer;">Update Roles</button>
</div>
</form>
</div>
</div>
<script>
function openModal(modalId) {
document.getElementById(modalId).classList.add('active');
}
function closeModal(modalId) {
document.getElementById(modalId).classList.remove('active');
}
function openEditRoleModal(id, name, description, appIds) {
const form = document.getElementById('edit-role-form');
form.action = `/admin/roles/${id}/update`;
document.getElementById('edit-role-name').value = name;
document.getElementById('edit-role-description').value = description || '';
// Reset and check assigned apps
document.querySelectorAll('.edit-role-app-checkbox').forEach(cb => {
cb.checked = appIds.includes(parseInt(cb.value));
});
openModal('edit-role-modal');
}
function openAssignRolesModal(userId, name, roleIds) {
const form = document.getElementById('assign-roles-form');
form.action = `/admin/users/${userId}/roles`;
document.getElementById('assign-user-name').innerText = name;
// Reset and check user's roles
document.querySelectorAll('.assign-user-role-checkbox').forEach(cb => {
cb.checked = roleIds.includes(parseInt(cb.value));
});
openModal('assign-roles-modal');
}
</script>
<footer style="text-align: center; padding: 40px 0; font-size: 0.75rem; color: var(--text-muted); z-index: 10;"> <footer style="text-align: center; padding: 40px 0; font-size: 0.75rem; color: var(--text-muted); z-index: 10;">
&copy; 2026 SingleLogin Systems. Authorized Access Only. &copy; 2026 SingleLogin Systems. Authorized Access Only.
</footer> </footer>

View File

@ -363,19 +363,6 @@
Login as Administrator Login as Administrator
</button> </button>
</form> </form>
<div class="divider">or</div>
<!-- Microsoft Login Alternative for Admin -->
<a href="{{ route('auth.microsoft.redirect') }}" class="btn btn-microsoft" style="text-decoration: none;">
<div class="microsoft-icon-grid">
<div class="micro-rect r1"></div>
<div class="micro-rect r2"></div>
<div class="micro-rect r3"></div>
<div class="micro-rect r4"></div>
</div>
Sign in with Microsoft
</a>
</div> </div>
</div> </div>

View File

@ -551,7 +551,7 @@
<div class="services-grid"> <div class="services-grid">
@foreach($services as $service) @foreach($services as $service)
<div class="service-card" onclick="launchSSO('{{ $service['name'] }}', '{{ $service['url'] }}')" style="border-top: 4px solid {{ $service['color'] }}"> <div class="service-card" onclick="launchSSO('{{ $service['name'] }}', '{{ route('sso.launch', $service['id']) }}')" style="border-top: 4px solid {{ $service['color'] }}">
<div> <div>
<div class="service-tag">{{ $service['tag'] }}</div> <div class="service-tag">{{ $service['tag'] }}</div>
<div class="service-icon-wrapper" style="background-color: rgba({{ hexdec(substr($service['color'], 1, 2)) }}, {{ hexdec(substr($service['color'], 3, 2)) }}, {{ hexdec(substr($service['color'], 5, 2)) }}, 0.08)"> <div class="service-icon-wrapper" style="background-color: rgba({{ hexdec(substr($service['color'], 1, 2)) }}, {{ hexdec(substr($service['color'], 3, 2)) }}, {{ hexdec(substr($service['color'], 5, 2)) }}, 0.08)">

View File

@ -17,9 +17,11 @@
Route::get('/auth/microsoft/redirect', [LoginController::class, 'redirectToMicrosoft'])->name('auth.microsoft.redirect'); Route::get('/auth/microsoft/redirect', [LoginController::class, 'redirectToMicrosoft'])->name('auth.microsoft.redirect');
Route::get('/auth/microsoft/callback', [LoginController::class, 'handleMicrosoftCallback'])->name('auth.microsoft.callback'); Route::get('/auth/microsoft/callback', [LoginController::class, 'handleMicrosoftCallback'])->name('auth.microsoft.callback');
// User Dashboard Portal (requires standard user role) // User Dashboard Portal (requires standard user role or admin access)
Route::middleware(['auth', 'role:user'])->group(function () { Route::middleware(['auth', 'role:user'])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard'); Route::get('/dashboard', [DashboardController::class, 'index'])->name('dashboard');
Route::get('/sso/launch/{id}', [DashboardController::class, 'launchSSO'])->name('sso.launch');
Route::get('/sso/callback', [DashboardController::class, 'handleSSOCallback'])->name('sso.callback');
}); });
// Admin Control Panel (requires admin role) // Admin Control Panel (requires admin role)
@ -27,7 +29,15 @@
Route::get('/admin/dashboard', [AdminController::class, 'index'])->name('admin.dashboard'); Route::get('/admin/dashboard', [AdminController::class, 'index'])->name('admin.dashboard');
Route::post('/admin/apps', [AdminController::class, 'storeApp'])->name('admin.apps.store'); Route::post('/admin/apps', [AdminController::class, 'storeApp'])->name('admin.apps.store');
Route::delete('/admin/apps/{id}', [AdminController::class, 'destroyApp'])->name('admin.apps.destroy'); Route::delete('/admin/apps/{id}', [AdminController::class, 'destroyApp'])->name('admin.apps.destroy');
Route::post('/admin/restrictions', [AdminController::class, 'storeRestriction'])->name('admin.restrictions.store');
Route::delete('/admin/restrictions/{id}', [AdminController::class, 'destroyRestriction'])->name('admin.restrictions.destroy');
Route::post('/admin/users/{id}/toggle-block', [AdminController::class, 'toggleBlockUser'])->name('admin.users.toggle-block'); Route::post('/admin/users/{id}/toggle-block', [AdminController::class, 'toggleBlockUser'])->name('admin.users.toggle-block');
// Roles CRUD
Route::post('/admin/roles', [AdminController::class, 'storeRole'])->name('admin.roles.store');
Route::post('/admin/roles/{id}/update', [AdminController::class, 'updateRole'])->name('admin.roles.update');
Route::delete('/admin/roles/{id}', [AdminController::class, 'destroyRole'])->name('admin.roles.destroy');
// User Roles & Overrides
Route::post('/admin/users/{id}/roles', [AdminController::class, 'updateUserRoles'])->name('admin.users.roles.update');
Route::post('/admin/overrides', [AdminController::class, 'storeUserOverride'])->name('admin.overrides.store');
Route::delete('/admin/overrides/{id}', [AdminController::class, 'destroyUserOverride'])->name('admin.overrides.destroy');
}); });

View File

@ -4,7 +4,8 @@
use App\Models\User; use App\Models\User;
use App\Models\App; use App\Models\App;
use App\Models\AppRestriction; use App\Models\Role;
use App\Models\UserAppOverride;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase; use Tests\TestCase;
@ -38,7 +39,7 @@ public function test_admin_can_access_admin_dashboard_but_not_standard_users():
->get('/admin/dashboard') ->get('/admin/dashboard')
->assertStatus(200); ->assertStatus(200);
// Standard user gets redirected to user dashboard // Standard user gets allowed standard user access to dashboard (our middleware handles this)
$this->actingAs($user) $this->actingAs($user)
->get('/admin/dashboard') ->get('/admin/dashboard')
->assertRedirect(route('dashboard')); ->assertRedirect(route('dashboard'));
@ -65,7 +66,7 @@ public function test_blocked_user_cannot_access_dashboard_and_gets_logged_out():
} }
/** /**
* Test restricted services are hidden from specific users. * Test restricted services are hidden from specific users via deny overrides.
*/ */
public function test_restricted_service_is_not_displayed_to_the_restricted_user(): void public function test_restricted_service_is_not_displayed_to_the_restricted_user(): void
{ {
@ -89,10 +90,18 @@ public function test_restricted_service_is_not_displayed_to_the_restricted_user(
'tag' => 'HR Portal' 'tag' => 'HR Portal'
]); ]);
// Restrict user from Keka HR // Create a role and associate both apps
AppRestriction::create([ $role = Role::create(['name' => 'Employee']);
'email' => $user->email, $role->apps()->sync([$app1->id, $app2->id]);
// Assign role to user
$user->roles()->sync([$role->id]);
// Restrict user from Keka HR using override
UserAppOverride::create([
'user_id' => $user->id,
'app_id' => $app2->id, 'app_id' => $app2->id,
'type' => 'deny',
]); ]);
$response = $this->actingAs($user) $response = $this->actingAs($user)
@ -102,4 +111,81 @@ public function test_restricted_service_is_not_displayed_to_the_restricted_user(
$response->assertSee('Outlook'); $response->assertSee('Outlook');
$response->assertDontSee('Keka HR'); $response->assertDontSee('Keka HR');
} }
/**
* Test additional services are displayed to specific users via allow overrides.
*/
public function test_additional_service_is_displayed_to_the_user(): void
{
$user = User::create([
'name' => 'Standard User',
'email' => 'user@sentientgeeks.com',
'role' => 'user',
]);
$app1 = App::create([
'name' => 'Outlook',
'url' => 'https://outlook.office.com',
'color' => '#0078d4',
'tag' => 'Microsoft 365'
]);
$app2 = App::create([
'name' => 'Keka HR',
'url' => 'https://sentientgeeks.keka.com',
'color' => '#f27059',
'tag' => 'HR Portal'
]);
// Create a role that only has Outlook
$role = Role::create(['name' => 'Intern']);
$role->apps()->sync([$app1->id]);
// Assign role to user
$user->roles()->sync([$role->id]);
// Add Keka HR as extra service using override
UserAppOverride::create([
'user_id' => $user->id,
'app_id' => $app2->id,
'type' => 'allow',
]);
$response = $this->actingAs($user)
->get('/dashboard');
$response->assertStatus(200);
$response->assertSee('Outlook');
$response->assertSee('Keka HR');
}
/**
* Test admin user cannot authenticate via Microsoft OAuth callback.
*/
public function test_admin_cannot_authenticate_via_microsoft_oauth_callback(): void
{
$admin = User::create([
'name' => 'Admin User',
'email' => 'admin@company.com',
'role' => 'admin',
'microsoft_id' => 'admin-microsoft-id-999'
]);
$mockSocialite = \Mockery::mock('Laravel\Socialite\Contracts\Provider');
$mockUser = \Mockery::mock('Laravel\Socialite\Two\User');
$mockUser->shouldReceive('getId')->andReturn('admin-microsoft-id-999');
$mockUser->shouldReceive('getEmail')->andReturn('admin@company.com');
$mockUser->shouldReceive('getName')->andReturn('Admin User');
$mockUser->shouldReceive('getAvatar')->andReturn(null);
$mockSocialite->shouldReceive('user')->andReturn($mockUser);
\Laravel\Socialite\Facades\Socialite::shouldReceive('driver')->with('microsoft')->andReturn($mockSocialite);
$response = $this->get('/auth/microsoft/callback');
$response->assertRedirect(route('admin.login'));
$response->assertSessionHas('error', 'Security protocol: Administrators are restricted to email and password authentication only.');
$this->assertFalse(auth()->check());
}
} }