admin intregration

This commit is contained in:
subhajit 2026-06-23 15:46:07 +05:30
parent 49e1a5d20f
commit 643aafe07d
17 changed files with 1012 additions and 104 deletions

View File

@ -3,6 +3,8 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\User; use App\Models\User;
use App\Models\App;
use App\Models\AppRestriction;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Auth;
@ -18,14 +20,112 @@ public function index()
// Fetch all users to display on the dashboard for administration // Fetch all users to display on the dashboard for administration
$users = User::orderBy('created_at', 'desc')->get(); $users = User::orderBy('created_at', 'desc')->get();
// Fetch all apps and active restrictions
$apps = App::orderBy('name', 'asc')->get();
$restrictions = AppRestriction::with('app')->orderBy('created_at', 'desc')->get();
// Calculate statistics // Calculate statistics
$stats = [ $stats = [
'total_users' => User::count(), 'total_users' => User::count(),
'admins_count' => User::where('role', 'admin')->count(), 'admins_count' => User::where('role', 'admin')->count(),
'microsoft_users' => User::whereNotNull('microsoft_id')->count(), 'blocked_count' => User::where('is_blocked', true)->count(),
'credentials_users' => User::whereNull('microsoft_id')->count(), 'apps_count' => App::count(),
]; ];
return view('admin.dashboard', compact('admin', 'users', 'stats')); return view('admin.dashboard', compact('admin', 'users', 'apps', 'restrictions', 'stats'));
}
/**
* Store a new app / service.
*/
public function storeApp(Request $request)
{
$request->validate([
'name' => 'required|string|max:255',
'url' => 'required|url|max:255',
'icon_svg' => 'nullable|string',
'color' => 'nullable|string|max:20',
'desc' => 'nullable|string',
'tag' => 'nullable|string|max:255',
]);
App::create([
'name' => $request->name,
'url' => $request->url,
'icon_svg' => $request->icon_svg,
'color' => $request->color ?? '#4f46e5',
'desc' => $request->desc,
'tag' => $request->tag ?? 'General',
]);
return redirect()->route('admin.dashboard')->with('success', 'Service app registered successfully.');
}
/**
* Delete an app / service.
*/
public function destroyApp($id)
{
$app = App::findOrFail($id);
$app->delete();
return redirect()->route('admin.dashboard')->with('success', 'Service app deleted successfully.');
}
/**
* Restrict an app for a specific user email.
*/
public function storeRestriction(Request $request)
{
$request->validate([
'email' => 'required|email|max:255',
'app_id' => 'required|exists:apps,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', 'This user is already restricted from accessing this service.');
}
AppRestriction::create([
'email' => $request->email,
'app_id' => $request->app_id,
]);
return redirect()->route('admin.dashboard')->with('success', 'Access restriction created successfully.');
}
/**
* Remove an app restriction.
*/
public function destroyRestriction($id)
{
$restriction = AppRestriction::findOrFail($id);
$restriction->delete();
return redirect()->route('admin.dashboard')->with('success', 'Access restriction removed successfully.');
}
/**
* Toggle the blocked status of a user.
*/
public function toggleBlockUser(Request $request, $id)
{
$user = User::findOrFail($id);
// Security: Prevent blocking oneself
if ($user->id === Auth::id()) {
return redirect()->route('admin.dashboard')->with('error', 'Security violation: You cannot block your own administrator account.');
}
$user->is_blocked = !$user->is_blocked;
$user->save();
$status = $user->is_blocked ? 'blocked' : 'unblocked';
return redirect()->route('admin.dashboard')->with('success', "User account has been {$status} successfully.");
} }
} }

View File

@ -14,33 +14,13 @@ public function index()
{ {
$user = Auth::user(); $user = Auth::user();
// Service list with URLs and aesthetic tags // Get all apps that are NOT restricted for this user's email
$services = [ $services = \App\Models\App::whereNotExists(function ($query) use ($user) {
[ $query->select(\DB::raw(1))
'name' => 'Outlook', ->from('app_restrictions')
'url' => 'https://outlook.office.com/mail/', ->whereColumn('app_restrictions.app_id', 'apps.id')
'icon_svg' => 'outlook', ->where('app_restrictions.email', $user->email);
'color' => '#0078d4', })->orderBy('name', 'asc')->get();
'desc' => 'Access your corporate mail, calendar, and contacts instantly.',
'tag' => 'Microsoft 365'
],
[
'name' => 'Microsoft Teams',
'url' => 'https://teams.microsoft.com/v2/',
'icon_svg' => 'teams',
'color' => '#6264a7',
'desc' => 'Chat, meet, call, and collaborate with your team.',
'tag' => 'Microsoft 365'
],
[
'name' => 'Keka HR',
'url' => 'https://sentientgeeks.keka.com/',
'icon_svg' => 'keka',
'color' => '#f27059',
'desc' => 'Manage timesheets, leaves, payroll, and performance tasks.',
'tag' => 'HR Portal'
],
];
return view('dashboard', compact('user', 'services')); return view('dashboard', compact('user', 'services'));
} }

