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:
= 2026-06-01 13:05:59 +00:00
parent 57f0a4590c
commit 1a7134419c
14 changed files with 626 additions and 207 deletions

View 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,
) {}
}

View 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,
) {}
}

View File

@ -6,22 +6,37 @@
use App\Helpers\DashboardRoute; use App\Helpers\DashboardRoute;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;
class ResolveDashboardRouteController extends Controller class ResolveDashboardRouteController extends Controller
{ {
public function __invoke(Request $request) public function __invoke(Request $request)
{ {
/** /**
* If the user is redirected after SAML SSO Login (Foritfy Response always redirects to dashboard), * 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. * redirect them to the SSO endpoint so that SAML SSO Login can be completed.
*/ */
if (session()->has('saml_pending_request')) { 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'); return redirect()->route('saml.sso');
} }
$user = $request->user(); $user = $request->user();
$route = DashboardRoute::resolve($user->getRoleNames()); $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); return to_route($route);
} }
} }

View File

@ -20,20 +20,37 @@ public function __construct(private readonly SamlIdpService $samlService) {}
*/ */
public function sso(Request $request) public function sso(Request $request)
{ {
// Retrieve SAMLRequest and RelayState (from request parameters or session fallback)
$samlRequest = $request->input('SAMLRequest') ?? session('saml_pending_request'); $samlRequest = $request->input('SAMLRequest') ?? session('saml_pending_request');
$relayState = $request->input('RelayState') ?? session('saml_pending_relay_state'); $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)) { 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.'); abort(400, 'Missing SAMLRequest parameter.');
} }
try { try {
// Parse the SAMLRequest XML Log::info('Decoding and parsing SAMLRequest XML payload.');
$parsed = $this->samlService->parseRequest($samlRequest); $parsed = $this->samlService->parseRequest($samlRequest);
Log::info('SAMLRequest successfully parsed.', [
'request_id' => $parsed['requestId'],
'issuer' => $parsed['issuer'],
'acs_url' => $parsed['acsUrl'],
]);
} catch (Throwable $e) { } 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()); abort(400, 'Invalid SAMLRequest: '.$e->getMessage());
} }
@ -41,24 +58,35 @@ public function sso(Request $request)
$connectedApp = $this->samlService->findConnectedApp($parsed['issuer']); $connectedApp = $this->samlService->findConnectedApp($parsed['issuer']);
if (! $connectedApp) { if (! $connectedApp) {
Log::warning( Log::warning('SAML SSO access rejected: Unregistered Service Provider (EntityID / Issuer).', [
"SAML SP Issuer [{$parsed['issuer']}] is not registered.", 'issuer' => $parsed['issuer'],
); 'request_id' => $parsed['requestId'],
'ip' => $request->ip(),
]);
abort( abort(
403, 403,
"The Service Provider [{$parsed['issuer']}] is not authorized in this system.", "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) { if ('disconnected' === $connectedApp->status?->name) {
Log::warning( Log::warning('SAML SSO access rejected: Connected App is deactivated.', [
"SAML SP Issuer [{$parsed['issuer']}] is registered but disconnected.", 'app_id' => $connectedApp->id,
); 'app_name' => $connectedApp->name,
'request_id' => $parsed['requestId'],
]);
abort( abort(
403, 403,
"The Service Provider [{$connectedApp->name}] is currently deactivated.", "The Service Provider [{$connectedApp->name}] is currently deactivated.",
); );
} }
/** /**
* @var array{ * @var array{
* saml?: array{ * saml?: array{
@ -68,19 +96,25 @@ public function sso(Request $request)
* } $settings * } $settings
*/ */
$settings = $connectedApp->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; $acsUrl = $parsed['acsUrl'] ?: $settings['saml']['acs_url'] ?? null;
if (empty($acsUrl)) { if (empty($acsUrl)) {
Log::error( Log::error('SAML SSO failed: Assertion Consumer Service (ACS) URL is undefined.', [
"No Assertion Consumer Service (ACS) URL defined for [{$connectedApp->name}].", 'app_id' => $connectedApp->id,
); 'app_name' => $connectedApp->name,
'request_id' => $parsed['requestId'],
]);
abort(400, 'Assertion Consumer Service (ACS) URL is not defined.'); abort(400, 'Assertion Consumer Service (ACS) URL is not defined.');
} }
// Authenticate the User // Authenticate the User
if (! Auth::check()) { 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([ session([
'saml_pending_request' => $samlRequest, 'saml_pending_request' => $samlRequest,
'saml_pending_relay_state' => $relayState, 'saml_pending_relay_state' => $relayState,
@ -89,10 +123,18 @@ public function sso(Request $request)
return redirect()->route('login'); return redirect()->route('login');
} }
// Generate Signed SAML Response
/** @var User $user */ /** @var User $user */
$user = Auth::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 { try {
$samlResponse = $this->samlService->generateResponse( $samlResponse = $this->samlService->generateResponse(
$user, $user,
@ -100,13 +142,26 @@ public function sso(Request $request)
$parsed['requestId'], $parsed['requestId'],
$acsUrl, $acsUrl,
); );
Log::info('Signed SAML Response assertion successfully generated.');
} catch (Throwable $e) { } 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.'); abort(500, 'Cryptographic signing of SAML Response failed.');
} }
// Clear pending SAML request from session // Clear pending SAML request from session
session()->forget(['saml_pending_request', 'saml_pending_relay_state']); 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 HTTP-POST Auto-Submission Page
return view('saml.post_response', [ return view('saml.post_response', [
@ -126,15 +181,23 @@ public function metadata(): Response
$idpEntityId = route('saml.metadata'); $idpEntityId = route('saml.metadata');
$ssoUrl = route('saml.sso'); $ssoUrl = route('saml.sso');
Log::info('SAML IdP Metadata requested.', [
'ip' => request()->ip(),
'user_agent' => request()->userAgent(),
'entity_id' => $idpEntityId,
'sso_url' => $ssoUrl,
]);
try { try {
$metadataXml = $this->samlService->generateMetadata( $metadataXml = $this->samlService->generateMetadata(
$idpEntityId, $idpEntityId,
$ssoUrl, $ssoUrl,
); );
Log::info('SAML IdP Metadata XML descriptor generated successfully.');
} catch (Throwable $e) { } catch (Throwable $e) {
Log::error( Log::error('SAML IdP Metadata XML generation failed.', [
'Failed to generate SAML IdP Metadata: '.$e->getMessage(), 'error' => $e->getMessage(),
); ]);
abort( abort(
500, 500,
'Failed to retrieve Identity Provider cryptographic identity.', 'Failed to retrieve Identity Provider cryptographic identity.',

View 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);
}
}

View File

@ -25,13 +25,25 @@ class SamlIdpService
*/ */
public function parseRequest(string $samlRequest): array public function parseRequest(string $samlRequest): array
{ {
Log::debug('SamlIdpService: Parsing incoming SAMLRequest.', [
'payload_length' => mb_strlen($samlRequest),
]);
$decoded = base64_decode($samlRequest, true); $decoded = base64_decode($samlRequest, true);
if (false === $decoded) { if (false === $decoded) {
Log::error('SamlIdpService: Failed to base64 decode SAMLRequest.');
throw new Exception('Failed to base64 decode SAMLRequest.'); throw new Exception('Failed to base64 decode SAMLRequest.');
} }
// Try deflated (GET Redirect binding), fallback to raw XML (POST binding) // 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 = new DOMDocument();
$dom->preserveWhiteSpace = false; $dom->preserveWhiteSpace = false;
@ -39,9 +51,15 @@ public function parseRequest(string $samlRequest): array
// Securely handle XML loading without using '@' suppression // Securely handle XML loading without using '@' suppression
libxml_use_internal_errors(true); libxml_use_internal_errors(true);
$loaded = $dom->loadXML($xml); $loaded = $dom->loadXML($xml);
$xmlErrors = libxml_get_errors();
libxml_clear_errors(); libxml_clear_errors();
if (! $loaded) { 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.'); throw new Exception('Failed to load SAMLRequest XML.');
} }
@ -52,6 +70,12 @@ public function parseRequest(string $samlRequest): array
$requestId = $dom->documentElement->getAttribute('ID'); $requestId = $dom->documentElement->getAttribute('ID');
$acsUrl = $dom->documentElement->getAttribute('AssertionConsumerServiceURL'); $acsUrl = $dom->documentElement->getAttribute('AssertionConsumerServiceURL');
Log::info('SamlIdpService: SAMLRequest XML parsed successfully.', [
'requestId' => $requestId,
'issuer' => $issuer,
'acsUrl' => $acsUrl,
]);
return [ return [
'requestId' => $requestId, 'requestId' => $requestId,
'issuer' => $issuer, 'issuer' => $issuer,
@ -64,9 +88,23 @@ public function parseRequest(string $samlRequest): array
*/ */
public function findConnectedApp(string $issuer): ?ConnectedApp 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) ->where('settings->saml->entity_id', $issuer)
->first(); ->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 public function generateMetadata(string $idpEntityId, string $ssoUrl): string
{ {
Log::info('SamlIdpService: Generating SAML IdP metadata XML.', [
'idpEntityId' => $idpEntityId,
'ssoUrl' => $ssoUrl,
]);
$certPem = $this->getPublicCertificate(); $certPem = $this->getPublicCertificate();
$certClean = $this->cleanPem($certPem); $certClean = $this->cleanPem($certPem);
return mb_trim(<<<XML $metadata = mb_trim(<<<XML
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<EntityDescriptor entityID="{$idpEntityId}" xmlns="urn:oasis:names:tc:SAML:2.0:metadata"> <EntityDescriptor entityID="{$idpEntityId}" xmlns="urn:oasis:names:tc:SAML:2.0:metadata">
<IDPSSODescriptor WantAuthnRequestsSigned="false" protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol"> <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> </IDPSSODescriptor>
</EntityDescriptor> </EntityDescriptor>
XML); 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 private function getPublicCertificate(): string
{ {
$path = storage_path('app/saml/idp.crt'); $path = storage_path('app/saml/idp.crt');
Log::debug('SamlIdpService: Loading public certificate.', ['path' => $path]);
try { 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) { } 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); 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(); $responseId = '_'.Str::uuid()->toString();
$assertionId = '_'.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 // Prepare Timestamps
$now = time(); $now = time();
$timestamps = [ $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), '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 = new DOMDocument('1.0', 'UTF-8');
$dom->preserveWhiteSpace = false; $dom->preserveWhiteSpace = false;
// 1. Create Base Response // 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 = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:protocol', 'samlp:Response');
$response->setAttribute('ID', $responseId); $response->setAttribute('ID', $responseId);
$response->setAttribute('InResponseTo', $requestId); $response->setAttribute('InResponseTo', $requestId);
@ -171,6 +244,7 @@ public function generateResponse(User $user, ConnectedApp $app, string $requestI
$response->appendChild($status); $response->appendChild($status);
// 2. Create Assertion // 2. Create Assertion
Log::debug('SamlIdpService: Constructing saml:Assertion element.');
$assertion = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:assertion', 'saml:Assertion'); $assertion = $dom->createElementNS('urn:oasis:names:tc:SAML:2.0:assertion', 'saml:Assertion');
$assertion->setAttribute('ID', $assertionId); $assertion->setAttribute('ID', $assertionId);
$assertion->setAttribute('Version', '2.0'); $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)); $assertion->appendChild($dom->createElement('saml:Issuer', $idpEntityId));
// 3. Build Assertion Sub-components // 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->buildSubject($dom, $assertion, $user, $requestId, $acsUrl, $timestamps['expire']);
$this->buildConditions($dom, $assertion, $spEntityId, $timestamps['notBefore'], $timestamps['expire']); $this->buildConditions($dom, $assertion, $spEntityId, $timestamps['notBefore'], $timestamps['expire']);
$this->buildAuthnStatement($dom, $assertion, $timestamps['issue'], $assertionId); $this->buildAuthnStatement($dom, $assertion, $timestamps['issue'], $assertionId);
@ -187,9 +262,20 @@ public function generateResponse(User $user, ConnectedApp $app, string $requestI
$dom->appendChild($response); $dom->appendChild($response);
// 4. Sign Assertion & Return Base64 payload // 4. Sign Assertion & Return Base64 payload
Log::info('SamlIdpService: Invoking cryptographic signing for the Assertion node.');
$this->signAssertion($dom, $assertion); $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 { ): void {
$immutableId = $user->immutable_id ?? base64_encode((string) $user->id); $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'); $subject = $dom->createElement('saml:Subject');
$nameId = $dom->createElement('saml:NameID', $immutableId); $nameId = $dom->createElement('saml:NameID', $immutableId);
$nameId->setAttribute('Format', 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent'); $nameId->setAttribute('Format', 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent');
@ -237,6 +330,12 @@ private function buildConditions(
string $notBefore, string $notBefore,
string $expireInstant string $expireInstant
): void { ): 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 = $dom->createElement('saml:Conditions');
$conditions->setAttribute('NotBefore', $notBefore); $conditions->setAttribute('NotBefore', $notBefore);
$conditions->setAttribute('NotOnOrAfter', $expireInstant); $conditions->setAttribute('NotOnOrAfter', $expireInstant);
@ -259,6 +358,12 @@ private function buildAuthnStatement(
string $issueInstant, string $issueInstant,
string $assertionId string $assertionId
): void { ): 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 = $dom->createElement('saml:AuthnStatement');
$authnStatement->setAttribute('AuthnInstant', $issueInstant); $authnStatement->setAttribute('AuthnInstant', $issueInstant);
$authnStatement->setAttribute('SessionIndex', $assertionId); $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, '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) { foreach ($claims as $name => $value) {
$this->addAttribute($dom, $statement, $name, (string) $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 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 = $dom->createElement('saml:Attribute');
$attribute->setAttribute('Name', $name); $attribute->setAttribute('Name', $name);
$attribute->setAttribute('NameFormat', 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic'); $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 private function signAssertion(DOMDocument $dom, DOMElement|DOMDocument $assertion): void
{ {
Log::info('SamlIdpService: Starting cryptographic assertion signing.', [
'assertion_id' => $assertion->getAttribute('ID'),
]);
try { try {
$privateKeyPem = $this->getPrivateKey(); $privateKeyPem = $this->getPrivateKey();
$certPem = $this->getPublicCertificate(); $certPem = $this->getPublicCertificate();
Log::debug('SamlIdpService: Instantiating XMLSecurityDSig for Assertion.');
$objDSig = new XMLSecurityDSig(); $objDSig = new XMLSecurityDSig();
$objDSig->setCanonicalMethod(XMLSecurityDSig::C14N); $objDSig->setCanonicalMethod(XMLSecurityDSig::C14N);
$assertion->setIdAttribute('ID', true); $assertion->setIdAttribute('ID', true);
Log::debug('SamlIdpService: Adding reference to XMLSignature.', [
'algorithm' => 'SHA256',
]);
$objDSig->addReference( $objDSig->addReference(
$assertion, $assertion,
XMLSecurityDSig::SHA256, XMLSecurityDSig::SHA256,
@ -336,13 +460,18 @@ private function signAssertion(DOMDocument $dom, DOMElement|DOMDocument $asserti
['id_name' => 'ID'] ['id_name' => 'ID']
); );
Log::debug('SamlIdpService: Loading RSA-SHA256 private key.');
$objKey = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, ['type' => 'private']); $objKey = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, ['type' => 'private']);
$objKey->loadKey($privateKeyPem); $objKey->loadKey($privateKeyPem);
Log::debug('SamlIdpService: Computing signature value.');
$objDSig->sign($objKey, $assertion); $objDSig->sign($objKey, $assertion);
Log::debug('SamlIdpService: Appending X509 certificate to signature.');
$objDSig->add509Cert($certPem); $objDSig->add509Cert($certPem);
// Enforce SAML Schema order compliance: Signature node must precede Subject. // 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( $signatureNode = $assertion->getElementsByTagNameNS(
'http://www.w3.org/2000/09/xmldsig#', 'http://www.w3.org/2000/09/xmldsig#',
'Signature' 'Signature'
@ -354,9 +483,18 @@ private function signAssertion(DOMDocument $dom, DOMElement|DOMDocument $asserti
if ($signatureNode && $subjectNode) { if ($signatureNode && $subjectNode) {
$assertion->insertBefore($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) { } 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); 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 private function getPrivateKey(): string
{ {
$path = storage_path('app/saml/idp.key'); $path = storage_path('app/saml/idp.key');
Log::debug('SamlIdpService: Loading private key.', ['path' => $path]);
try { 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) { } 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); throw new RuntimeException("SAML private key not found at [$path].", code: 500);
} }
} }

View File

@ -1,18 +1,20 @@
@props(['stats'])
<grid class="grid grid-cols-4 gap-x-4"> <grid class="grid grid-cols-4 gap-x-4">
<x-shared.card class="p-4"> <x-shared.card class="p-4">
<p class="text-sm text-gray-400">Total Users</p> <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>
<x-shared.card class="p-4"> <x-shared.card class="p-4">
<p class="text-sm text-gray-400">Active Roles</p> <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>
<x-shared.card class="p-4"> <x-shared.card class="p-4">
<p class="text-sm text-gray-400">Services</p> <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>
<x-shared.card class="p-4"> <x-shared.card class="p-4">
<p class="text-sm text-gray-400">Revoked Today</p> <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> </x-shared.card>
</grid> </grid>

View File

@ -12,6 +12,13 @@
use Spatie\LaravelData\PaginatedDataCollection; use Spatie\LaravelData\PaginatedDataCollection;
new class extends Component { new class extends Component {
private ConnectedAppService $service;
public function boot(ConnectedAppService $service): void
{
$this->service = $service;
}
public function mount(): void public function mount(): void
{ {
} }
@ -22,8 +29,7 @@ public function mount(): void
#[Computed] #[Computed]
public function getConnectedApps(): PaginatedDataCollection public function getConnectedApps(): PaginatedDataCollection
{ {
$service = app(ConnectedAppService::class); return $this->service->getAll();
return $service->getAll();
} }
}; };
?> ?>

View File

@ -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>

View File

@ -1,9 +1,9 @@
<?php <?php
use App\Data\Ui\ManageRoles\{RoleData, ServiceData}; use App\Data\Ui\ManageRoles\RoleData;
use App\Data\Ui\TableHeader; use App\Data\Ui\TableHeader;
use App\Data\Users\UserData; use App\Services\AdminDashboardService;
use App\Enums\RolesStatusEnum; use Livewire\Attributes\Computed;
use Livewire\Component; use Livewire\Component;
use Spatie\LaravelData\Attributes\DataCollectionOf; use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\DataCollection; use Spatie\LaravelData\DataCollection;
@ -12,8 +12,23 @@
#[DataCollectionOf(TableHeader::class)] #[DataCollectionOf(TableHeader::class)]
public DataCollection $header; public DataCollection $header;
#[DataCollectionOf(class: RoleData::class)] private AdminDashboardService $service;
public DataCollection $roles;
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 public function mount(): void
{ {
@ -35,38 +50,6 @@ public function mount(): void
"label" => "Status", "label" => "Status",
], ],
], DataCollection::class); ], 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-shared.button>
</x-slot:actions> </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) @scope('cell_users', $role)
@php /** @var RoleData $role */ @endphp @php /** @var RoleData $role */ @endphp
<div class="flex -space-x-3"> <div class="flex -space-x-3">

View File

@ -2,9 +2,8 @@
use App\Data\Ui\TableHeader; use App\Data\Ui\TableHeader;
use App\Data\Users\UserData; use App\Data\Users\UserData;
use App\Data\Ui\ManageRoles\RoleData; use App\Services\AdminDashboardService;
use App\Data\Ui\ManageRoles\ServiceData; use Livewire\Attributes\Computed;
use App\Enums\RolesStatusEnum;
use Livewire\Component; use Livewire\Component;
use Spatie\LaravelData\Attributes\DataCollectionOf; use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\DataCollection; use Spatie\LaravelData\DataCollection;
@ -14,9 +13,23 @@
#[DataCollectionOf(TableHeader::class)] #[DataCollectionOf(TableHeader::class)]
public DataCollection $header; public DataCollection $header;
/** @var DataCollection<int, UserData>|null */ private AdminDashboardService $service;
#[DataCollectionOf(UserData::class)]
public ?DataCollection $users = null; 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 public function mount(): void
{ {
@ -26,66 +39,6 @@ public function mount(): void
new TableHeader('role.service', 'Service Access'), new TableHeader('role.service', 'Service Access'),
new TableHeader('status', 'Status'), new TableHeader('status', 'Status'),
], DataCollection::class); ], 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-shared.button>
</x-slot:actions> </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) @scope('cell_name', $row)
<x-shared.avatar :placeholder="$row->name" :title="$row->name" :subtitle="$row->email"/> <x-shared.avatar :placeholder="$row->name" :title="$row->name" :subtitle="$row->email"/>
@endscope @endscope

View 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>

View File

@ -1,6 +1,8 @@
<?php <?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\Layout;
use Livewire\Attributes\Title; use Livewire\Attributes\Title;
use Livewire\Component; use Livewire\Component;
@ -9,14 +11,26 @@
#[Layout('layouts.app.sidebar')] #[Layout('layouts.app.sidebar')]
#[Title('Dashboard')] #[Title('Dashboard')]
class extends Component { 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"> <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.connected-apps/>
<livewire:dashboard.roles.manager/> <livewire:dashboard.roles.manager/>
<livewire:dashboard.roles.hr-permission/> <livewire:dashboard.users.latest/>
<livewire:dashboard.users.assignments/> <livewire:dashboard.users.assignments/>
</div> </div>

View File

@ -2,17 +2,47 @@
declare(strict_types=1); declare(strict_types=1);
use App\Models\User; use App\Models\{Role, User};
test('guests are redirected to the login page', function (): void { test('guests are redirected to the login page', function (): void {
$response = $this->get(route('dashboard')); $response = $this->get(route('dashboard'));
$response->assertRedirect(route('login')); $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(); $user = User::factory()->create();
$this->actingAs($user); $this->actingAs($user);
$response = $this->get(route('dashboard')); $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(); $response->assertOk();
}); });