Refactor: Improve dependency injection for dashboard components and enhance SAML logging
- Removed inline data definitions from multiple dashboard components for roles, users, and connected apps, replacing them with service-based computed properties. - Added comprehensive debug and error-level logging throughout `SamlIdpService` and related controllers to improve traceability of SAML flows. - Cleaned up and centralized SAML-related logic, improving maintainability and error handling consistency. - Deleted unused `hr-permission.blade.php` component to reduce code redundancy.
This commit is contained in:
parent
57f0a4590c
commit
1a7134419c
20
app/Data/Ui/Dashboard/DashboardStatsData.php
Normal file
20
app/Data/Ui/Dashboard/DashboardStatsData.php
Normal file
@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data\Ui\Dashboard;
|
||||
|
||||
use Spatie\LaravelData\Data;
|
||||
|
||||
final class DashboardStatsData extends Data
|
||||
{
|
||||
/**
|
||||
* Create a new DashboardStatsData instance.
|
||||
*/
|
||||
public function __construct(
|
||||
public int $totalUsers,
|
||||
public int $activeRoles,
|
||||
public int $servicesCount,
|
||||
public int $revokedTodayCount,
|
||||
) {}
|
||||
}
|
||||
27
app/Data/Ui/Dashboard/LatestUserData.php
Normal file
27
app/Data/Ui/Dashboard/LatestUserData.php
Normal file
@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Data\Ui\Dashboard;
|
||||
|
||||
use App\Data\Ui\ManageRoles\RoleData;
|
||||
use Spatie\LaravelData\Attributes\DataCollectionOf;
|
||||
use Spatie\LaravelData\{Data, DataCollection};
|
||||
|
||||
final class LatestUserData extends Data
|
||||
{
|
||||
/**
|
||||
* Create a new LatestUserData instance.
|
||||
*
|
||||
* @param DataCollection<int, RoleData>|null $roles
|
||||
*/
|
||||
public function __construct(
|
||||
public string $name,
|
||||
public string $email,
|
||||
public string $initials,
|
||||
public string $registeredAt,
|
||||
/** @var DataCollection<int, RoleData>|null */
|
||||
#[DataCollectionOf(class: RoleData::class)]
|
||||
public ?DataCollection $roles = null,
|
||||
) {}
|
||||
}
|
||||
@ -6,22 +6,37 @@
|
||||
|
||||
use App\Helpers\DashboardRoute;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ResolveDashboardRouteController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
/**
|
||||
* If the user is redirected after SAML SSO Login (Foritfy Response always redirects to dashboard),
|
||||
* redirect them to the SSO endpoint, So that SAML SSO Login can be completed.
|
||||
* If the user is redirected after SAML SSO Login (Fortify Response always redirects to dashboard),
|
||||
* redirect them to the SSO endpoint so that SAML SSO Login can be completed.
|
||||
*/
|
||||
if (session()->has('saml_pending_request')) {
|
||||
$user = $request->user();
|
||||
Log::info('ResolveDashboardRouteController: Intercepted pending SAML SSO request in session. Redirecting authenticated user to complete SAML flow.', [
|
||||
'user_id' => $user?->id,
|
||||
'user_email' => $user?->email,
|
||||
'session_keys' => array_keys(session()->all()),
|
||||
]);
|
||||
|
||||
return redirect()->route('saml.sso');
|
||||
}
|
||||
|
||||
$user = $request->user();
|
||||
$route = DashboardRoute::resolve($user->getRoleNames());
|
||||
|
||||
Log::info('ResolveDashboardRouteController: Resolving dashboard landing route for authenticated user.', [
|
||||
'user_id' => $user?->id,
|
||||
'user_email' => $user?->email,
|
||||
'roles' => $user?->getRoleNames(),
|
||||
'target_route' => $route,
|
||||
]);
|
||||
|
||||
return to_route($route);
|
||||
}
|
||||
}
|
||||
|
||||
@ -20,20 +20,37 @@ public function __construct(private readonly SamlIdpService $samlService) {}
|
||||
*/
|
||||
public function sso(Request $request)
|
||||
{
|
||||
// Retrieve SAMLRequest and RelayState (from request parameters or session fallback)
|
||||
$samlRequest = $request->input('SAMLRequest') ?? session('saml_pending_request');
|
||||
$relayState = $request->input('RelayState') ?? session('saml_pending_relay_state');
|
||||
|
||||
Log::info('SAML SSO Request received.', [
|
||||
'ip' => $request->ip(),
|
||||
'method' => $request->method(),
|
||||
'has_saml_request' => ! empty($samlRequest),
|
||||
'has_relay_state' => ! empty($relayState),
|
||||
'session_has_pending' => session()->has('saml_pending_request'),
|
||||
]);
|
||||
|
||||
if (empty($samlRequest)) {
|
||||
Log::warning('SAML SSO accessed without a SAMLRequest.');
|
||||
Log::warning('SAML SSO access rejected: Missing SAMLRequest parameter.', [
|
||||
'ip' => $request->ip(),
|
||||
]);
|
||||
abort(400, 'Missing SAMLRequest parameter.');
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse the SAMLRequest XML
|
||||
Log::info('Decoding and parsing SAMLRequest XML payload.');
|
||||
$parsed = $this->samlService->parseRequest($samlRequest);
|
||||
Log::info('SAMLRequest successfully parsed.', [
|
||||
'request_id' => $parsed['requestId'],
|
||||
'issuer' => $parsed['issuer'],
|
||||
'acs_url' => $parsed['acsUrl'],
|
||||
]);
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Failed to parse SAMLRequest: '.$e->getMessage());
|
||||
Log::error('SAMLRequest parsing failed.', [
|
||||
'ip' => $request->ip(),
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
abort(400, 'Invalid SAMLRequest: '.$e->getMessage());
|
||||
}
|
||||
|
||||
@ -41,24 +58,35 @@ public function sso(Request $request)
|
||||
$connectedApp = $this->samlService->findConnectedApp($parsed['issuer']);
|
||||
|
||||
if (! $connectedApp) {
|
||||
Log::warning(
|
||||
"SAML SP Issuer [{$parsed['issuer']}] is not registered.",
|
||||
);
|
||||
Log::warning('SAML SSO access rejected: Unregistered Service Provider (EntityID / Issuer).', [
|
||||
'issuer' => $parsed['issuer'],
|
||||
'request_id' => $parsed['requestId'],
|
||||
'ip' => $request->ip(),
|
||||
]);
|
||||
abort(
|
||||
403,
|
||||
"The Service Provider [{$parsed['issuer']}] is not authorized in this system.",
|
||||
);
|
||||
}
|
||||
|
||||
Log::info('SAML ConnectedApp matched successfully.', [
|
||||
'app_id' => $connectedApp->id,
|
||||
'app_name' => $connectedApp->name,
|
||||
'status' => $connectedApp->status?->name,
|
||||
]);
|
||||
|
||||
if ('disconnected' === $connectedApp->status?->name) {
|
||||
Log::warning(
|
||||
"SAML SP Issuer [{$parsed['issuer']}] is registered but disconnected.",
|
||||
);
|
||||
Log::warning('SAML SSO access rejected: Connected App is deactivated.', [
|
||||
'app_id' => $connectedApp->id,
|
||||
'app_name' => $connectedApp->name,
|
||||
'request_id' => $parsed['requestId'],
|
||||
]);
|
||||
abort(
|
||||
403,
|
||||
"The Service Provider [{$connectedApp->name}] is currently deactivated.",
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @var array{
|
||||
* saml?: array{
|
||||
@ -68,19 +96,25 @@ public function sso(Request $request)
|
||||
* } $settings
|
||||
*/
|
||||
$settings = $connectedApp->settings;
|
||||
// Use the ACS URL from the request, falling back to the registered ACS URL
|
||||
$acsUrl = $parsed['acsUrl'] ?: $settings['saml']['acs_url'] ?? null;
|
||||
|
||||
if (empty($acsUrl)) {
|
||||
Log::error(
|
||||
"No Assertion Consumer Service (ACS) URL defined for [{$connectedApp->name}].",
|
||||
);
|
||||
Log::error('SAML SSO failed: Assertion Consumer Service (ACS) URL is undefined.', [
|
||||
'app_id' => $connectedApp->id,
|
||||
'app_name' => $connectedApp->name,
|
||||
'request_id' => $parsed['requestId'],
|
||||
]);
|
||||
abort(400, 'Assertion Consumer Service (ACS) URL is not defined.');
|
||||
}
|
||||
|
||||
// Authenticate the User
|
||||
if (! Auth::check()) {
|
||||
// Store the SAML details in session to complete flow post-login
|
||||
Log::info('SAML SSO guest session intercepted. Persisting request parameters to session and redirecting to login.', [
|
||||
'request_id' => $parsed['requestId'],
|
||||
'relay_state' => $relayState,
|
||||
'ip' => $request->ip(),
|
||||
]);
|
||||
|
||||
session([
|
||||
'saml_pending_request' => $samlRequest,
|
||||
'saml_pending_relay_state' => $relayState,
|
||||
@ -89,10 +123,18 @@ public function sso(Request $request)
|
||||
return redirect()->route('login');
|
||||
}
|
||||
|
||||
// Generate Signed SAML Response
|
||||
/** @var User $user */
|
||||
$user = Auth::user();
|
||||
|
||||
Log::info('SAML SSO user authenticated session detected. Generating signed SAML Response assertion.', [
|
||||
'user_id' => $user->id,
|
||||
'user_email' => $user->email,
|
||||
'app_id' => $connectedApp->id,
|
||||
'app_name' => $connectedApp->name,
|
||||
'acs_url' => $acsUrl,
|
||||
'request_id' => $parsed['requestId'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$samlResponse = $this->samlService->generateResponse(
|
||||
$user,
|
||||
@ -100,13 +142,26 @@ public function sso(Request $request)
|
||||
$parsed['requestId'],
|
||||
$acsUrl,
|
||||
);
|
||||
Log::info('Signed SAML Response assertion successfully generated.');
|
||||
} catch (Throwable $e) {
|
||||
Log::error('Failed to generate SAML Response: '.$e->getMessage());
|
||||
Log::error('SAML Response generation failed.', [
|
||||
'user_id' => $user->id,
|
||||
'app_id' => $connectedApp->id,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
abort(500, 'Cryptographic signing of SAML Response failed.');
|
||||
}
|
||||
|
||||
// Clear pending SAML request from session
|
||||
session()->forget(['saml_pending_request', 'saml_pending_relay_state']);
|
||||
Log::info('Pending SAML session parameters cleared.');
|
||||
|
||||
Log::info('Rendering SAML HTTP-POST auto-submission redirect form.', [
|
||||
'user_id' => $user->id,
|
||||
'app_name' => $connectedApp->name,
|
||||
'acs_url' => $acsUrl,
|
||||
'relay_state' => $relayState,
|
||||
]);
|
||||
|
||||
// Return HTTP-POST Auto-Submission Page
|
||||
return view('saml.post_response', [
|
||||
@ -126,15 +181,23 @@ public function metadata(): Response
|
||||
$idpEntityId = route('saml.metadata');
|
||||
$ssoUrl = route('saml.sso');
|
||||
|
||||
Log::info('SAML IdP Metadata requested.', [
|
||||
'ip' => request()->ip(),
|
||||
'user_agent' => request()->userAgent(),
|
||||
'entity_id' => $idpEntityId,
|
||||
'sso_url' => $ssoUrl,
|
||||
]);
|
||||
|
||||
try {
|
||||
$metadataXml = $this->samlService->generateMetadata(
|
||||
$idpEntityId,
|
||||
$ssoUrl,
|
||||
);
|
||||
Log::info('SAML IdP Metadata XML descriptor generated successfully.');
|
||||
} catch (Throwable $e) {
|
||||
Log::error(
|
||||
'Failed to generate SAML IdP Metadata: '.$e->getMessage(),
|
||||
);
|
||||
Log::error('SAML IdP Metadata XML generation failed.', [
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
abort(
|
||||
500,
|
||||
'Failed to retrieve Identity Provider cryptographic identity.',
|
||||
|
||||
147
app/Services/AdminDashboardService.php
Normal file
147
app/Services/AdminDashboardService.php
Normal file
@ -0,0 +1,147 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Data\Ui\Dashboard\{DashboardStatsData, LatestUserData};
|
||||
use App\Data\Ui\ManageRoles\{RoleData, ServiceData};
|
||||
use App\Data\Users\UserData;
|
||||
use App\Enums\RolesStatusEnum;
|
||||
use App\Models\{ConnectedApp, RestrictedUserPermission, Role, User};
|
||||
use Spatie\LaravelData\DataCollection;
|
||||
|
||||
final class AdminDashboardService
|
||||
{
|
||||
/**
|
||||
* Get statistics for the dashboard.
|
||||
*/
|
||||
public function getStats(): DashboardStatsData
|
||||
{
|
||||
$totalUsers = User::query()->count();
|
||||
$activeRoles = Role::query()->count();
|
||||
$servicesCount = ConnectedApp::query()->count();
|
||||
$revokedTodayCount = RestrictedUserPermission::query()
|
||||
->whereDate('created_at', today())
|
||||
->count();
|
||||
|
||||
return new DashboardStatsData(
|
||||
totalUsers: $totalUsers,
|
||||
activeRoles: $activeRoles,
|
||||
servicesCount: $servicesCount,
|
||||
revokedTodayCount: $revokedTodayCount,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all roles mapped to DTOs for the roles manager.
|
||||
*
|
||||
* @return DataCollection<int, RoleData>
|
||||
*/
|
||||
public function getRoles(): DataCollection
|
||||
{
|
||||
$roles = Role::query()
|
||||
->visible()
|
||||
->with(['users', 'apps'])
|
||||
->get();
|
||||
|
||||
$mappedRoles = $roles->map(function ($role) {
|
||||
/** @var Role $role */
|
||||
/** @var \Illuminate\Database\Eloquent\Collection<int, User> $users */
|
||||
$users = $role->users;
|
||||
/** @var \Illuminate\Database\Eloquent\Collection<int, ConnectedApp> $apps */
|
||||
$apps = $role->apps;
|
||||
|
||||
return new RoleData(
|
||||
name: $role->name,
|
||||
users: UserData::collect($users->map(fn ($user) => new UserData(
|
||||
name: $user->name,
|
||||
email: $user->email,
|
||||
active: true,
|
||||
)), DataCollection::class),
|
||||
services: ServiceData::collect($apps->map(fn ($app) => new ServiceData(
|
||||
name: $app->name,
|
||||
)), DataCollection::class),
|
||||
status: RolesStatusEnum::Active,
|
||||
);
|
||||
});
|
||||
|
||||
return RoleData::collect($mappedRoles, DataCollection::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest registered users.
|
||||
*
|
||||
* @return DataCollection<int, LatestUserData>
|
||||
*/
|
||||
public function getLatestUsers(): DataCollection
|
||||
{
|
||||
$users = User::query()
|
||||
->with(['roles'])
|
||||
->orderBy('created_at', 'desc')
|
||||
->take(5)
|
||||
->get();
|
||||
|
||||
$mappedUsers = $users->map(function ($user) {
|
||||
/** @var User $user */
|
||||
/** @var \Illuminate\Database\Eloquent\Collection<int, Role> $roles */
|
||||
$roles = $user->roles;
|
||||
|
||||
return new LatestUserData(
|
||||
name: $user->name,
|
||||
email: $user->email,
|
||||
initials: $user->initials(),
|
||||
registeredAt: $user->created_at ? $user->created_at->diffForHumans() : 'unknown',
|
||||
roles: RoleData::collect($roles->map(fn ($role) => new RoleData(
|
||||
name: $role->name,
|
||||
users: UserData::collect([], DataCollection::class),
|
||||
services: ServiceData::collect([], DataCollection::class),
|
||||
status: RolesStatusEnum::Active,
|
||||
)), DataCollection::class),
|
||||
);
|
||||
});
|
||||
|
||||
return LatestUserData::collect($mappedUsers, DataCollection::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all user role and service assignments.
|
||||
*
|
||||
* @return DataCollection<int, UserData>
|
||||
*/
|
||||
public function getUserAssignments(): DataCollection
|
||||
{
|
||||
$users = User::query()
|
||||
->with(['roles.apps'])
|
||||
->orderBy('id')
|
||||
->get();
|
||||
|
||||
$mappedUsers = $users->map(function ($user) {
|
||||
/** @var User $user */
|
||||
/** @var \Illuminate\Database\Eloquent\Collection<int, Role> $roles */
|
||||
$roles = $user->roles;
|
||||
|
||||
return new UserData(
|
||||
name: $user->name,
|
||||
email: $user->email,
|
||||
active: true,
|
||||
roles: RoleData::collect($roles->map(function ($role) {
|
||||
/** @var Role $role */
|
||||
/** @var \Illuminate\Database\Eloquent\Collection<int, ConnectedApp> $apps */
|
||||
$apps = $role->apps;
|
||||
|
||||
return new RoleData(
|
||||
name: $role->name,
|
||||
users: UserData::collect([], DataCollection::class),
|
||||
services: ServiceData::collect($apps->map(fn ($app) => new ServiceData(
|
||||
name: $app->name,
|
||||
)), DataCollection::class),
|
||||
status: RolesStatusEnum::Active,
|
||||
);
|
||||
}), DataCollection::class),
|
||||
);
|
||||
});
|
||||
|
||||
return UserData::collect($mappedUsers, DataCollection::class);
|
||||
}
|
||||
}
|
||||
@ -25,13 +25,25 @@ class SamlIdpService
|
||||
*/
|
||||
public function parseRequest(string $samlRequest): array
|
||||
{
|
||||
Log::debug('SamlIdpService: Parsing incoming SAMLRequest.', [
|
||||
'payload_length' => mb_strlen($samlRequest),
|
||||
]);
|
||||
|
||||
$decoded = base64_decode($samlRequest, true);
|
||||
if (false === $decoded) {
|
||||
Log::error('SamlIdpService: Failed to base64 decode SAMLRequest.');
|
||||
throw new Exception('Failed to base64 decode SAMLRequest.');
|
||||
}
|
||||
|
||||
// Try deflated (GET Redirect binding), fallback to raw XML (POST binding)
|
||||
$xml = @gzinflate($decoded) ?: $decoded;
|
||||
$inflated = @gzinflate($decoded);
|
||||
if (false !== $inflated) {
|
||||
Log::debug('SamlIdpService: SAMLRequest successfully inflated using gzip/deflate.');
|
||||
$xml = $inflated;
|
||||
} else {
|
||||
Log::debug('SamlIdpService: SAMLRequest gzip inflation failed/skipped; falling back to raw XML.');
|
||||
$xml = $decoded;
|
||||
}
|
||||
|
||||
$dom = new DOMDocument();
|
||||
$dom->preserveWhiteSpace = false;
|
||||
@ -39,9 +51,15 @@ public function parseRequest(string $samlRequest): array
|
||||
// Securely handle XML loading without using '@' suppression
|
||||
libxml_use_internal_errors(true);
|
||||
$loaded = $dom->loadXML($xml);
|
||||
$xmlErrors = libxml_get_errors();
|
||||
libxml_clear_errors();
|
||||
|
||||
if (! $loaded) {
|
||||
$formattedErrors = array_map(fn ($err) => mb_trim($err->message), $xmlErrors);
|
||||
Log::error('SamlIdpService: Failed to load SAMLRequest XML.', [
|
||||
'errors' => $formattedErrors,
|
||||
'xml_snippet' => mb_substr($xml, 0, 500),
|
||||
]);
|
||||
throw new Exception('Failed to load SAMLRequest XML.');
|
||||
}
|
||||
|
||||
@ -52,6 +70,12 @@ public function parseRequest(string $samlRequest): array
|
||||
$requestId = $dom->documentElement->getAttribute('ID');
|
||||
$acsUrl = $dom->documentElement->getAttribute('AssertionConsumerServiceURL');
|
||||
|
||||
Log::info('SamlIdpService: SAMLRequest XML parsed successfully.', [
|
||||
'requestId' => $requestId,
|
||||
'issuer' => $issuer,
|
||||
'acsUrl' => $acsUrl,
|
||||
]);
|
||||
|
||||
return [
|
||||
'requestId' => $requestId,
|
||||
'issuer' => $issuer,
|
||||
@ -64,9 +88,23 @@ public function parseRequest(string $samlRequest): array
|
||||
*/
|
||||
public function findConnectedApp(string $issuer): ?ConnectedApp
|
||||
{
|
||||
return ConnectedApp::query()
|
||||
Log::debug('SamlIdpService: Querying ConnectedApp database for entity ID.', ['issuer' => $issuer]);
|
||||
$app = ConnectedApp::query()
|
||||
->where('settings->saml->entity_id', $issuer)
|
||||
->first();
|
||||
|
||||
if ($app) {
|
||||
Log::info('SamlIdpService: ConnectedApp found for issuer.', [
|
||||
'issuer' => $issuer,
|
||||
'app_id' => $app->id,
|
||||
'app_name' => $app->name,
|
||||
'status' => $app->status->name ?? 'unknown',
|
||||
]);
|
||||
} else {
|
||||
Log::warning('SamlIdpService: No ConnectedApp found matching issuer entity ID.', ['issuer' => $issuer]);
|
||||
}
|
||||
|
||||
return $app;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -74,10 +112,14 @@ public function findConnectedApp(string $issuer): ?ConnectedApp
|
||||
*/
|
||||
public function generateMetadata(string $idpEntityId, string $ssoUrl): string
|
||||
{
|
||||
Log::info('SamlIdpService: Generating SAML IdP metadata XML.', [
|
||||
'idpEntityId' => $idpEntityId,
|
||||
'ssoUrl' => $ssoUrl,
|
||||
]);
|
||||
$certPem = $this->getPublicCertificate();
|
||||
$certClean = $this->cleanPem($certPem);
|
||||
|
||||
return mb_trim(<<<XML
|
||||
$metadata = mb_trim(<<<XML
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<EntityDescriptor entityID="{$idpEntityId}" xmlns="urn:oasis:names:tc:SAML:2.0:metadata">
|
||||
<IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
@ -93,6 +135,12 @@ public function generateMetadata(string $idpEntityId, string $ssoUrl): string
|
||||
</IDPSSODescriptor>
|
||||
</EntityDescriptor>
|
||||
XML);
|
||||
|
||||
Log::info('SamlIdpService: SAML IdP metadata XML successfully generated.', [
|
||||
'length' => mb_strlen($metadata),
|
||||
]);
|
||||
|
||||
return $metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -101,10 +149,20 @@ public function generateMetadata(string $idpEntityId, string $ssoUrl): string
|
||||
private function getPublicCertificate(): string
|
||||
{
|
||||
$path = storage_path('app/saml/idp.crt');
|
||||
Log::debug('SamlIdpService: Loading public certificate.', ['path' => $path]);
|
||||
try {
|
||||
return File::get($path);
|
||||
$cert = File::get($path);
|
||||
Log::debug('SamlIdpService: Public certificate loaded successfully.', [
|
||||
'path' => $path,
|
||||
'length' => mb_strlen($cert),
|
||||
]);
|
||||
|
||||
return $cert;
|
||||
} catch (Exception $e) {
|
||||
Log::error("SAML public certificate not found at [$path].", ['message' => $e->getMessage()]);
|
||||
Log::error("SamlIdpService: SAML public certificate not found at [$path].", [
|
||||
'path' => $path,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
throw new RuntimeException("SAML public certificate not found at [$path].", code: 500);
|
||||
}
|
||||
}
|
||||
@ -139,6 +197,18 @@ public function generateResponse(User $user, ConnectedApp $app, string $requestI
|
||||
$responseId = '_'.Str::uuid()->toString();
|
||||
$assertionId = '_'.Str::uuid()->toString();
|
||||
|
||||
Log::info('SamlIdpService: Initiating SAML Response generation.', [
|
||||
'user_id' => $user->id,
|
||||
'user_email' => $user->email,
|
||||
'app_id' => $app->id,
|
||||
'app_name' => $app->name,
|
||||
'sp_entity_id' => $spEntityId,
|
||||
'request_id' => $requestId,
|
||||
'acs_url' => $acsUrl,
|
||||
'response_id' => $responseId,
|
||||
'assertion_id' => $assertionId,
|
||||
]);
|
||||
|
||||
// Prepare Timestamps
|
||||
$now = time();
|
||||
$timestamps = [
|
||||
@ -147,10 +217,13 @@ public function generateResponse(User $user, ConnectedApp $app, string $requestI
|
||||
'expire' => gmdate("Y-m-d\TH:i:s\Z", $now + 300),
|
||||
];
|
||||
|
||||
Log::debug('SamlIdpService: Computed SAML assertion timestamps.', $timestamps);
|
||||
|
||||
$dom = new DOMDocument('1.0', 'UTF-8');
|
||||
$dom->preserveWhiteSpace = false;
|
||||
|
||||
// 1. Create Base Response
|
||||
Log::debug('SamlIdpService: Constructing samlp:Response element.');
|
||||
$response = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:protocol', 'samlp:Response');
|
||||
$response->setAttribute('ID', $responseId);
|
||||
$response->setAttribute('InResponseTo', $requestId);
|
||||
@ -171,6 +244,7 @@ public function generateResponse(User $user, ConnectedApp $app, string $requestI
|
||||
$response->appendChild($status);
|
||||
|
||||
// 2. Create Assertion
|
||||
Log::debug('SamlIdpService: Constructing saml:Assertion element.');
|
||||
$assertion = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:assertion', 'saml:Assertion');
|
||||
$assertion->setAttribute('ID', $assertionId);
|
||||
$assertion->setAttribute('Version', '2.0');
|
||||
@ -178,6 +252,7 @@ public function generateResponse(User $user, ConnectedApp $app, string $requestI
|
||||
$assertion->appendChild($dom->createElement('saml:Issuer', $idpEntityId));
|
||||
|
||||
// 3. Build Assertion Sub-components
|
||||
Log::debug('SamlIdpService: Appending assertion components (Subject, Conditions, AuthnStatement, Attributes).');
|
||||
$this->buildSubject($dom, $assertion, $user, $requestId, $acsUrl, $timestamps['expire']);
|
||||
$this->buildConditions($dom, $assertion, $spEntityId, $timestamps['notBefore'], $timestamps['expire']);
|
||||
$this->buildAuthnStatement($dom, $assertion, $timestamps['issue'], $assertionId);
|
||||
@ -187,9 +262,20 @@ public function generateResponse(User $user, ConnectedApp $app, string $requestI
|
||||
$dom->appendChild($response);
|
||||
|
||||
// 4. Sign Assertion & Return Base64 payload
|
||||
Log::info('SamlIdpService: Invoking cryptographic signing for the Assertion node.');
|
||||
$this->signAssertion($dom, $assertion);
|
||||
|
||||
return base64_encode($dom->saveXML());
|
||||
$xml = $dom->saveXML();
|
||||
Log::debug('SamlIdpService: SAML Response XML generated successfully.', [
|
||||
'xml_length' => mb_strlen($xml),
|
||||
]);
|
||||
|
||||
$base64Response = base64_encode($xml);
|
||||
Log::info('SamlIdpService: SAML Response base64 encoded successfully.', [
|
||||
'base64_length' => mb_strlen($base64Response),
|
||||
]);
|
||||
|
||||
return $base64Response;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -207,6 +293,13 @@ private function buildSubject(
|
||||
): void {
|
||||
$immutableId = $user->immutable_id ?? base64_encode((string) $user->id);
|
||||
|
||||
Log::debug('SamlIdpService: Constructing saml:Subject component.', [
|
||||
'user_id' => $user->id,
|
||||
'has_explicit_immutable_id' => ! empty($user->immutable_id),
|
||||
'resolved_immutable_id' => $immutableId,
|
||||
'nameid_format' => 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent',
|
||||
]);
|
||||
|
||||
$subject = $dom->createElement('saml:Subject');
|
||||
$nameId = $dom->createElement('saml:NameID', $immutableId);
|
||||
$nameId->setAttribute('Format', 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent');
|
||||
@ -237,6 +330,12 @@ private function buildConditions(
|
||||
string $notBefore,
|
||||
string $expireInstant
|
||||
): void {
|
||||
Log::debug('SamlIdpService: Constructing saml:Conditions component.', [
|
||||
'sp_entity_id' => $spEntityId,
|
||||
'not_before' => $notBefore,
|
||||
'not_on_or_after' => $expireInstant,
|
||||
]);
|
||||
|
||||
$conditions = $dom->createElement('saml:Conditions');
|
||||
$conditions->setAttribute('NotBefore', $notBefore);
|
||||
$conditions->setAttribute('NotOnOrAfter', $expireInstant);
|
||||
@ -259,6 +358,12 @@ private function buildAuthnStatement(
|
||||
string $issueInstant,
|
||||
string $assertionId
|
||||
): void {
|
||||
Log::debug('SamlIdpService: Constructing saml:AuthnStatement component.', [
|
||||
'authn_instant' => $issueInstant,
|
||||
'session_index' => $assertionId,
|
||||
'authn_class' => 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport',
|
||||
]);
|
||||
|
||||
$authnStatement = $dom->createElement('saml:AuthnStatement');
|
||||
$authnStatement->setAttribute('AuthnInstant', $issueInstant);
|
||||
$authnStatement->setAttribute('SessionIndex', $assertionId);
|
||||
@ -290,6 +395,12 @@ private function buildAttributeStatement(DOMDocument $dom, DOMElement $assertion
|
||||
'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress' => $user->email,
|
||||
];
|
||||
|
||||
Log::debug('SamlIdpService: Constructing saml:AttributeStatement (claims mapping).', [
|
||||
'user_id' => $user->id,
|
||||
'claims_count' => count($claims),
|
||||
'claims' => array_keys($claims),
|
||||
]);
|
||||
|
||||
foreach ($claims as $name => $value) {
|
||||
$this->addAttribute($dom, $statement, $name, (string) $value);
|
||||
}
|
||||
@ -304,6 +415,11 @@ private function buildAttributeStatement(DOMDocument $dom, DOMElement $assertion
|
||||
*/
|
||||
private function addAttribute(DOMDocument $dom, DOMElement $statement, string $name, string $value): void
|
||||
{
|
||||
Log::debug('SamlIdpService: Mapping claim attribute.', [
|
||||
'name' => $name,
|
||||
'value' => $value,
|
||||
]);
|
||||
|
||||
$attribute = $dom->createElement('saml:Attribute');
|
||||
$attribute->setAttribute('Name', $name);
|
||||
$attribute->setAttribute('NameFormat', 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic');
|
||||
@ -318,14 +434,22 @@ private function addAttribute(DOMDocument $dom, DOMElement $statement, string $n
|
||||
*/
|
||||
private function signAssertion(DOMDocument $dom, DOMElement|DOMDocument $assertion): void
|
||||
{
|
||||
Log::info('SamlIdpService: Starting cryptographic assertion signing.', [
|
||||
'assertion_id' => $assertion->getAttribute('ID'),
|
||||
]);
|
||||
|
||||
try {
|
||||
$privateKeyPem = $this->getPrivateKey();
|
||||
$certPem = $this->getPublicCertificate();
|
||||
|
||||
Log::debug('SamlIdpService: Instantiating XMLSecurityDSig for Assertion.');
|
||||
$objDSig = new XMLSecurityDSig();
|
||||
$objDSig->setCanonicalMethod(XMLSecurityDSig::C14N);
|
||||
$assertion->setIdAttribute('ID', true);
|
||||
|
||||
Log::debug('SamlIdpService: Adding reference to XMLSignature.', [
|
||||
'algorithm' => 'SHA256',
|
||||
]);
|
||||
$objDSig->addReference(
|
||||
$assertion,
|
||||
XMLSecurityDSig::SHA256,
|
||||
@ -336,13 +460,18 @@ private function signAssertion(DOMDocument $dom, DOMElement|DOMDocument $asserti
|
||||
['id_name' => 'ID']
|
||||
);
|
||||
|
||||
Log::debug('SamlIdpService: Loading RSA-SHA256 private key.');
|
||||
$objKey = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, ['type' => 'private']);
|
||||
$objKey->loadKey($privateKeyPem);
|
||||
|
||||
Log::debug('SamlIdpService: Computing signature value.');
|
||||
$objDSig->sign($objKey, $assertion);
|
||||
|
||||
Log::debug('SamlIdpService: Appending X509 certificate to signature.');
|
||||
$objDSig->add509Cert($certPem);
|
||||
|
||||
// Enforce SAML Schema order compliance: Signature node must precede Subject.
|
||||
Log::debug('SamlIdpService: Ordering DOM nodes. Moving Signature before Subject to satisfy SAML XSD schema.');
|
||||
$signatureNode = $assertion->getElementsByTagNameNS(
|
||||
'http://www.w3.org/2000/09/xmldsig#',
|
||||
'Signature'
|
||||
@ -354,9 +483,18 @@ private function signAssertion(DOMDocument $dom, DOMElement|DOMDocument $asserti
|
||||
|
||||
if ($signatureNode && $subjectNode) {
|
||||
$assertion->insertBefore($signatureNode, $subjectNode);
|
||||
Log::debug('SamlIdpService: Re-ordered nodes: Signature is now placed before Subject.');
|
||||
} else {
|
||||
Log::warning('SamlIdpService: Failed to re-order nodes; Signature or Subject node not found.', [
|
||||
'signature_found' => ! empty($signatureNode),
|
||||
'subject_found' => ! empty($subjectNode),
|
||||
]);
|
||||
}
|
||||
} catch (Exception $e) {
|
||||
Log::error('SAML assertion signing failed.', ['message' => $e->getMessage()]);
|
||||
Log::error('SamlIdpService: Cryptographic signing failed.', [
|
||||
'message' => $e->getMessage(),
|
||||
'trace' => $e->getTraceAsString(),
|
||||
]);
|
||||
throw new RuntimeException('SAML assertion signing failed.', code: 500);
|
||||
}
|
||||
}
|
||||
@ -369,10 +507,20 @@ private function signAssertion(DOMDocument $dom, DOMElement|DOMDocument $asserti
|
||||
private function getPrivateKey(): string
|
||||
{
|
||||
$path = storage_path('app/saml/idp.key');
|
||||
Log::debug('SamlIdpService: Loading private key.', ['path' => $path]);
|
||||
try {
|
||||
return File::get($path);
|
||||
$key = File::get($path);
|
||||
Log::debug('SamlIdpService: Private key loaded successfully.', [
|
||||
'path' => $path,
|
||||
'length' => mb_strlen($key),
|
||||
]);
|
||||
|
||||
return $key;
|
||||
} catch (Exception $e) {
|
||||
Log::error("SAML private key not found at [$path].", ['message' => $e->getMessage()]);
|
||||
Log::error("SamlIdpService: SAML private key not found at [$path].", [
|
||||
'path' => $path,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
throw new RuntimeException("SAML private key not found at [$path].", code: 500);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,18 +1,20 @@
|
||||
@props(['stats'])
|
||||
|
||||
<grid class="grid grid-cols-4 gap-x-4">
|
||||
<x-shared.card class="p-4">
|
||||
<p class="text-sm text-gray-400">Total Users</p>
|
||||
<p class="text-2xl">284</p>
|
||||
<p class="text-2xl">{{ $stats->totalUsers }}</p>
|
||||
</x-shared.card>
|
||||
<x-shared.card class="p-4">
|
||||
<p class="text-sm text-gray-400">Active Roles</p>
|
||||
<p class="text-2xl">4</p>
|
||||
<p class="text-2xl">{{ $stats->activeRoles }}</p>
|
||||
</x-shared.card>
|
||||
<x-shared.card class="p-4">
|
||||
<p class="text-sm text-gray-400">Services</p>
|
||||
<p class="text-2xl">8</p>
|
||||
<p class="text-2xl">{{ $stats->servicesCount }}</p>
|
||||
</x-shared.card>
|
||||
<x-shared.card class="p-4">
|
||||
<p class="text-sm text-gray-400">Revoked Today</p>
|
||||
<p class="text-2xl">7</p>
|
||||
<p class="text-2xl">{{ $stats->revokedTodayCount }}</p>
|
||||
</x-shared.card>
|
||||
</grid>
|
||||
|
||||
@ -12,6 +12,13 @@
|
||||
use Spatie\LaravelData\PaginatedDataCollection;
|
||||
|
||||
new class extends Component {
|
||||
private ConnectedAppService $service;
|
||||
|
||||
public function boot(ConnectedAppService $service): void
|
||||
{
|
||||
$this->service = $service;
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
}
|
||||
@ -22,8 +29,7 @@ public function mount(): void
|
||||
#[Computed]
|
||||
public function getConnectedApps(): PaginatedDataCollection
|
||||
{
|
||||
$service = app(ConnectedAppService::class);
|
||||
return $service->getAll();
|
||||
return $this->service->getAll();
|
||||
}
|
||||
};
|
||||
?>
|
||||
|
||||
@ -1,60 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Data\Ui\ManageRoles\PermissionData;
|
||||
use App\Data\Ui\TableHeader;
|
||||
use Livewire\Component;
|
||||
use Spatie\LaravelData\Attributes\DataCollectionOf;
|
||||
use Spatie\LaravelData\DataCollection;
|
||||
|
||||
new class extends Component {
|
||||
#[DataCollectionOf(TableHeader::class)]
|
||||
public DataCollection $header;
|
||||
|
||||
#[DataCollectionOf(PermissionData::class)]
|
||||
public DataCollection $rolePermissions;
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->header = TableHeader::collect([
|
||||
new TableHeader('name', 'Permission'),
|
||||
new TableHeader('view', 'View'),
|
||||
new TableHeader('create', 'Create'),
|
||||
new TableHeader('update', 'Edit'),
|
||||
new TableHeader('delete', 'Delete'),
|
||||
], DataCollection::class);
|
||||
|
||||
$this->rolePermissions = PermissionData::collect([
|
||||
new PermissionData('Outlook - emails', true, true, false, false),
|
||||
new PermissionData('Teams - Messages', true, false, true, true),
|
||||
], DataCollection::class);
|
||||
}
|
||||
};
|
||||
?>
|
||||
|
||||
<div>
|
||||
<x-shared.card title="Role Permission - HR Manager" icon="lucide-lock" class="p-0">
|
||||
<x-slot:actions>
|
||||
<x-shared.button icon="lucide-x">
|
||||
{{ __('Revoke All') }}
|
||||
</x-shared.button>
|
||||
<x-shared.button icon="lucide-check">
|
||||
{{ __('Save') }}
|
||||
</x-shared.button>
|
||||
</x-slot:actions>
|
||||
|
||||
<x-mary-table :headers="$header->toArray()" :rows="$rolePermissions->items()">
|
||||
@scope('cell_view', $row)
|
||||
<x-shared.toggle :checked="$row->view"/>
|
||||
@endscope
|
||||
@scope('cell_create', $row)
|
||||
<x-shared.toggle :checked="$row->create"/>
|
||||
@endscope
|
||||
@scope('cell_update', $row)
|
||||
<x-shared.toggle :checked="$row->update"/>
|
||||
@endscope
|
||||
@scope('cell_delete', $row)
|
||||
<x-shared.toggle :checked="$row->update"/>
|
||||
@endscope
|
||||
</x-mary-table>
|
||||
</x-shared.card>
|
||||
</div>
|
||||
@ -1,9 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Data\Ui\ManageRoles\{RoleData, ServiceData};
|
||||
use App\Data\Ui\ManageRoles\RoleData;
|
||||
use App\Data\Ui\TableHeader;
|
||||
use App\Data\Users\UserData;
|
||||
use App\Enums\RolesStatusEnum;
|
||||
use App\Services\AdminDashboardService;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
use Spatie\LaravelData\Attributes\DataCollectionOf;
|
||||
use Spatie\LaravelData\DataCollection;
|
||||
@ -12,8 +12,23 @@
|
||||
#[DataCollectionOf(TableHeader::class)]
|
||||
public DataCollection $header;
|
||||
|
||||
#[DataCollectionOf(class: RoleData::class)]
|
||||
public DataCollection $roles;
|
||||
private AdminDashboardService $service;
|
||||
|
||||
public function boot(AdminDashboardService $service): void
|
||||
{
|
||||
$this->service = $service;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get roles for the manager.
|
||||
*
|
||||
* @return DataCollection<int, RoleData>
|
||||
*/
|
||||
#[Computed]
|
||||
public function roles(): DataCollection
|
||||
{
|
||||
return $this->service->getRoles();
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
@ -35,38 +50,6 @@ public function mount(): void
|
||||
"label" => "Status",
|
||||
],
|
||||
], DataCollection::class);
|
||||
$this->roles = RoleData::collect(
|
||||
[
|
||||
new RoleData(
|
||||
name: "Super Admin",
|
||||
|
||||
users: UserData::collect(
|
||||
[
|
||||
new UserData(
|
||||
name: "Kushal Saha",
|
||||
email: "kushal@example.com",
|
||||
),
|
||||
new UserData(
|
||||
name: "Test Example",
|
||||
email: "test@example.com",
|
||||
),
|
||||
],
|
||||
DataCollection::class,
|
||||
),
|
||||
|
||||
services: ServiceData::collect(
|
||||
[
|
||||
new ServiceData(name: "Teams"),
|
||||
new ServiceData(name: "Outlook"),
|
||||
],
|
||||
DataCollection::class,
|
||||
),
|
||||
|
||||
status: RolesStatusEnum::Active,
|
||||
),
|
||||
],
|
||||
DataCollection::class,
|
||||
);
|
||||
}
|
||||
};
|
||||
?>
|
||||
@ -80,7 +63,7 @@ public function mount(): void
|
||||
</x-shared.button>
|
||||
</x-slot:actions>
|
||||
|
||||
<x-mary-table :headers="$header->toArray()" :rows="$roles->items()">
|
||||
<x-mary-table :headers="$header->toArray()" :rows="$this->roles->items()">
|
||||
@scope('cell_users', $role)
|
||||
@php /** @var RoleData $role */ @endphp
|
||||
<div class="flex -space-x-3">
|
||||
|
||||
@ -2,9 +2,8 @@
|
||||
|
||||
use App\Data\Ui\TableHeader;
|
||||
use App\Data\Users\UserData;
|
||||
use App\Data\Ui\ManageRoles\RoleData;
|
||||
use App\Data\Ui\ManageRoles\ServiceData;
|
||||
use App\Enums\RolesStatusEnum;
|
||||
use App\Services\AdminDashboardService;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
use Spatie\LaravelData\Attributes\DataCollectionOf;
|
||||
use Spatie\LaravelData\DataCollection;
|
||||
@ -14,9 +13,23 @@
|
||||
#[DataCollectionOf(TableHeader::class)]
|
||||
public DataCollection $header;
|
||||
|
||||
/** @var DataCollection<int, UserData>|null */
|
||||
#[DataCollectionOf(UserData::class)]
|
||||
public ?DataCollection $users = null;
|
||||
private AdminDashboardService $service;
|
||||
|
||||
public function boot(AdminDashboardService $service): void
|
||||
{
|
||||
$this->service = $service;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user assignments.
|
||||
*
|
||||
* @return DataCollection<int, UserData>
|
||||
*/
|
||||
#[Computed]
|
||||
public function users(): DataCollection
|
||||
{
|
||||
return $this->service->getUserAssignments();
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
@ -26,66 +39,6 @@ public function mount(): void
|
||||
new TableHeader('role.service', 'Service Access'),
|
||||
new TableHeader('status', 'Status'),
|
||||
], DataCollection::class);
|
||||
|
||||
$this->users = UserData::collect([
|
||||
new UserData(
|
||||
name: 'Priya Rao',
|
||||
email: 'priya@example.com',
|
||||
active: true,
|
||||
roles: RoleData::collect([
|
||||
new RoleData(
|
||||
name: 'Super Admin',
|
||||
users: UserData::collect([], DataCollection::class),
|
||||
services: ServiceData::collect([
|
||||
new ServiceData(name: 'AWS Core'),
|
||||
new ServiceData(name: 'GitHub Enterprise'),
|
||||
], DataCollection::class),
|
||||
status: RolesStatusEnum::Active,
|
||||
),
|
||||
new RoleData(
|
||||
name: 'HR Manager',
|
||||
users: UserData::collect([], DataCollection::class),
|
||||
services: ServiceData::collect([
|
||||
new ServiceData(name: 'Keka'),
|
||||
new ServiceData(name: 'Outlook'),
|
||||
], DataCollection::class),
|
||||
status: RolesStatusEnum::Active,
|
||||
)
|
||||
], DataCollection::class)
|
||||
),
|
||||
|
||||
new UserData(
|
||||
name: 'Kushal Saha',
|
||||
email: 'kushal@example.com',
|
||||
active: true,
|
||||
roles: RoleData::collect([
|
||||
new RoleData(
|
||||
name: 'Backend Developer',
|
||||
users: UserData::collect([], DataCollection::class),
|
||||
services: ServiceData::collect([
|
||||
new ServiceData(name: 'Forge'),
|
||||
], DataCollection::class),
|
||||
status: RolesStatusEnum::Active,
|
||||
)
|
||||
], DataCollection::class)
|
||||
),
|
||||
|
||||
new UserData(
|
||||
name: 'Alex Johnson',
|
||||
email: 'alex@example.com',
|
||||
active: false,
|
||||
roles: RoleData::collect([
|
||||
new RoleData(
|
||||
name: 'Guest Viewer',
|
||||
users: UserData::collect([], DataCollection::class),
|
||||
services: ServiceData::collect([
|
||||
new ServiceData(name: 'Analytics Dashboard'),
|
||||
], DataCollection::class),
|
||||
status: RolesStatusEnum::Active,
|
||||
)
|
||||
], DataCollection::class)
|
||||
)
|
||||
], DataCollection::class);
|
||||
}
|
||||
};
|
||||
?>
|
||||
@ -98,7 +51,7 @@ public function mount(): void
|
||||
</x-shared.button>
|
||||
</x-slot:actions>
|
||||
|
||||
<x-mary-table :headers="$header->toArray()" :rows="$users->items()">
|
||||
<x-mary-table :headers="$header->toArray()" :rows="$this->users->items()">
|
||||
@scope('cell_name', $row)
|
||||
<x-shared.avatar :placeholder="$row->name" :title="$row->name" :subtitle="$row->email"/>
|
||||
@endscope
|
||||
|
||||
71
resources/views/components/dashboard/users/⚡latest.blade.php
Normal file
71
resources/views/components/dashboard/users/⚡latest.blade.php
Normal file
@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
use App\Data\Ui\Dashboard\LatestUserData;
|
||||
use App\Data\Ui\TableHeader;
|
||||
use App\Services\AdminDashboardService;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Component;
|
||||
use Spatie\LaravelData\Attributes\DataCollectionOf;
|
||||
use Spatie\LaravelData\DataCollection;
|
||||
|
||||
new class extends Component {
|
||||
/** @var DataCollection<int, TableHeader> */
|
||||
#[DataCollectionOf(TableHeader::class)]
|
||||
public DataCollection $header;
|
||||
|
||||
private AdminDashboardService $service;
|
||||
|
||||
public function boot(AdminDashboardService $service): void
|
||||
{
|
||||
$this->service = $service;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the latest users.
|
||||
*
|
||||
* @return DataCollection<int, LatestUserData>
|
||||
*/
|
||||
#[Computed]
|
||||
public function users(): DataCollection
|
||||
{
|
||||
return $this->service->getLatestUsers();
|
||||
}
|
||||
|
||||
public function mount(): void
|
||||
{
|
||||
$this->header = TableHeader::collect([
|
||||
new TableHeader('name', 'User'),
|
||||
new TableHeader('registeredAt', 'Registered'),
|
||||
new TableHeader('roles', 'Roles'),
|
||||
], DataCollection::class);
|
||||
}
|
||||
};
|
||||
?>
|
||||
|
||||
<div>
|
||||
<x-shared.card title="Latest Users" icon="lucide-user-plus" class="p-0">
|
||||
<x-mary-table :headers="$header->toArray()" :rows="$this->users->items()">
|
||||
@scope('cell_name', $row)
|
||||
<x-shared.avatar :placeholder="$row->name" :title="$row->name" :subtitle="$row->email"/>
|
||||
@endscope
|
||||
|
||||
@scope('cell_registeredAt', $row)
|
||||
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ $row->registeredAt }}
|
||||
</span>
|
||||
@endscope
|
||||
|
||||
@scope('cell_roles', $row)
|
||||
<div class="flex space-x-1.5 flex-wrap gap-y-1">
|
||||
@forelse ($row->roles as $role)
|
||||
<x-shared.badge class="bg-indigo-50 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-400 border border-indigo-100 dark:border-indigo-800/40 text-xs font-semibold">
|
||||
{{ $role->name }}
|
||||
</x-shared.badge>
|
||||
@empty
|
||||
<span class="text-xs text-gray-400 italic">No roles assigned</span>
|
||||
@endforelse
|
||||
</div>
|
||||
@endscope
|
||||
</x-mary-table>
|
||||
</x-shared.card>
|
||||
</div>
|
||||
@ -1,6 +1,8 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Routing\Attributes\Controllers\Middleware;
|
||||
use App\Services\AdminDashboardService;
|
||||
use App\Data\Ui\Dashboard\DashboardStatsData;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\Layout;
|
||||
use Livewire\Attributes\Title;
|
||||
use Livewire\Component;
|
||||
@ -9,14 +11,26 @@
|
||||
#[Layout('layouts.app.sidebar')]
|
||||
#[Title('Dashboard')]
|
||||
class extends Component {
|
||||
private AdminDashboardService $service;
|
||||
|
||||
public function boot(AdminDashboardService $service): void
|
||||
{
|
||||
$this->service = $service;
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function stats(): DashboardStatsData
|
||||
{
|
||||
return $this->service->getStats();
|
||||
}
|
||||
};
|
||||
?>
|
||||
|
||||
<div class="flex h-full w-full flex-1 flex-col gap-4 rounded-xl">
|
||||
<x-dashboard-stats/>
|
||||
<x-dashboard-stats :stats="$this->stats"/>
|
||||
<livewire:dashboard.connected-apps/>
|
||||
<livewire:dashboard.roles.manager/>
|
||||
<livewire:dashboard.roles.hr-permission/>
|
||||
<livewire:dashboard.users.latest/>
|
||||
<livewire:dashboard.users.assignments/>
|
||||
</div>
|
||||
|
||||
|
||||
@ -2,17 +2,47 @@
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\{Role, User};
|
||||
|
||||
test('guests are redirected to the login page', function (): void {
|
||||
$response = $this->get(route('dashboard'));
|
||||
$response->assertRedirect(route('login'));
|
||||
});
|
||||
|
||||
test('authenticated users can visit the dashboard', function (): void {
|
||||
test('authenticated users without admin roles are redirected to the user dashboard', function (): void {
|
||||
$user = User::factory()->create();
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('dashboard'));
|
||||
$response->assertRedirect(route('dashboard.user'));
|
||||
});
|
||||
|
||||
test('authenticated admin users are redirected to the admin dashboard', function (): void {
|
||||
Role::findOrCreate('admin', 'web');
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole('admin');
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('dashboard'));
|
||||
$response->assertRedirect(route('dashboard.admin'));
|
||||
});
|
||||
|
||||
test('authenticated users can visit the user dashboard', function (): void {
|
||||
Role::findOrCreate('user', 'web');
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole('user');
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('dashboard.user'));
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
test('authenticated admin users can visit the admin dashboard', function (): void {
|
||||
Role::findOrCreate('admin', 'web');
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole('admin');
|
||||
$this->actingAs($user);
|
||||
|
||||
$response = $this->get(route('dashboard.admin'));
|
||||
$response->assertOk();
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user