View File

@ -22,6 +22,18 @@ public function showLoginForm()
return view('auth.login'); return view('auth.login');
} }
/**
* Show the administrator login screen.
*/
public function showAdminLoginForm()
{
if (Auth::check()) {
return $this->redirectUserBasedOnRole(Auth::user());
}
return view('auth.admin-login');
}
/** /**
* Handle Email & Password login for Administrators. * Handle Email & Password login for Administrators.
*/ */
@ -88,14 +100,20 @@ public function handleMicrosoftCallback(Request $request)
return redirect()->route('login')->with('error', 'Microsoft Authentication failed or was cancelled: ' . $e->getMessage()); return redirect()->route('login')->with('error', 'Microsoft Authentication failed or was cancelled: ' . $e->getMessage());
} }
$email = $microsoftUser->getEmail();
$isSentientGeeks = str_ends_with($email, '@sentientgeeks.com') || str_ends_with($email, '@sentientgeeks.in');
// Search for user by microsoft_id first, then by email // Search for user by microsoft_id first, then by email
$user = User::where('microsoft_id', $microsoftUser->getId())->first(); $user = User::where('microsoft_id', $microsoftUser->getId())->first();
if (!$user) { if (!$user) {
$user = User::where('email', $microsoftUser->getEmail())->first(); $user = User::where('email', $email)->first();
} }
if ($user) { if ($user) {
if ($user->is_blocked) {
return redirect()->route('login')->with('error', 'Your account has been blocked. Please contact the administrator.');
}
// Update existing user with Microsoft data if not already set // Update existing user with Microsoft data if not already set
$user->update([ $user->update([
'microsoft_id' => $microsoftUser->getId(), 'microsoft_id' => $microsoftUser->getId(),
@ -104,10 +122,15 @@ public function handleMicrosoftCallback(Request $request)
'avatar' => $microsoftUser->getAvatar() ?? $user->avatar, 'avatar' => $microsoftUser->getAvatar() ?? $user->avatar,
]); ]);
} else { } else {
// Block registration if domain is not allowed
if (!$isSentientGeeks) {
return redirect()->route('login')->with('error', 'Access denied. Only @sentientgeeks.com and @sentientgeeks.in users are allowed to register.');
}
// Create a new User (defaults to 'user' role) // Create a new User (defaults to 'user' role)
$user = User::create([ $user = User::create([
'name' => $microsoftUser->getName() ?? explode('@', $microsoftUser->getEmail())[0], 'name' => $microsoftUser->getName() ?? explode('@', $email)[0],
'email' => $microsoftUser->getEmail(), 'email' => $email,
'microsoft_id' => $microsoftUser->getId(), 'microsoft_id' => $microsoftUser->getId(),
'microsoft_token' => $microsoftUser->token, 'microsoft_token' => $microsoftUser->token,
'microsoft_refresh_token' => $microsoftUser->refreshToken ?? null, 'microsoft_refresh_token' => $microsoftUser->refreshToken ?? null,

View File

@ -19,6 +19,13 @@ public function handle(Request $request, Closure $next, string $role): Response
return redirect()->route('login')->with('error', 'Please log in to access this page.'); return redirect()->route('login')->with('error', 'Please log in to access this page.');
} }
if (auth()->user()->is_blocked) {
auth()->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
return redirect()->route('login')->with('error', 'Your account has been blocked. Please contact the administrator.');
}
if (auth()->user()->role !== $role) { if (auth()->user()->role !== $role) {
if (auth()->user()->role === 'admin') { if (auth()->user()->role === 'admin') {
return redirect()->route('admin.dashboard'); return redirect()->route('admin.dashboard');

21
app/Models/App.php Normal file
View File

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Attributes\Fillable;
#[Fillable(['name', 'url', 'icon_svg', 'color', 'desc', 'tag'])]
class App extends Model
{
use HasFactory;
/**
* Get the restrictions for this app.
*/
public function restrictions()
{
return $this->hasMany(AppRestriction::class, 'app_id');
}
}

View File

@ -0,0 +1,21 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Attributes\Fillable;
#[Fillable(['email', 'app_id'])]
class AppRestriction extends Model
{
use HasFactory;
/**
* Get the app that is restricted.
*/
public function app()
{
return $this->belongsTo(App::class, 'app_id');
}
}

View File

@ -10,7 +10,7 @@
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
#[Fillable(['name', 'email', 'password', 'role', 'microsoft_id', 'microsoft_token', 'microsoft_refresh_token', 'avatar'])] #[Fillable(['name', 'email', 'password', 'role', 'microsoft_id', 'microsoft_token', 'microsoft_refresh_token', 'avatar', 'is_blocked'])]
#[Hidden(['password', 'remember_token'])] #[Hidden(['password', 'remember_token'])]
class User extends Authenticatable class User extends Authenticatable
{ {

View File

@ -0,0 +1,28 @@
<?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
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('is_blocked')->default(false);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('is_blocked');
});
}
};

View File

@ -0,0 +1,33 @@
<?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
{
Schema::create('apps', function (Blueprint $table) {
$table->id();
$table->string('name');
$table->string('url');
$table->longText('icon_svg')->nullable();
$table->string('color')->default('#4f46e5');
$table->text('desc')->nullable();
$table->string('tag')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('apps');
}
};

View File

@ -0,0 +1,30 @@
<?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
{
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();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('app_restrictions');
}
};

View File

@ -36,5 +36,39 @@ public function run(): void
'microsoft_id' => 'mock-microsoft-id-123', 'microsoft_id' => 'mock-microsoft-id-123',
] ]
); );
// Seed default apps
\App\Models\App::updateOrCreate(
['name' => 'Outlook'],
[
'url' => 'https://outlook.office.com/mail/',
'icon_svg' => 'outlook',
'color' => '#0078d4',
'desc' => 'Access your corporate mail, calendar, and contacts instantly.',
'tag' => 'Microsoft 365'
]
);
\App\Models\App::updateOrCreate(
['name' => 'Microsoft Teams'],
[
'url' => 'https://teams.microsoft.com/v2/',
'icon_svg' => 'teams',
'color' => '#6264a7',
'desc' => 'Chat, meet, call, and collaborate with your team.',
'tag' => 'Microsoft 365'
]
);
\App\Models\App::updateOrCreate(
['name' => 'Keka HR'],
[
'url' => 'https://sentientgeeks.keka.com/',
'icon_svg' => 'keka',
'color' => '#f27059',
'desc' => 'Manage timesheets, leaves, payroll, and performance tasks.',
'tag' => 'HR Portal'
]
);
} }
} }

