Compare commits

..

No commits in common. "a2e3ad20e7b003dcff56bae0f1aebd88b2552c5e" and "643aafe07d683c7cc7ae80120585981aae77e83d" have entirely different histories.

14 changed files with 100 additions and 951 deletions

View File

@ -4,8 +4,7 @@
use App\Models\User;
use App\Models\App;
use App\Models\Role;
use App\Models\UserAppOverride;
use App\Models\AppRestriction;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
@ -18,17 +17,12 @@ public function index()
{
$admin = Auth::user();
// Fetch all users with their roles and overrides
$users = User::with(['roles', 'overrides.app'])->orderBy('created_at', 'desc')->get();
// Fetch all users to display on the dashboard for administration
$users = User::orderBy('created_at', 'desc')->get();
// Fetch all apps
// Fetch all apps and active restrictions
$apps = App::orderBy('name', 'asc')->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();
$restrictions = AppRestriction::with('app')->orderBy('created_at', 'desc')->get();
// Calculate statistics
$stats = [
@ -36,10 +30,9 @@ 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', 'roles', 'overrides', 'stats'));
return view('admin.dashboard', compact('admin', 'users', 'apps', 'restrictions', 'stats'));
}
/**
@ -80,121 +73,41 @@ public function destroyApp($id)
}
/**
* Store a new role.
* Restrict an app for a specific user email.
*/
public function storeRole(Request $request)
public function storeRestriction(Request $request)
{
$request->validate([
'name' => 'required|string|max:255|unique:roles,name',
'description' => 'nullable|string',
'apps' => 'nullable|array',
'apps.*' => 'exists:apps,id',
]);
$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',
'email' => 'required|email|max:255',
'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)
// Prevent duplicate restrictions
$exists = AppRestriction::where('email', $request->email)
->where('app_id', $request->app_id)
->exists();
if ($exists) {
return redirect()->route('admin.dashboard')->with('error', 'An override for this service and user already exists.');
return redirect()->route('admin.dashboard')->with('error', 'This user is already restricted from accessing this service.');
}
UserAppOverride::create([
'user_id' => $user->id,
AppRestriction::create([
'email' => $request->email,
'app_id' => $request->app_id,
'type' => $request->type,
]);
return redirect()->route('admin.dashboard')->with('success', 'User service override registered successfully.');
return redirect()->route('admin.dashboard')->with('success', 'Access restriction created successfully.');
}
/**
* Remove a user-specific override.
* Remove an app restriction.
*/
public function destroyUserOverride($id)
public function destroyRestriction($id)
{
$override = UserAppOverride::findOrFail($id);
$override->delete();
$restriction = AppRestriction::findOrFail($id);
$restriction->delete();
return redirect()->route('admin.dashboard')->with('success', 'User service override removed successfully.');
return redirect()->route('admin.dashboard')->with('success', 'Access restriction removed successfully.');
}
/**

View File

@ -5,8 +5,6 @@
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Laravel\Socialite\Facades\Socialite;
class DashboardController extends Controller
{
/**
@ -16,94 +14,14 @@ public function index()
{
$user = Auth::user();
// Get authorized apps for this user
$services = $user->authorizedApps();
// 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();
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,9 +28,6 @@ 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.');

View File

@ -1,29 +0,0 @@
<?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,53 +25,6 @@ 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.
*

View File

@ -1,29 +0,0 @@
<?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

@ -1,73 +0,0 @@
<?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,11 +3,9 @@
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
@ -19,8 +17,8 @@ class DatabaseSeeder extends Seeder
*/
public function run(): void
{
// 1. Seed default administrator
$admin = User::updateOrCreate(
// Seed default administrator
User::updateOrCreate(
['email' => 'admin@company.com'],
[
'name' => 'System Administrator',
@ -29,8 +27,18 @@ public function run(): void
]
);
// 2. Seed default apps
$outlook = App::updateOrCreate(
// 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(
['name' => 'Outlook'],
[
'url' => 'https://outlook.office.com/mail/',
@ -41,7 +49,7 @@ public function run(): void
]
);
$teams = App::updateOrCreate(
\App\Models\App::updateOrCreate(
['name' => 'Microsoft Teams'],
[
'url' => 'https://teams.microsoft.com/v2/',
@ -52,7 +60,7 @@ public function run(): void
]
);
$keka = App::updateOrCreate(
\App\Models\App::updateOrCreate(
['name' => 'Keka HR'],
[
'url' => 'https://sentientgeeks.keka.com/',
@ -62,92 +70,5 @@ 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']
);
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 473 KiB

View File

@ -368,113 +368,6 @@
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>
</head>
<body>
@ -543,14 +436,6 @@
</div>
<div class="stat-icon" style="color: #fbbf24;">📦</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>
<!-- Alert messages in dashboard -->
@ -568,8 +453,8 @@
</div>
@endif
<!-- App Management, Roles Management, & User Overrides Grid -->
<section class="management-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 20px; margin-bottom: 40px;">
<!-- App Management & Access Restrictions Grid -->
<section class="management-grid" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); gap: 20px; margin-bottom: 40px;">
<!-- 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);">
@ -606,7 +491,7 @@
<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;">
</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; border:none; cursor:pointer; font-weight:600;">+ 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;">+ Register Service App</button>
</form>
<!-- Registered Apps List -->
@ -617,6 +502,7 @@
<tr>
<th>Service</th>
<th>Tag</th>
<th>URL</th>
<th style="text-align: right;">Action</th>
</tr>
</thead>
@ -630,8 +516,9 @@
</div>
</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;">
<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;">
<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;">
@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;">Delete</button>
@ -640,7 +527,7 @@
</tr>
@empty
<tr>
<td colspan="3" style="text-align: center; color: var(--text-muted); padding: 20px;">No registered service apps.</td>
<td colspan="4" style="text-align: center; color: var(--text-muted); padding: 20px;">No registered service apps.</td>
</tr>
@endforelse
</tbody>
@ -648,173 +535,65 @@
</div>
</div>
<!-- Role Management Card -->
<!-- Access Restrictions 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;">Manage Roles & Services</h3>
<h3 style="font-family: 'Outfit', sans-serif; font-size: 1.25rem; font-weight: 600; margin-bottom: 16px; color: #a5b4fc;">Restrict User Access</h3>
<!-- Add Role Form -->
<form action="{{ route('admin.roles.store') }}" method="POST" style="margin-bottom: 30px;">
<!-- Add Restriction Form -->
<form action="{{ route('admin.restrictions.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;">Role Name</label>
<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;">
<label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:6px; font-weight:500;">User Email Address</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;">
</div>
<div style="margin-bottom: 16px;">
<label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:6px; font-weight:500;">Assign Services Access</label>
<div class="checkbox-grid">
<label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:6px; font-weight:500;">Restrict 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="">-- Select App to Restrict --</option>
@foreach($apps as $app)
<label class="checkbox-item">
<input type="checkbox" name="apps[]" value="{{ $app->id }}">
{{ $app->name }}
</label>
@endforeach
</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; border:none; cursor:pointer; font-weight:600;">+ Create Role</button>
</form>
<!-- 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;">System Application Roles</h4>
<div class="table-responsive" style="max-height: 250px; overflow-y: auto;">
<table class="users-table" style="font-size:0.8rem;">
<thead>
<tr>
<th>Role Name</th>
<th>Assigned Services</th>
<th style="text-align: right;">Actions</th>
</tr>
</thead>
<tbody>
@forelse($roles as $role)
<tr>
<td>
<div>
<strong>{{ $role->name }}</strong>
@if($role->description)
<div style="font-size:0.7rem; color:var(--text-muted); margin-top:2px;">{{ $role->description }}</div>
@endif
</div>
</td>
<td>
<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
@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>
</form>
</td>
</tr>
@empty
<tr>
<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
<option value="{{ $app->id }}">{{ $app->name }}</option>
@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>
<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>
</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>
<!-- Active Restrictions 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>
<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>User Email</th>
<th>Restricted App</th>
<th style="text-align: right;">Action</th>
</tr>
</thead>
<tbody>
@forelse($overrides as $override)
@forelse($restrictions as $res)
<tr>
<td><strong style="color:#fca5a5;">{{ $res->email }}</strong></td>
<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)
@if($res->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 }}
<div style="width:8px; height:8px; border-radius:50%; background-color:{{ $res->app->color }};"></div>
{{ $res->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;">
<form action="{{ route('admin.restrictions.destroy', $res->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>
<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>
</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>
<td colspan="3" style="text-align: center; color: var(--text-muted); padding: 20px;">No active restrictions. All apps visible to all users.</td>
</tr>
@endforelse
</tbody>
@ -873,14 +652,7 @@
@if($usr->role === 'admin')
<span class="badge badge-admin">Admin</span>
@else
<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>
<span class="badge badge-user">User</span>
@endif
</td>
<td>
@ -903,17 +675,14 @@
{{ $usr->created_at ? $usr->created_at->format('Y-m-d H:i') : 'N/A' }}
</span>
</td>
<td style="text-align: right; white-space: nowrap;">
<td style="text-align: right;">
@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;">
@csrf
<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;
{{ $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' : 'Block' }}
{{ $usr->is_blocked ? 'Unblock User' : 'Block User' }}
</button>
</form>
@else
@ -935,114 +704,6 @@
</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;">
&copy; 2026 SingleLogin Systems. Authorized Access Only.
</footer>

View File

@ -363,6 +363,19 @@
Login as Administrator
</button>
</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>

View File

@ -551,7 +551,7 @@
<div class="services-grid">
@foreach($services as $service)
<div class="service-card" onclick="launchSSO('{{ $service['name'] }}', '{{ route('sso.launch', $service['id']) }}')" style="border-top: 4px solid {{ $service['color'] }}">
<div class="service-card" onclick="launchSSO('{{ $service['name'] }}', '{{ $service['url'] }}')" style="border-top: 4px solid {{ $service['color'] }}">
<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)">

View File

@ -17,11 +17,9 @@
Route::get('/auth/microsoft/redirect', [LoginController::class, 'redirectToMicrosoft'])->name('auth.microsoft.redirect');
Route::get('/auth/microsoft/callback', [LoginController::class, 'handleMicrosoftCallback'])->name('auth.microsoft.callback');
// User Dashboard Portal (requires standard user role or admin access)
// User Dashboard Portal (requires standard user role)
Route::middleware(['auth', 'role:user'])->group(function () {
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)
@ -29,15 +27,7 @@
Route::get('/admin/dashboard', [AdminController::class, 'index'])->name('admin.dashboard');
Route::post('/admin/apps', [AdminController::class, 'storeApp'])->name('admin.apps.store');
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');
// 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,8 +4,7 @@
use App\Models\User;
use App\Models\App;
use App\Models\Role;
use App\Models\UserAppOverride;
use App\Models\AppRestriction;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
@ -39,7 +38,7 @@ public function test_admin_can_access_admin_dashboard_but_not_standard_users():
->get('/admin/dashboard')
->assertStatus(200);
// Standard user gets allowed standard user access to dashboard (our middleware handles this)
// Standard user gets redirected to user dashboard
$this->actingAs($user)
->get('/admin/dashboard')
->assertRedirect(route('dashboard'));
@ -66,7 +65,7 @@ public function test_blocked_user_cannot_access_dashboard_and_gets_logged_out():
}
/**
* Test restricted services are hidden from specific users via deny overrides.
* Test restricted services are hidden from specific users.
*/
public function test_restricted_service_is_not_displayed_to_the_restricted_user(): void
{
@ -90,18 +89,10 @@ public function test_restricted_service_is_not_displayed_to_the_restricted_user(
'tag' => 'HR Portal'
]);
// Create a role and associate both apps
$role = Role::create(['name' => 'Employee']);
$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,
// Restrict user from Keka HR
AppRestriction::create([
'email' => $user->email,
'app_id' => $app2->id,
'type' => 'deny',
]);
$response = $this->actingAs($user)
@ -111,81 +102,4 @@ public function test_restricted_service_is_not_displayed_to_the_restricted_user(
$response->assertSee('Outlook');
$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());
}
}