View File

@ -423,27 +423,192 @@
<div class="stat-card"> <div class="stat-card">
<div> <div>
<div class="stat-label">Microsoft Connected</div> <div class="stat-label">Blocked Accounts</div>
<div class="stat-value">{{ $stats['microsoft_users'] }}</div> <div class="stat-value">{{ $stats['blocked_count'] }}</div>
</div> </div>
<div class="stat-icon" style="color: var(--success-color);">☁️</div> <div class="stat-icon" style="color: var(--error-color);">🚫</div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div> <div>
<div class="stat-label">Local Auth Only</div> <div class="stat-label">Registered Apps</div>
<div class="stat-value">{{ $stats['credentials_users'] }}</div> <div class="stat-value">{{ $stats['apps_count'] }}</div>
</div> </div>
<div class="stat-icon" style="color: #fbbf24;">🔑</div> <div class="stat-icon" style="color: #fbbf24;">📦</div>
</div> </div>
</section> </section>
<!-- Alert messages in dashboard -->
@if(session('error'))
<div class="alert alert-danger" style="margin-bottom: 24px;">
<span class="alert-icon">⚠️</span>
<div>{{ session('error') }}</div>
</div>
@endif
@if(session('success'))
<div class="alert alert-success" style="margin-bottom: 24px;">
<span class="alert-icon"></span>
<div>{{ session('success') }}</div>
</div>
@endif
<!-- 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);">
<h3 style="font-family: 'Outfit', sans-serif; font-size: 1.25rem; font-weight: 600; margin-bottom: 16px; color: #a5b4fc;">Manage System Services</h3>
<!-- Add App Form -->
<form action="{{ route('admin.apps.store') }}" method="POST" style="margin-bottom: 30px;">
@csrf
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px;">
<div>
<label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:6px; font-weight:500;">Service Name</label>
<input type="text" name="name" required placeholder="e.g. Keka 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>
<label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:6px; font-weight:500;">SSO URL</label>
<input type="url" name="url" required placeholder="https://..." 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="display: grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-bottom: 12px;">
<div>
<label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:6px; font-weight:500;">Theme Color</label>
<input type="text" name="color" placeholder="#f27059" 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>
<label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:6px; font-weight:500;">Portal Tag</label>
<input type="text" name="tag" placeholder="HR Portal" 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: 12px;">
<label style="display:block; font-size:0.75rem; color:var(--text-muted); margin-bottom:6px; font-weight:500;">Icon SVG Code or Preset Name (outlook/teams/keka)</label>
<textarea name="icon_svg" rows="2" placeholder="e.g. keka, outlook, teams, or raw <svg>...</svg>" 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; font-family:monospace; font-size:0.8rem;"></textarea>
</div>
<div style="margin-bottom: 16px;">
<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;">+ Register Service App</button>
</form>
<!-- Registered Apps 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;">Registered Service Apps</h4>
<div class="table-responsive" style="max-height: 250px; overflow-y: auto;">
<table class="users-table" style="font-size:0.8rem;">
<thead>
<tr>
<th>Service</th>
<th>Tag</th>
<th>URL</th>
<th style="text-align: right;">Action</th>
</tr>
</thead>
<tbody>
@forelse($apps as $app)
<tr>
<td>
<div style="display:flex; align-items:center; gap:8px;">
<div style="width:12px; height:12px; border-radius:50%; background-color:{{ $app->color }};"></div>
<strong>{{ $app->name }}</strong>
</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? 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>
</form>
</td>
</tr>
@empty
<tr>
<td colspan="4" style="text-align: center; color: var(--text-muted); padding: 20px;">No registered service apps.</td>
</tr>
@endforelse
</tbody>
</table>
</div>
</div>
<!-- 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;">Restrict User Access</h3>
<!-- 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;">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;">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)
<option value="{{ $app->id }}">{{ $app->name }}</option>
@endforeach
</select>
</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>
</form>
<!-- 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 Email</th>
<th>Restricted App</th>
<th style="text-align: right;">Action</th>
</tr>
</thead>
<tbody>
@forelse($restrictions as $res)
<tr>
<td><strong style="color:#fca5a5;">{{ $res->email }}</strong></td>
<td>
@if($res->app)
<span style="display:flex; align-items:center; gap:6px;">
<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 style="text-align: right;">
<form action="{{ route('admin.restrictions.destroy', $res->id) }}" method="POST" style="display:inline;">
@csrf
@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>
</form>
</td>
</tr>
@empty
<tr>
<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>
</table>
</div>
</div>
</section>
<!-- Users Table Card --> <!-- Users Table Card -->
<section class="users-card"> <section class="users-card">
<div class="card-header"> <div class="card-header">
<div class="card-title">User Accounts Directory</div> <div class="card-title">User Accounts Directory</div>
<div style="font-size: 0.8rem; color: var(--text-muted);"> <div style="font-size: 0.8rem; color: var(--text-muted);">
Showing registered system entities Showing registered system entities and block controls
</div> </div>
</div> </div>
@ -471,7 +636,14 @@
</div> </div>
@endif @endif
<div> <div>
<div class="user-name">{{ $usr->name }}</div> <div class="user-name" style="display:flex; align-items:center; gap:8px;">
{{ $usr->name }}
@if($usr->is_blocked)
<span class="badge" style="background-color:rgba(239,68,68,0.15); color:#fca5a5; border:1px solid rgba(239,68,68,0.3); font-size:0.65rem; padding:2px 6px;">Blocked</span>
@else
<span class="badge" style="background-color:rgba(16,185,129,0.12); color:#a7f3d0; border:1px solid rgba(16,185,129,0.25); font-size:0.65rem; padding:2px 6px;">Active</span>
@endif
</div>
<div class="user-email">{{ $usr->email }}</div> <div class="user-email">{{ $usr->email }}</div>
</div> </div>
</div> </div>
@ -504,7 +676,18 @@
</span> </span>
</td> </td>
<td style="text-align: right;"> <td style="text-align: right;">
<button class="action-btn" onclick="alert('Management feature toggled for {{ $usr->name }}')">Configure</button> @if($usr->id !== $admin->id)
<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 User' : 'Block User' }}
</button>
</form>
@else
<span style="font-size:0.75rem; color:var(--text-muted); font-style:italic;">You (Admin)</span>
@endif
</td> </td>
</tr> </tr>
@empty @empty

View File

@ -0,0 +1,390 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SingleLogin Portal - Admin Access</title>
<!-- Google Fonts: Outfit & Inter -->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Outfit:wght@400;600;700;800&display=swap" rel="stylesheet">
<style>
:root {
--bg-color: #0b0f19;
--card-bg: rgba(17, 24, 39, 0.7);
--card-border: rgba(255, 255, 255, 0.08);
--accent-primary: #ef4444; /* red for admin alert */
--accent-secondary: #0078d4;
--text-main: #f3f4f6;
--text-muted: #9ca3af;
--error-color: #ef4444;
--success-color: #10b981;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
font-family: 'Inter', sans-serif;
background-color: var(--bg-color);
color: var(--text-main);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow-x: hidden;
position: relative;
}
/* Gorgeous glowing red gradient backgrounds for Admin */
body::before {
content: '';
position: absolute;
width: 400px;
height: 400px;
border-radius: 50%;
background: radial-gradient(circle, rgba(239, 68, 68, 0.1) 0%, rgba(0, 0, 0, 0) 70%);
top: -100px;
left: -100px;
z-index: -1;
}
body::after {
content: '';
position: absolute;
width: 500px;
height: 500px;
border-radius: 50%;
background: radial-gradient(circle, rgba(0, 120, 212, 0.1) 0%, rgba(0, 0, 0, 0) 70%);
bottom: -150px;
right: -150px;
z-index: -1;
}
.login-container {
width: 100%;
max-width: 460px;
padding: 20px;
z-index: 10;
}
.brand-header {
text-align: center;
margin-bottom: 24px;
}
.brand-logo {
font-family: 'Outfit', sans-serif;
font-size: 2.2rem;
font-weight: 800;
background: linear-gradient(135deg, #fca5a5 0%, #ef4444 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: -0.5px;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.brand-logo svg {
width: 32px;
height: 32px;
fill: #ef4444;
}
.brand-subtitle {
font-size: 0.9rem;
color: var(--text-muted);
margin-top: 6px;
}
/* Glassmorphism Card styling */
.login-card {
background-color: var(--card-bg);
border: 1px solid var(--card-border);
border-radius: 20px;
padding: 40px 32px;
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.login-card:hover {
box-shadow: 0 15px 40px rgba(239, 68, 68, 0.08);
}
.tab-content {
display: block;
animation: fadeIn 0.4s ease-out;
}
/* Typography & Forms */
h2 {
font-family: 'Outfit', sans-serif;
font-weight: 600;
font-size: 1.4rem;
margin-bottom: 8px;
color: #fca5a5;
}
.portal-desc {
font-size: 0.85rem;
color: var(--text-muted);
margin-bottom: 24px;
line-height: 1.4;
}
.form-group {
margin-bottom: 20px;
position: relative;
}
.form-label {
display: block;
font-size: 0.8rem;
color: var(--text-muted);
margin-bottom: 8px;
font-weight: 500;
letter-spacing: 0.5px;
text-transform: uppercase;
}
.form-input {
width: 100%;
padding: 12px 16px;
background-color: rgba(255, 255, 255, 0.04);
border: 1px solid var(--card-border);
border-radius: 10px;
color: var(--text-main);
font-family: 'Inter', sans-serif;
font-size: 0.95rem;
transition: all 0.3s ease;
}
.form-input:focus {
outline: none;
border-color: var(--accent-primary);
background-color: rgba(255, 255, 255, 0.07);
box-shadow: 0 0 10px rgba(239, 68, 68, 0.15);
}
/* Buttons */
.btn {
width: 100%;
padding: 13px;
border-radius: 10px;
border: none;
font-family: 'Inter', sans-serif;
font-weight: 600;
font-size: 0.95rem;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.btn-primary {
background-color: var(--accent-primary);
color: var(--text-main);
}
.btn-primary:hover {
background-color: #dc2626;
transform: translateY(-2px);
box-shadow: 0 6px 15px rgba(239, 68, 68, 0.3);
}
.btn-microsoft {
background-color: #2f2f2f;
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--text-main);
font-weight: 500;
}
.btn-microsoft:hover {
background-color: #3f3f3f;
border-color: rgba(255, 255, 255, 0.2);
transform: translateY(-2px);
box-shadow: 0 6px 15px rgba(0, 0, 0, 0.2);
}
.microsoft-icon-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2px;
width: 16px;
height: 16px;
}
.micro-rect {
width: 7px;
height: 7px;
}
.r1 { background-color: #f25022; }
.r2 { background-color: #7fba00; }
.r3 { background-color: #00a4ef; }
.r4 { background-color: #ffb900; }
/* Notification Alerts */
.alert {
border-radius: 10px;
padding: 12px 16px;
margin-bottom: 20px;
font-size: 0.85rem;
line-height: 1.4;
display: flex;
align-items: flex-start;
gap: 10px;
}
.alert-danger {
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.2);
color: var(--error-color);
}
.alert-success {
background-color: rgba(16, 185, 129, 0.1);
border: 1px solid rgba(16, 185, 129, 0.2);
color: var(--success-color);
}
.alert-icon {
font-size: 1.1rem;
line-height: 1;
}
/* Divider */
.divider {
display: flex;
align-items: center;
text-align: center;
color: var(--text-muted);
font-size: 0.8rem;
margin: 20px 0;
}
.divider::before, .divider::after {
content: '';
flex: 1;
border-bottom: 1px solid var(--card-border);
}
.divider:not(:empty)::before {
margin-right: .5em;
}
.divider:not(:empty)::after {
margin-left: .5em;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.footer {
text-align: center;
margin-top: 24px;
font-size: 0.75rem;
color: var(--text-muted);
}
</style>
</head>
<body>
<div class="login-container">
<div class="brand-header">
<div class="brand-logo">
<svg viewBox="0 0 24 24">
<path d="M12,17A2,2 0 0,0 14,15C14,13.89 13.1,13 12,13A2,2 0 0,0 10,15A2,2 0 0,0 12,17M18,8H17V6A5,5 0 0,0 12,1A5,5 0 0,0 7,6V8H6A2,2 0 0,0 4,10V20A2,2 0 0,0 6,22H18A2,2 0 0,0 20,20V10A2,2 0 0,0 18,8M8.9,6C8.9,4.29 10.29,2.9 12,2.9C13.71,2.9 15.1,4.29 15.1,6V8H8.9V6M18,20H6V10H18V20Z" />
</svg>
SingleLogin Admin
</div>
<div class="brand-subtitle">Administrator Access Only</div>
</div>
<div class="login-card">
<!-- Alert Display -->
@if(session('error'))
<div class="alert alert-danger">
<span class="alert-icon">⚠️</span>
<div>{{ session('error') }}</div>
</div>
@endif
@if(session('success'))
<div class="alert alert-success">
<span class="alert-icon"></span>
<div>{{ session('success') }}</div>
</div>
@endif
@if($errors->any())
<div class="alert alert-danger">
<span class="alert-icon">⚠️</span>
<div>
@foreach($errors->all() as $error)
<p>{{ $error }}</p>
@endforeach
</div>
</div>
@endif
<!-- Admin Portal Content -->
<div class="tab-content">
<h2>Admin Credentials</h2>
<div class="portal-desc">Manage system services, restrict users, and review platform authentication logs.</div>
<!-- Email/Password Admin Form -->
<form action="{{ route('admin.login.submit') }}" method="POST">
@csrf
<div class="form-group">
<label class="form-label" for="email">Admin Email</label>
<input type="email" id="email" name="email" class="form-input" placeholder="admin@company.com" required value="{{ old('email') }}">
</div>
<div class="form-group">
<label class="form-label" for="password">Password</label>
<input type="password" id="password" name="password" class="form-input" placeholder="••••••••" required>
</div>
<button type="submit" class="btn btn-primary" style="margin-bottom: 10px;">
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>
<div class="footer">
&copy; 2026 SingleLogin Systems. Secured with Microsoft Identity Platform.
</div>
</div>
</body>
</html>

View File

@ -374,13 +374,7 @@
</div> </div>
@endif @endif
<!-- Portal Tabs --> <!-- User Portal Content -->
<div class="portal-tabs">
<button type="button" class="tab-btn active" onclick="switchPortal('user')">User Portal</button>
<button type="button" class="tab-btn" onclick="switchPortal('admin')">Admin Portal</button>
</div>
<!-- User Portal Tab -->
<div id="user-portal" class="tab-content active"> <div id="user-portal" class="tab-content active">
<h2>Welcome back</h2> <h2>Welcome back</h2>
<div class="portal-desc">Access your Microsoft 365 services and corporate resources. Users must sign in via Single Sign-On.</div> <div class="portal-desc">Access your Microsoft 365 services and corporate resources. Users must sign in via Single Sign-On.</div>
@ -396,43 +390,6 @@
</a> </a>
</div> </div>
<!-- Admin Portal Tab -->
<div id="admin-portal" class="tab-content">
<h2>Admin Login</h2>
<div class="portal-desc">Manage tenant configurations, user assignments, and systems.</div>
<!-- Email/Password Admin Form -->
<form action="{{ route('login.admin') }}" method="POST">
@csrf
<div class="form-group">
<label class="form-label" for="email">Admin Email</label>
<input type="email" id="email" name="email" class="form-input" placeholder="admin@company.com" required value="{{ old('email') }}">
</div>
<div class="form-group">
<label class="form-label" for="password">Password</label>
<input type="password" id="password" name="password" class="form-input" placeholder="••••••••" required>
</div>
<button type="submit" class="btn btn-primary" style="margin-bottom: 10px;">
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> </div>
<div class="footer"> <div class="footer">
@ -441,21 +398,5 @@
</div> </div>
<script>
function switchPortal(portal) {
// Remove active classes
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
// Add active classes
if (portal === 'user') {
document.querySelector('.tab-btn:nth-child(1)').classList.add('active');
document.getElementById('user-portal').classList.add('active');
} else {
document.querySelector('.tab-btn:nth-child(2)').classList.add('active');
document.getElementById('admin-portal').classList.add('active');
}
}
</script>
</body> </body>
</html> </html>

View File

@ -564,6 +564,12 @@
<svg viewBox="0 0 24 24" fill="{{ $service['color'] }}"> <svg viewBox="0 0 24 24" fill="{{ $service['color'] }}">
<path d="M12.5,9A2.5,2.5 0 0,1 15,11.5A2.5,2.5 0 0,1 12.5,14A2.5,2.5 0 0,1 10,11.5A2.5,2.5 0 0,1 12.5,9M12.5,15.5C14.71,15.5 18.5,16.5 18.5,18.5V20H6.5V18.5C6.5,16.5 10.29,15.5 12.5,15.5M6,8A2,2 0 0,1 8,10A2,2 0 0,1 6,12A2,2 0 0,1 4,10A2,2 0 0,1 6,8M6,13.5C7.43,13.5 10,14.07 10,15.25V17H2V15.25C2,14.07 4.57,13.5 6,13.5M12.5,3A2,2 0 0,1 14.5,5A2,2 0 0,1 12.5,7A2,2 0 0,1 10.5,5A2,2 0 0,1 12.5,3M12.5,20V22H19V20H12.5Z" /> <path d="M12.5,9A2.5,2.5 0 0,1 15,11.5A2.5,2.5 0 0,1 12.5,14A2.5,2.5 0 0,1 10,11.5A2.5,2.5 0 0,1 12.5,9M12.5,15.5C14.71,15.5 18.5,16.5 18.5,18.5V20H6.5V18.5C6.5,16.5 10.29,15.5 12.5,15.5M6,8A2,2 0 0,1 8,10A2,2 0 0,1 6,12A2,2 0 0,1 4,10A2,2 0 0,1 6,8M6,13.5C7.43,13.5 10,14.07 10,15.25V17H2V15.25C2,14.07 4.57,13.5 6,13.5M12.5,3A2,2 0 0,1 14.5,5A2,2 0 0,1 12.5,7A2,2 0 0,1 10.5,5A2,2 0 0,1 12.5,3M12.5,20V22H19V20H12.5Z" />
</svg> </svg>
@elseif($service['icon_svg'] === 'keka')
<svg viewBox="0 0 24 24" fill="{{ $service['color'] }}">
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 17h-2v-2h2v2zm2.07-7.75l-.9.92C13.45 12.9 13 13.5 13 15h-2v-.5c0-1.1.45-2.1 1.17-2.83l1.24-1.26c.37-.36.59-.86.59-1.41 0-1.1-.9-2-2-2s-2 .9-2 2H7c0-2.76 2.24-5 5-5s5 2.24 5 5c0 1.04-.42 1.99-1.07 2.75z"/>
</svg>
@elseif(str_starts_with($service['icon_svg'] ?? '', '<svg'))
{!! $service['icon_svg'] !!}
@else @else
<svg viewBox="0 0 24 24" fill="{{ $service['color'] }}"> <svg viewBox="0 0 24 24" fill="{{ $service['color'] }}">
<path d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4M12,6A6,6 0 0,0 6,12A6,6 0 0,0 12,18A6,6 0 0,0 18,12A6,6 0 0,0 12,6M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8Z" /> <path d="M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2M12,4A8,8 0 0,1 20,12A8,8 0 0,1 12,20A8,8 0 0,1 4,12A8,8 0 0,1 12,4M12,6A6,6 0 0,0 6,12A6,6 0 0,0 12,18A6,6 0 0,0 18,12A6,6 0 0,0 12,6M12,8A4,4 0 0,1 16,12A4,4 0 0,1 12,16A4,4 0 0,1 8,12A4,4 0 0,1 12,8Z" />

View File

@ -8,7 +8,8 @@
// Authentication & Portal landing // Authentication & Portal landing
Route::get('/', [LoginController::class, 'showLoginForm']); Route::get('/', [LoginController::class, 'showLoginForm']);
Route::get('/login', [LoginController::class, 'showLoginForm'])->name('login'); Route::get('/login', [LoginController::class, 'showLoginForm'])->name('login');
Route::post('/login/admin', [LoginController::class, 'adminLogin'])->name('login.admin'); Route::get('/admin/login', [LoginController::class, 'showAdminLoginForm'])->name('admin.login');
Route::post('/admin/login', [LoginController::class, 'adminLogin'])->name('admin.login.submit');
Route::post('/logout', [LoginController::class, 'logout'])->name('logout'); Route::post('/logout', [LoginController::class, 'logout'])->name('logout');
Route::get('/logout', [LoginController::class, 'logout']); // For ease of development/testing via GET Route::get('/logout', [LoginController::class, 'logout']); // For ease of development/testing via GET
@ -24,4 +25,9 @@
// Admin Control Panel (requires admin role) // Admin Control Panel (requires admin role)
Route::middleware(['auth', 'role:admin'])->group(function () { Route::middleware(['auth', 'role:admin'])->group(function () {
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::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');
}); });

View File

@ -0,0 +1,105 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\App;
use App\Models\AppRestriction;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class SecurityAndAdminTest extends TestCase
{
use RefreshDatabase;
/**
* Test admin role middleware restrictions.
*/
public function test_admin_can_access_admin_dashboard_but_not_standard_users(): void
{
$admin = User::create([
'name' => 'Admin User',
'email' => 'admin@company.com',
'role' => 'admin',
]);
$user = User::create([
'name' => 'Standard User',
'email' => 'user@sentientgeeks.com',
'role' => 'user',
]);
// Unauthenticated user is redirected to login
$this->get('/admin/dashboard')
->assertRedirect(route('login'));
// Admin can access admin dashboard
$this->actingAs($admin)
->get('/admin/dashboard')
->assertStatus(200);
// Standard user gets redirected to user dashboard
$this->actingAs($user)
->get('/admin/dashboard')
->assertRedirect(route('dashboard'));
}
/**
* Test blocked users are forced to logout and redirected.
*/
public function test_blocked_user_cannot_access_dashboard_and_gets_logged_out(): void
{
$user = User::create([
'name' => 'Standard User',
'email' => 'user@sentientgeeks.com',
'role' => 'user',
'is_blocked' => true,
]);
// Attempting to access dashboard logs them out and redirects
$this->actingAs($user)
->get('/dashboard')
->assertRedirect(route('login'));
$this->assertFalse(auth()->check());
}
/**
* Test restricted services are hidden from specific users.
*/
public function test_restricted_service_is_not_displayed_to_the_restricted_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'
]);
// Restrict user from Keka HR
AppRestriction::create([
'email' => $user->email,
'app_id' => $app2->id,
]);
$response = $this->actingAs($user)
->get('/dashboard');
$response->assertStatus(200);
$response->assertSee('Outlook');
$response->assertDontSee('Keka HR');
}
}