Merge pull request #1 from subhajit9328/feature/test-saml-integration

Feature/test saml integration
This commit is contained in:
subhajit9328 2026-05-29 11:41:02 +05:30 committed by GitHub
commit 6ff2b803bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
252 changed files with 15351 additions and 1859 deletions

View File

@ -1,81 +0,0 @@
---
name: fluxui-development
description: "Use this skill for Flux UI development in Livewire applications only. Trigger when working with <flux:*> components, building or customizing Livewire component UIs, creating forms, modals, tables, or other interactive elements. Covers: flux: components (buttons, inputs, modals, forms, tables, date-pickers, kanban, badges, tooltips, etc.), component composition, Tailwind CSS styling, Heroicons/Lucide icon integration, validation patterns, responsive design, and theming. Do not use for non-Livewire frameworks or non-component styling."
license: MIT
metadata:
author: laravel
---
# Flux UI Development
## Documentation
Use `search-docs` for detailed Flux UI patterns and documentation.
## Basic Usage
This project uses the free edition of Flux UI, which includes all free components and variants but not Pro components.
Flux UI is a component library for Livewire built with Tailwind CSS. It provides components that are easy to use and customize.
Use Flux UI components when available. Fall back to standard Blade components when no Flux component exists for your needs.
<!-- Basic Button -->
```blade
<flux:button variant="primary">Click me</flux:button>
```
## Available Components (Free Edition)
Available: avatar, badge, brand, breadcrumbs, button, callout, checkbox, dropdown, field, heading, icon, input, modal, navbar, otp-input, profile, radio, select, separator, skeleton, switch, text, textarea, tooltip
## Icons
Flux includes [Heroicons](https://heroicons.com/) as its default icon set. Search for exact icon names on the Heroicons site - do not guess or invent icon names.
<!-- Icon Button -->
```blade
<flux:button icon="arrow-down-tray">Export</flux:button>
```
For icons not available in Heroicons, use [Lucide](https://lucide.dev/). Import the icons you need with the Artisan command:
```bash
php artisan flux:icon crown grip-vertical github
```
## Common Patterns
### Form Fields
<!-- Form Field -->
```blade
<flux:field>
<flux:label>Email</flux:label>
<flux:input type="email" wire:model="email" />
<flux:error name="email" />
</flux:field>
```
### Modals
<!-- Modal -->
```blade
<flux:modal wire:model="showModal">
<flux:heading>Title</flux:heading>
<p>Content</p>
</flux:modal>
```
## Verification
1. Check component renders correctly
2. Test interactive states
3. Verify mobile responsiveness
## Common Pitfalls
- Trying to use Pro-only components in the free edition
- Not checking if a Flux component exists before creating custom implementations
- Forgetting to use the `search-docs` tool for component-specific documentation
- Not following existing project patterns for Flux usage

View File

@ -63,3 +63,12 @@ AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false
VITE_APP_NAME="${APP_NAME}"
# Microsoft Graph API credentials for Domain Federation
MICROSOFT_GRAPH_TENANT_ID=
MICROSOFT_GRAPH_CLIENT_ID=
MICROSOFT_GRAPH_CLIENT_SECRET=
MICROSOFT_GRAPH_DOMAIN_NAME=
MICROSOFT_GRAPH_DISPLAY_NAME="Company SSO"
MICROSOFT_GRAPH_MFA_BEHAVIOR="acceptIfMfaDoneByFederatedIdp"

1
.gitignore vendored
View File

@ -23,3 +23,4 @@ yarn-error.log
/.vscode
/.zed
/storage/framework/views/*
laradumps.yaml

View File

@ -15,6 +15,7 @@ ## Foundational Context
- laravel/prompts (PROMPTS) - v0
- livewire/flux (FLUXUI_FREE) - v2
- livewire/livewire (LIVEWIRE) - v4
- larastan/larastan (LARASTAN) - v3
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1

View File

@ -15,6 +15,7 @@ ## Foundational Context
- laravel/prompts (PROMPTS) - v0
- livewire/flux (FLUXUI_FREE) - v2
- livewire/livewire (LIVEWIRE) - v4
- larastan/larastan (LARASTAN) - v3
- laravel/boost (BOOST) - v2
- laravel/mcp (MCP) - v0
- laravel/pail (PAIL) - v1

2
CONTRIBUTING.MD Normal file
View File

@ -0,0 +1,2 @@
Always use `#[NoDiscard]` attribute in function which fetches data from DB. This ensures that
db does not called unnecessarily.

7
DATABASE.md Normal file
View File

@ -0,0 +1,7 @@
## DB Diagram
![DB Diagram](art/db-diagram.svg)
## ER Diagram
### Diagram is not complete, Please use it as a reference with the previous DB Diagram.
![ER Diagram part 1](art/er-diagram-2.png)
![ER Diagram part 1](art/er-diagram-1.png)

View File

@ -4,8 +4,7 @@
namespace App\Actions\Fortify;
use App\Concerns\PasswordValidationRules;
use App\Concerns\ProfileValidationRules;
use App\Concerns\{PasswordValidationRules, ProfileValidationRules};
use App\Models\User;
use Illuminate\Support\Facades\Validator;
use Laravel\Fortify\Contracts\CreatesNewUsers;

View File

@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace App\Concerns;
use Livewire\Attributes\On;
trait Confirmation
{
/**
* Shows a confirmation modal before executing the given method.
*
* @param string $method Method name that should be executed if the user confirms.
* @param mixed|null $params Any parameters that should be passed to the method.
* @param string $title Title of the confirmation modal.
* @param string $subtitle Subtitle of the confirmation modal.
* @param string|null $description Description of the confirmation modal.
* @param string $descriptionClass Classes to apply to the description element. (!, !important to overide)
* @param string $acceptBtnClass Classes to apply to the accept button. (!, !important to overide)
* @param string $icon Icon to display in the confirmation modal.
*/
public function requireConfirmation(
string $method,
mixed $params = null,
string $title = 'Are you sure?',
string $subtitle = 'This action cannot be undone.',
?string $description = null,
string $descriptionClass = '',
string $acceptBtnClass = '',
string $icon = 'lucide.triangle-alert'
): void {
$this->dispatch(
'open-confirmation',
componentId: $this->getId(),
action: $method,
params: $params,
title: $title,
subtitle: $subtitle,
description: $description,
descriptionClass: $descriptionClass,
acceptBtnClass: $acceptBtnClass,
icon: $icon
);
}
/**
* Listen globally for the acceptance, but only process if the ID matches.
*/
#[On('confirmation-accepted')]
public function onConfirmationAccepted(string $action, mixed $params, string $componentId): void
{
// Ignore the event if it wasn't requested by this specific component instance
if ($this->getId() !== $componentId) {
return;
}
// Verify the method actually exists on the component
if (method_exists($this, $action)) {
// Execute the requested method, passing any provided parameters
$this->{$action}($params);
}
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Concerns;
trait EnumValuesAsArray
{
public static function values(): array
{
return array_column(self::cases(), 'value');
}
}

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Concerns;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
use Mary\Traits\Toast;
use Throwable;
trait HandlesOperations
{
use Toast;
public function attempt(callable $action, ?callable $onError = null, string $successMessage = 'Success !', string $errorMessage = 'Something gone wrong!', bool $showSuccess = true): void
{
try {
// call the action callback
$action();
if ($showSuccess) {
$this->success($successMessage, css: 'alert-success alert-dismissible alert-soft');
}
} catch (ValidationException $exception) {
/**
* We are catching this exception intentionally so that, generic throwable catch block
* doesn't catch this, which will lead to close the modal.
*
* But, we are rethrowing this so that validation errors can be handled by others.
*/
throw $exception;
} catch (Throwable $exception) {
if (null !== $onError) {
// call the callback
$onError();
}
$this->error($errorMessage, css: 'alert-error alert-dismissible alert-soft');
Log::error($exception->getMessage());
}
}
}

View File

@ -12,7 +12,7 @@ trait PasswordValidationRules
/**
* Get the validation rules used to validate passwords.
*
* @return array<int, ValidationRule|array<mixed>|string>
* @return array<int, ValidationRule|Password|string>
*/
protected function passwordRules(): array
{

View File

@ -7,13 +7,14 @@
use App\Models\User;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Unique;
trait ProfileValidationRules
{
/**
* Get the validation rules used to validate user profiles.
*
* @return array<string, array<int, ValidationRule|array<mixed>|string>>
* @return array<string, array<int, ValidationRule|Unique|string>>
*/
protected function profileRules(?int $userId = null): array
{
@ -36,7 +37,7 @@ protected function nameRules(): array
/**
* Get the validation rules used to validate user emails.
*
* @return array<int, ValidationRule|array<mixed>|string>
* @return array<int, ValidationRule|Unique|string>
*/
protected function emailRules(?int $userId = null): array
{

View File

@ -0,0 +1,115 @@
<?php
declare(strict_types=1);
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
class GenerateSamlKeysCommand extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'saml:generate-keys {--force : Overwrite existing keys}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate self-signed SAML private key and public certificate for the IdP';
/**
* Execute the console command.
*/
public function handle(): int
{
$directory = storage_path('app/saml');
$keyPath = "{$directory}/idp.key";
$certPath = "{$directory}/idp.crt";
if (! File::exists($directory)) {
File::makeDirectory($directory, 0755, true);
}
if (File::exists($keyPath) && File::exists($certPath) && ! $this->option('force')) {
$this->info('SAML keys already exist. Use --force to overwrite them.');
return self::SUCCESS;
}
$this->info('Generating SAML keys...');
$config = [
'digest_alg' => 'sha256',
'private_key_bits' => 2048,
'private_key_type' => OPENSSL_KEYTYPE_RSA,
];
// Create keypair
$res = openssl_pkey_new($config);
if (false === $res) {
$this->error('Failed to generate private key. Ensure OpenSSL is configured.');
return self::FAILURE;
}
// Export private key
if (! openssl_pkey_export($res, $privKey)) {
$this->error('Failed to export private key.');
return self::FAILURE;
}
// Generate CSR and self-signed certificate
$dn = [
'countryName' => 'US',
'stateOrProvinceName' => 'California',
'localityName' => 'San Francisco',
'organizationName' => 'Laravel SSO',
'organizationalUnitName' => 'IT Department',
'commonName' => 'laravel-sso-idp',
'emailAddress' => 'admin@sso.local',
];
$csr = openssl_csr_new($dn, $res, ['digest_alg' => 'sha256']);
if (false === $csr) {
$this->error('Failed to create CSR.');
return self::FAILURE;
}
$cert = openssl_csr_sign($csr, null, $res, 3650, ['digest_alg' => 'sha256']); // 10 years validity
if (false === $cert) {
$this->error('Failed to sign certificate.');
return self::FAILURE;
}
if (! openssl_x509_export($cert, $certOut)) {
$this->error('Failed to export certificate.');
return self::FAILURE;
}
// Write files
File::put($keyPath, $privKey);
File::put($certPath, $certOut);
// Update or add .gitignore inside the saml directory
$gitignorePath = "{$directory}/.gitignore";
if (! File::exists($gitignorePath)) {
File::put($gitignorePath, "idp.key\n");
}
$this->info('Keys generated successfully:');
$this->line("- Private Key: {$keyPath}");
$this->line("- Certificate: {$certPath}");
return self::SUCCESS;
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Data\ConnectedApp;
use Spatie\LaravelData\Attributes\MapOutputName;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapOutputName(SnakeCaseMapper::class)]
class ConnectAppRequest extends Data
{
public function __construct(
public string $name,
public int $connectionProviderId,
public int $connectionProtocolId,
public ?string $slug = null,
public ?int $connectionStatusId = null,
public ?array $settings = null,
) {}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Data\ConnectedApp;
use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Data;
final class ConnectedAppData extends Data
{
public function __construct(
public int $id,
public string $name,
#[MapInputName('connection_protocol_id')]
public int $protocolId,
#[MapInputName('connection_provider_id')]
public int $providerId,
#[MapInputName('connection_status_id')]
public int $statusId,
public ?string $slug = null,
public ?array $settings = null,
) {}
}

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Data\ConnectedApp;
use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapInputName(SnakeCaseMapper::class)]
class ConnectionProviderData extends Data
{
public function __construct(
public int $id,
public string $name,
public string $slug
) {}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Data\ConnectedApp;
use App\Enums\ConnectionStatusEnum;
use Spatie\LaravelData\Attributes\{MapInputName, WithCast};
use Spatie\LaravelData\Casts\EnumCast;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapInputName(SnakeCaseMapper::class)]
class ConnectionStatusData extends Data
{
public function __construct(
public int $id,
#[WithCast(EnumCast::class)]
public ConnectionStatusEnum $name,
) {}
}

View File

@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Data\ConnectedApp;
use Spatie\LaravelData\Attributes\MapInputName;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapInputName(SnakeCaseMapper::class)]
class StoreConnectionProviderRequestData extends Data
{
public string $slug;
public function __construct(
public string $name,
) {
$this->slug = str($name)->slug()->toString();
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Data\MicrosoftGraph;
use App\Enums\FederatedIdpMfaBehaviorEnum;
use Spatie\LaravelData\Attributes\MapOutputName;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapOutputName(SnakeCaseMapper::class)]
final class MicrosoftGraphCredentialsData extends Data
{
public function __construct(
public string $tenantId,
public string $clientId,
public string $clientSecret,
public string $domainName,
public string $displayName = 'Company SSO',
public FederatedIdpMfaBehaviorEnum $mfaBehavior = FederatedIdpMfaBehaviorEnum::AcceptIfMfaDoneByFederatedIdp,
) {}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Data\Permissions;
use Spatie\LaravelData\Data;
final class DeactivatedPermissionData extends Data
{
public function __construct(
public int $roleId,
public int $permissionId,
) {}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Data\Permissions;
use App\Enums\ConnectedAppPermissonEnum;
use App\Helpers\AppPermission;
use Spatie\LaravelData\Data;
use Spatie\Permission\Models\Permission;
class PermissionData extends Data
{
public function __construct(
public int $id,
public string $name,
public ?ConnectedAppPermissonEnum $type,
public string $resource = 'general',
) {}
public static function fromModel(Permission $permission): self
{
$parts = explode(':', $permission->name);
$resource = count($parts) > 1 ? $parts[0] : 'general';
return new self(
id: $permission->id,
name: $permission->name,
type: AppPermission::type($permission->name),
resource: $resource,
);
}
}

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Data\Role;
use Spatie\LaravelData\Data;
final class AssignAppsToRoleData extends Data
{
public function __construct(
public int $roleId,
public string $validUpto,
/** @var int[] */
public array $appIds
) {}
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Data\Role;
use Spatie\LaravelData\Data;
final class AssignUsersToRoleData extends Data
{
public function __construct(
public int $roleId,
/** @var int[] */
public array $userIds
) {}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Data\Role;
use Spatie\LaravelData\Data;
final class CreateRoleData extends Data
{
public function __construct(
public string $name,
public int $priority,
) {}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Data\Role;
use App\Models\ConnectedApp;
use Spatie\LaravelData\Data;
final class RoleAppData extends Data
{
public function __construct(
public int $id,
public int $appId,
public string $appName,
public string $duration,
) {}
/**
* Intercept the Eloquent model and map it to the DTO properties.
*/
public static function fromModel(ConnectedApp $app): self
{
return new self(
id: $app->pivot->id,
appId: $app->id,
appName: $app->name,
duration: $app->pivot->duration
);
}
}

View File

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Data\Role;
use App\Data\Permissions\PermissionData;
use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\{Data, DataCollection};
final class RoleData extends Data
{
public function __construct(
public int $id,
public string $name,
public int $priority = 0,
#[DataCollectionOf(class: RoleAppData::class)]
public ?DataCollection $apps = null,
#[DataCollectionOf(class: RoleUserData::class)]
public ?DataCollection $users = null,
#[DataCollectionOf(PermissionData::class)]
public ?DataCollection $permissions = null,
) {}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Data\Role;
use App\Models\User;
use Spatie\LaravelData\Data;
final class RoleUserData extends Data
{
public function __construct(
public int $id,
public string $name,
public string $initials = '',
) {}
/**
* Intercept the Eloquent model and map it to the DTO properties.
*/
public static function fromModel(User $user): self
{
return new self(
id: $user->id,
name: $user->name,
initials: $user->initials()
);
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Data\Role;
use Spatie\LaravelData\Data;
final class UpdateRolePriorityData extends Data
{
public function __construct(
public int $roleId,
public int $priority,
) {}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Data\Role;
use Spatie\LaravelData\Data;
/**
* Lightweight DTO for populating the user search/select dropdown.
* Only carries the fields the UI choices component needs.
*/
final class UserSelectData extends Data
{
public function __construct(
public int $id,
public string $name,
) {}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Data\Ticket;
use Spatie\LaravelData\Attributes\MapOutputName;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapOutputName(SnakeCaseMapper::class)]
class CreateTicketRequest extends Data
{
public function __construct(
public int $userId,
public ?int $connectedAppId,
public string $issueCategory,
public ?string $description = null,
public array $notifiedRoleIds = [],
public array $notifiedUserIds = [],
public mixed $attachment = null,
) {}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Data\Ticket;
use Spatie\LaravelData\Attributes\MapOutputName;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapOutputName(SnakeCaseMapper::class)]
class PostReplyRequest extends Data
{
public function __construct(
public int $ticketId,
public int $userId,
public string $message,
) {}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Data\Ui\ConnectedApps;
use App\Enums\{ConnectionProtocolEnum, ConnectionStatusEnum};
use App\Models\ConnectedApp;
use Spatie\LaravelData\Data;
final class ConnectedAppData extends Data
{
public function __construct(
public int $id,
public string $name,
public ConnectionProtocolEnum $protocol,
public ?string $provider,
public ConnectionStatusEnum $status,
) {}
public static function fromModel(ConnectedApp $app): self
{
return new self(
id: $app->id,
name: $app->name,
protocol: ConnectionProtocolEnum::tryFrom($app->protocol->name),
provider: $app->provider?->name,
status: ConnectionStatusEnum::tryFrom($app->status->name)
);
}
}

View File

@ -1,14 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Data\Ui\ConnectedServices;
use Illuminate\Support\Collection;
/**
* A collection of connected services.
*
* @extends Collection<int, ConnectedServiceData>
*/
final class ConnectedServiceCollectionData extends Collection {}

View File

@ -1,24 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Data\Ui\ConnectedServices;
use App\Enums\ConnectionStatus;
use App\Enums\ConnectionTechTypes;
use Livewire\Wireable;
use Spatie\LaravelData\Concerns\WireableData;
use Spatie\LaravelData\Data;
final class ConnectedServiceData extends Data implements Wireable
{
use WireableData;
public function __construct(
public string $name,
public string $icon,
public ConnectionTechTypes $type,
public string $connectionService,
public ConnectionStatus $status,
) {}
}

View File

@ -1,18 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Data\Ui\ConnectedServices;
use Livewire\Wireable;
use Spatie\LaravelData\Concerns\WireableData;
use Spatie\LaravelData\Data;
final class ConnectedServicesData extends Data implements Wireable
{
use WireableData;
public function __construct(
public ConnectedServiceCollectionData $services,
) {}
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Data\Ui\Dashboard;
use Spatie\LaravelData\Data;
class ServiceAppDto extends Data
{
public function __construct(
public int $id,
public string $name,
public string $slug,
public string $duration,
public int $days_remaining,
public bool $is_warning,
) {}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Data\Ui\ManageRoles;
use Spatie\LaravelData\Data;
final class PermissionData extends Data
{
public function __construct(
public string $name,
public bool $view,
public bool $create,
public bool $update,
public bool $delete,
) {}
}

View File

@ -4,19 +4,23 @@
namespace App\Data\Ui\ManageRoles;
use App\Data\Users\UsersData;
use App\Enums\RolesStatus;
use Livewire\Wireable;
use Spatie\LaravelData\Concerns\WireableData;
use Spatie\LaravelData\Data;
use App\Data\Users\UserData;
use App\Enums\RolesStatusEnum;
use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\{Data, DataCollection};
final class RoleData extends Data implements Wireable
final class RoleData extends Data
{
use WireableData;
public function __construct(
public string $name,
public UsersData $users,
public ServiceCollection $services,
public RolesStatus $status,
/** @var DataCollection<int, UserData> */
#[DataCollectionOf(class: UserData::class), ]
public DataCollection $users,
/** @var DataCollection<int, ServiceData> */
#[DataCollectionOf(class: ServiceData::class), ]
public DataCollection $services,
public RolesStatusEnum $status,
) {}
}

View File

@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Data\Ui\ManageRoles;
use Illuminate\Support\Collection;
/**
* @extends Collection<int, RoleData>
*/
final class RolesCollection extends Collection {}

View File

@ -1,17 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Data\Ui\ManageRoles;
use Livewire\Wireable;
use Spatie\LaravelData\Concerns\WireableData;
use Spatie\LaravelData\Data;
final class RolesData extends Data implements Wireable
{
use WireableData;
public function __construct(
public RolesCollection $roles,
) {}
}

View File

@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Data\Ui\ManageRoles;
use Illuminate\Support\Collection;
/**
* @extends Collection<int, ServiceData>
*/
final class ServiceCollection extends Collection {}

View File

@ -4,15 +4,9 @@
namespace App\Data\Ui\ManageRoles;
use Livewire\Wireable;
use Spatie\LaravelData\Concerns\WireableData;
use Spatie\LaravelData\Data;
final class ServiceData extends Data implements Wireable
final class ServiceData extends Data
{
use WireableData;
public function __construct(
public string $name,
) {}
public function __construct(public string $name) {}
}

3
app/Data/Ui/README.md Normal file
View File

@ -0,0 +1,3 @@
This folder includes DTO that are shared to UI. This DTO should not contains any sensitive data, like model Id if the
UI
doesn't need it.

View File

@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Data\Ui\Sidebar;
use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\{Data, DataCollection};
final class NavItemData extends Data
{
public function __construct(
public string $label,
public string $icon,
public string $active_pattern,
public ?string $route = null,
#[DataCollectionOf(NavSubItemData::class)]
public ?DataCollection $sub_menu = null,
) {}
}

View File

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace App\Data\Ui\Sidebar;
use BackedEnum;
use Spatie\LaravelData\Data;
final class NavSubItemData extends Data
{
public function __construct(
public string $label,
public string $route,
public string $active_pattern,
public ?BackedEnum $permission = null
) {}
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Data\Ui;
use Spatie\LaravelData\Data;
final class TableHeader extends Data
{
public function __construct(
public string $key,
public string $label,
public ?array $format = null,
) {}
}

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Data\Ui\Ticket;
use App\Models\TicketReply;
use Spatie\LaravelData\Data;
final class ReplyData extends Data
{
public function __construct(
public int $id,
public int $userId,
public string $userName,
public string $userInitials,
public array $userRoles,
public string $message,
public string $createdAt,
) {}
public static function fromModel(TicketReply $reply): self
{
return new self(
id: $reply->id,
userId: $reply->user_id,
userName: $reply->user->name,
userInitials: method_exists($reply->user, 'initials') ? $reply->user->initials() : mb_strtoupper(mb_substr($reply->user->name, 0, 2)),
userRoles: $reply->user->roles->pluck('name')->toArray(),
message: $reply->message,
createdAt: $reply->created_at->diffForHumans(),
);
}
}

View File

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace App\Data\Ui\Ticket;
use App\Enums\TicketStatusEnum;
use App\Models\Ticket;
use Spatie\LaravelData\Data;
final class TicketData extends Data
{
public function __construct(
public int $id,
public string $ticketId,
public int $userId,
public string $userName,
public ?int $connectedAppId,
public ?string $appName,
public string $issueCategory,
public ?string $description,
public int $statusId,
public TicketStatusEnum $status,
public array $notifiedRoleNames,
public array $notifiedUserNames,
public string $createdAt,
public array $attachments = [],
public ?int $assignedUserId = null,
public ?string $assignedUserName = null,
public array $replies = [],
) {}
public static function fromModel(Ticket $ticket): self
{
$attachments = $ticket->attachments->map(fn ($attachment) => [
'id' => $attachment->id,
'file_name' => $attachment->file_name,
'file_path' => $attachment->file_path,
'file_size' => $attachment->file_size,
'mime_type' => $attachment->mime_type,
])->toArray();
$replies = $ticket->relationLoaded('replies')
? $ticket->replies->map(fn ($reply) => ReplyData::fromModel($reply))->toArray()
: [];
return new self(
id: $ticket->id,
ticketId: $ticket->ticket_id,
userId: $ticket->user_id,
userName: $ticket->user->name,
connectedAppId: $ticket->connected_app_id,
appName: $ticket->connectedApp?->name,
issueCategory: $ticket->issue_category,
description: $ticket->description,
statusId: $ticket->status_id,
status: TicketStatusEnum::tryFrom($ticket->status->name),
notifiedRoleNames: $ticket->notifiedRoles->pluck('name')->toArray(),
notifiedUserNames: $ticket->notifiedUsers->pluck('name')->toArray(),
createdAt: $ticket->created_at->toDateTimeString(),
attachments: $attachments,
assignedUserId: $ticket->assigned_user_id,
assignedUserName: $ticket->assignedUser?->name,
replies: $replies,
);
}
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Data\Ui\Ticket;
use App\Enums\TicketStatusEnum;
use App\Models\Ticket;
use Spatie\LaravelData\Data;
final class TicketListData extends Data
{
public function __construct(
public int $id,
public string $ticketId,
public string $userName,
public ?string $appName,
public string $issueCategory,
public string $createdAt,
public TicketStatusEnum $status,
public string $statusSlug,
public ?int $assignedUserId = null,
) {}
public static function fromModel(Ticket $ticket): self
{
return new self(
id: $ticket->id,
ticketId: $ticket->ticket_id,
userName: $ticket->user->name,
appName: $ticket->connectedApp?->name,
issueCategory: $ticket->issue_category,
createdAt: $ticket->created_at->toDateTimeString(),
status: TicketStatusEnum::from($ticket->status->name),
statusSlug: $ticket->status->name,
assignedUserId: $ticket->assigned_user_id,
);
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Data\User;
use App\Data\Role\RoleData;
use App\Models\User;
use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\{Data, DataCollection};
final class UserIndexData extends Data
{
public function __construct(
public int $id,
public string $name,
public string $email,
public string $initials,
#[DataCollectionOf(class: RoleData::class)]
public ?DataCollection $roles = null,
public ?array $restrictedPermissionIds = null,
public ?string $immutableId = null,
) {}
public static function fromModel(User $user)
{
return new self(
id: $user->id,
name: $user->name,
email: $user->email,
initials: $user->initials(),
roles: RoleData::collect($user->roles, DataCollection::class),
restrictedPermissionIds: $user->restrictedPermissions->pluck('id')->toArray(),
immutableId: $user->immutable_id
);
}
}

View File

@ -1,12 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Data\Users;
use Illuminate\Support\Collection;
/**
* @extends Collection<int, UserData>
*/
final class UserCollection extends Collection {}

View File

@ -4,13 +4,19 @@
namespace App\Data\Users;
use Spatie\LaravelData\Data;
use App\Data\Ui\ManageRoles\RoleData;
use Spatie\LaravelData\Attributes\DataCollectionOf;
use Spatie\LaravelData\{Data, DataCollection};
final class UserData extends Data
{
public function __construct(
public string $name,
public string $email,
) {}
public bool $active = false,
/** @var DataCollection<int, RoleData>|null */
#[DataCollectionOf(class: RoleData::class)]
public ?DataCollection $roles = null,
) {}
}

View File

@ -1,14 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Data\Users;
use Spatie\LaravelData\Data;
final class UsersData extends Data
{
public function __construct(
public UserCollection $users,
) {}
}

View File

@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
namespace App\Enums;
enum ConnectedAppPermissonEnum: string
{
case Use = 'use';
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use App\Concerns\EnumValuesAsArray;
enum ConnectionProtocolEnum: string
{
use EnumValuesAsArray;
case OIDC = 'oidc';
case SAML = 'saml';
case OAUTH2 = 'oauth2';
}

View File

@ -4,8 +4,12 @@
namespace App\Enums;
enum ConnectionStatus: string
use App\Concerns\EnumValuesAsArray;
enum ConnectionStatusEnum: string
{
use EnumValuesAsArray;
case Connected = 'connected';
case Disconnected = 'disconnected';
case Pending = 'pending';

View File

@ -1,11 +0,0 @@
<?php
declare(strict_types=1);
namespace App\Enums;
enum ConnectionTechTypes: string
{
case OIDC = 'oidc';
case SAML = 'saml';
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use App\Concerns\EnumValuesAsArray;
enum DefaultUserRole: string
{
use EnumValuesAsArray;
case Admin = 'admin';
case User = 'user';
}

View File

@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use App\Concerns\EnumValuesAsArray;
/**
* Entra ID Federated IdP MFA behavior configuration values.
*/
enum FederatedIdpMfaBehaviorEnum: string
{
use EnumValuesAsArray;
case AcceptIfMfaDoneByFederatedIdp = 'acceptIfMfaDoneByFederatedIdp';
case EnforceMfaByFederatedIdp = 'enforceMfaByFederatedIdp';
case RejectMfaByFederatedIdp = 'rejectMfaByFederatedIdp';
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Enums\Permissions;
enum AppPermissionEnum: string
{
case Access = 'app:access';
case Create = 'app:create';
case Read = 'app:read';
case Update = 'app:update';
case Delete = 'app:delete';
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Enums\Permissions;
enum ConnectionProviderPermissionEnum: string
{
case Access = 'connection-provider:access';
case Create = 'connection-provider:create';
case Read = 'connection-provider:read';
case Update = 'connection-provider:update';
case Delete = 'connection-provider:delete';
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Enums\Permissions;
enum PermissionPermissionEnum: string
{
case Access = 'permission:access';
case Create = 'permission:create';
case Read = 'permission:read';
case Update = 'permission:update';
case Delete = 'permission:delete';
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Enums\Permissions;
enum ProfilePermissionEnum: string
{
case Access = 'profile:access';
case Create = 'profile:create';
case Read = 'profile:read';
case Update = 'profile:update';
case Delete = 'profile:delete';
}

View File

@ -0,0 +1,34 @@
# Permissions Enums
This directory contains all the permissions enums.
Spatie/laravel-permission uses these enums to check permissions.
Any Permission enum added here will be automatically added to the database, when the
[PermissionSeeder](../../../database/seeders/PermissionSeeder.php) is run, no need to add it manually.
This is done via Reflection API.
`Only string backed are supported.`
## Value format of Enum
We are following the convention of `scope:resource:action` .
**File name** of the enum should be same as the **scope name**, and Enum *value* should be in the format of
*resource:action*.
Example:
UserPermissionEnum.php
```php
namespace App\Enums\Permissions;
enum UserPermissionEnum:string
{
case Create = 'user:create';
case Read = 'user:read';
case Update = 'user:update';
case Delete = 'user:delete';
}
// using the enum makes straight forward permission check
$user->can(UserPermissionEnum::Create);
@can(UserPermissionEnum::Create);
```

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Enums\Permissions;
enum RoleAndAppsPermissionEnum: string
{
case Access = 'roles-and-apps:access';
case Create = 'roles-and-apps:create';
case Read = 'roles-and-apps:read';
case Update = 'roles-and-apps:update';
case Delete = 'roles-and-apps:delete';
case ConnectApps = 'roles-and-apps:connect-apps';
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Enums\Permissions;
enum SecurityPermissionEnum: string
{
case Access = 'security:access';
case Create = 'security:create';
case Read = 'security:read';
case Update = 'security:update';
case Delete = 'security:delete';
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Enums\Permissions;
enum TicketPermissionEnum: string
{
case Access = 'ticket:access';
case Create = 'ticket:create';
case Read = 'ticket:read';
case Update = 'ticket:update';
case Delete = 'ticket:delete';
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Enums\Permissions;
enum UserPermissionEnum: string
{
case Access = 'user:access';
case Create = 'user:create';
case Read = 'user:read';
case Update = 'user:update';
case Delete = 'user:delete';
}

View File

@ -4,7 +4,7 @@
namespace App\Enums;
enum RolesStatus: string
enum RolesStatusEnum: string
{
case Active = 'active';
case Inactive = 'inactive';

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Enums;
use App\Concerns\EnumValuesAsArray;
enum TicketStatusEnum: string
{
use EnumValuesAsArray;
case Open = 'open';
case InProgress = 'in-progress';
case Hold = 'hold';
case Resolved = 'resolved';
case Closed = 'closed';
public static function toLabels()
{
return array_map(fn ($status) => $status->label(), self::cases());
}
public function label(): string
{
return match ($this) {
self::Open => 'Open',
self::InProgress => 'In Progress',
self::Hold => 'Hold',
self::Resolved => 'Resolved',
self::Closed => 'Closed',
};
}
}

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Helpers;
use App\Data\ConnectedApp\ConnectedAppData;
use App\Enums\ConnectedAppPermissonEnum;
class AppPermission
{
/**
* The standard scope name for connected app permissions.
*/
public const SCOPE = 'app';
/**
* Generates the standard permission string following the scope:resource:action convention.
* Format: app:{slug}:{action}
*
* @param ConnectedAppPermissonEnum $permission The action to perform (e.g., 'use')
* @param string|ConnectedAppData $app The connected app object or its slug
*
* @return string The formatted permission name
*/
public static function name(ConnectedAppPermissonEnum $permission, string|ConnectedAppData $app): string
{
$slug = is_string($app) ? $app : $app->slug;
return sprintf('%s:%s:%s', self::SCOPE, $slug, $permission->value);
}
/**
* Parses the permission string to extract the ConnectedAppPermissonEnum type (action).
*
* @param string $permissionName The constructed permission name
*
* @return ?ConnectedAppPermissonEnum The matching enum or null
*/
public static function type(string $permissionName): ?ConnectedAppPermissonEnum
{
$parts = explode(':', $permissionName);
if (3 === count($parts) && self::SCOPE === $parts[0]) {
return ConnectedAppPermissonEnum::tryFrom($parts[2]);
}
return null;
}
/**
* Parses the permission string to extract the connected app slug (resource).
*
* @param string $permissionName The constructed permission name
*
* @return ?string The app slug or null
*/
public static function slug(string $permissionName): ?string
{
$parts = explode(':', $permissionName);
if (3 === count($parts) && self::SCOPE === $parts[0]) {
return $parts[1];
}
return null;
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Helpers;
use App\Enums\DefaultUserRole;
use Illuminate\Support\Collection;
class DashboardRoute
{
/**
* This function will resolve the dashboard route based on the user role.
*/
public static function resolve(Collection $roles): string
{
if ($roles->contains(DefaultUserRole::Admin->value)) {
return 'dashboard.admin';
}
return 'dashboard.user';
}
}

View File

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Helpers;
use FilesystemIterator;
use Generator;
use RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
class EnumExtractor
{
private string $namespace;
private string $directory;
public function __construct(string $namespace, string $directory)
{
$this->namespace = mb_trim($namespace, '\\').'\\';
$this->directory = mb_rtrim($directory, DIRECTORY_SEPARATOR).DIRECTORY_SEPARATOR;
}
/**
* Executes the extraction process and returns an array of actual Enum objects.
*/
public function extract(): array
{
$enumObjects = [];
foreach ($this->getPhpFiles() as $file) {
$className = $this->resolveClassName($file->getPathname());
$this->loadClassIfNeeded($className, $file->getPathname());
if (enum_exists($className)) {
// $className::cases() returns an array of the actual Enum objects
// e.g., [App\Enums\Status::Active, App\Enums\Status::Inactive]
$enumObjects[$className] = $className::cases();
}
}
return $enumObjects;
}
/**
* Yields only PHP files from the target directory.
*/
private function getPhpFiles(): Generator
{
$iterator = new RecursiveIteratorIterator(
new RecursiveDirectoryIterator($this->directory, FilesystemIterator::SKIP_DOTS)
);
/** @var SplFileInfo $file */
foreach ($iterator as $file) {
if ('php' === $file->getExtension()) {
yield $file;
}
}
}
/**
* Converts a physical file path to a fully qualified PSR-4 class name.
*/
private function resolveClassName(string $filePath): string
{
// Strip the base directory from the path
$relativePath = str_replace($this->directory, '', $filePath);
// Normalize slashes and remove the .php extension
$normalizedPath = str_replace([DIRECTORY_SEPARATOR, '/', '.php'], ['\\', '\\', ''], $relativePath);
return $this->namespace.mb_ltrim($normalizedPath, '\\');
}
/**
* Requires the file into memory if it hasn't been autoloaded yet.
*/
private function loadClassIfNeeded(string $className, string $filePath): void
{
if (! class_exists($className, false)
&& ! enum_exists($className, false)
&& ! interface_exists($className, false)
) {
require_once $filePath;
}
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Helpers\DashboardRoute;
use Illuminate\Http\Request;
class ResolveDashboardRouteController extends Controller
{
public function __invoke(Request $request)
{
if (session()->has('saml_pending_request')) {
return redirect()->route('saml.sso');
}
$user = $request->user();
$route = DashboardRoute::resolve($user->getRoleNames());
return to_route($route);
}
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Notifications\{TicketAssignedNotification, TicketCommentedNotification, TicketRaisedNotification, TicketStatusChangedNotification};
use Illuminate\Http\Request;
class ResolveNotificationRouteController extends Controller
{
public function __invoke(Request $request, string $id)
{
$user = $request->user();
if (! $user) {
return to_route('login');
}
$notification = $user->notifications()->findOrFail($id);
$notification->markAsRead();
// Modular pattern: Match notification type to determine where to redirect
$url = match ($notification->type) {
TicketRaisedNotification::class,
TicketAssignedNotification::class,
TicketCommentedNotification::class,
TicketStatusChangedNotification::class => route('tickets.index', [
'ticket' => data_get($notification->data, 'ticket_id'),
]),
// Future expansion:
// \App\Notifications\AppExpiredNotification::class => route('apps.index', ['app' => data_get($notification->data, 'app_id')]),
default => route('dashboard'),
};
return redirect($url);
}
}

View File

@ -0,0 +1,150 @@
<?php
declare(strict_types=1);
namespace App\Http\Controllers;
use App\Services\SamlIdpService;
use Illuminate\Http\{Request, Response};
use Illuminate\Support\Facades\{Auth, Log};
use Throwable;
class SamlIdpController extends Controller
{
public function __construct(private readonly SamlIdpService $samlService) {}
/**
* SAML 2.0 Single Sign-On (SSO) Endpoint.
* Handles both GET (Redirect binding) and POST (POST binding) requests.
*/
public function sso(Request $request)
{
// 1. 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');
if (empty($samlRequest)) {
Log::warning('SAML SSO accessed without a SAMLRequest.');
abort(400, 'Missing SAMLRequest parameter.');
}
try {
// 2. Parse the SAMLRequest XML
$parsed = $this->samlService->parseRequest($samlRequest);
} catch (Throwable $e) {
Log::error('Failed to parse SAMLRequest: '.$e->getMessage());
abort(400, 'Invalid SAMLRequest: '.$e->getMessage());
}
// 3. Find corresponding Connected App by issuer (SAML Entity ID)
$connectedApp = $this->samlService->findConnectedApp($parsed['issuer']);
if (! $connectedApp) {
Log::warning(
"SAML SP Issuer [{$parsed['issuer']}] is not registered.",
);
abort(
403,
"The Service Provider [{$parsed['issuer']}] is not authorized in this system.",
);
}
if ('disconnected' === $connectedApp->status?->name) {
Log::warning(
"SAML SP Issuer [{$parsed['issuer']}] is registered but disconnected.",
);
abort(
403,
"The Service Provider [{$connectedApp->name}] is currently deactivated.",
);
}
/**
* @var array{
* saml?: array{
* entity_id?: string,
* acs_url?: string
* }
* } $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}].",
);
abort(400, 'Assertion Consumer Service (ACS) URL is not defined.');
}
// 4. Authenticate the User
if (! Auth::check()) {
// Store the SAML details in session to complete flow post-login
session([
'saml_pending_request' => $samlRequest,
'saml_pending_relay_state' => $relayState,
]);
return redirect()->route('login');
}
// 5. Generate Signed SAML Response
$user = Auth::user();
try {
/** @var \App\Models\User $user */
$samlResponse = $this->samlService->generateResponse(
$user,
$connectedApp,
$parsed['requestId'],
$acsUrl,
);
} catch (Throwable $e) {
Log::error('Failed to generate SAML Response: '.$e->getMessage());
abort(500, 'Cryptographic signing of SAML Response failed.');
}
// 6. Clear pending SAML request from session
session()->forget(['saml_pending_request', 'saml_pending_relay_state']);
// 7. Return HTTP-POST Auto-Submission Page
return view('saml.post_response', [
'appName' => $connectedApp->name,
'acsUrl' => $acsUrl,
'samlResponse' => $samlResponse,
'relayState' => $relayState,
]);
}
/**
* SAML 2.0 Identity Provider (IdP) Metadata Endpoint.
* Exposes public keys and bindings to Service Providers like Microsoft Entra ID.
*/
public function metadata(): Response
{
$idpEntityId = route('saml.metadata');
$ssoUrl = route('saml.sso');
try {
$metadataXml = $this->samlService->generateMetadata(
$idpEntityId,
$ssoUrl,
);
} catch (Throwable $e) {
Log::error(
'Failed to generate SAML IdP Metadata: '.$e->getMessage(),
);
abort(
500,
'Failed to retrieve Identity Provider cryptographic identity.',
);
}
return response($metadataXml, 200, [
'Content-Type' => 'application/xml; charset=utf-8',
]);
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Http\Responses;
use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract;
/**
* This class is used overide the fortify LoginResponse class, so that
* we can redirect the user to the intended page after login.
*/
class LoginResponse implements LoginResponseContract
{
public function toResponse($request)
{
$user = $request->user();
activity()
->causedBy($user)
->withProperties([
'roles' => $user->getRoleNames(),
])
->log('User logged in}');
return to_route('dashboard');
}
}

View File

@ -4,8 +4,7 @@
namespace App\Livewire\Actions;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Session;
use Illuminate\Support\Facades\{Auth, Session};
final class Logout
{

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Forms;
use Livewire\Attributes\Validate;
use Livewire\Form;
class AssignAppToRoleForm extends Form
{
#[Validate([
'appIds' => 'required|array|min:1',
'appIds.*' => 'integer|exists:connected_apps,id',
])]
public array $appIds = [];
#[Validate('exclude_if:isUnlimited,true|required|date|after_or_equal:today')]
public ?string $validUpto = null;
public bool $isUnlimited = false;
public function reset(mixed ...$properties): void
{
$this->resetErrorBag();
parent::reset(...$properties);
$this->validUpto = null;
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Forms;
use Livewire\Attributes\Validate;
use Livewire\Form;
class AssignRolesForm extends Form
{
/**
* Holds the roles that the user has
*
* @var array <int, int>
*/
#[Validate('required|array')]
public array $roleIds = [];
/**
* Holds the direct permissions toggled for the user (from all selected roles).
*
* @var array<int, int>
*/
#[Validate('nullable|array')]
public array $permissions = [];
/**
* Holds the SSO Immutable ID for the user.
*/
#[Validate('nullable|string|max:255')]
public string $immutableId = '';
}

View File

@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Forms;
use Livewire\Attributes\Validate;
class CreateRoleForm extends AssignAppToRoleForm
{
#[Validate('required|string|max:255|min:3')]
public string $name = '';
#[Validate('required|integer|min:0')]
public int $priority = 0;
}

View File

@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Forms;
use Livewire\Form;
class CreateTicketForm extends Form
{
public ?int $connectedAppId = null;
public string $issueCategory = 'Billing Query';
public string $description = '';
public string $routingType = 'none';
public array $notifiedRoleIds = [];
public array $notifiedUserIds = [];
public mixed $attachment = null;
/**
* Define the validation rules dynamically.
*
* @return array<string, string>
*/
public function rules(): array
{
return [
'connectedAppId' => 'nullable|exists:connected_apps,id',
'issueCategory' => 'required|string',
'routingType' => 'nullable|in:role,user,none',
'notifiedRoleIds' => 'role' === $this->routingType ? 'required|array|min:1' : 'nullable|array',
'notifiedRoleIds.*' => 'role' === $this->routingType ? 'exists:roles,id' : 'nullable',
'notifiedUserIds' => 'user' === $this->routingType ? 'required|array|min:1' : 'nullable|array',
'notifiedUserIds.*' => 'user' === $this->routingType ? 'exists:users,id' : 'nullable',
'description' => 'Others' === $this->issueCategory ? 'required|string|min:20' : 'nullable|string',
'attachment' => 'Others' === $this->issueCategory ? 'nullable|file|mimes:jpg,jpeg,png,pdf|max:5120' : 'nullable',
];
}
/**
* Define validation attributes.
*
* @return array<string, string>
*/
public function validationAttributes(): array
{
return [
'connectedAppId' => 'service',
'issueCategory' => 'issue category',
'routingType' => 'routing type',
'notifiedRoleIds' => 'notified roles',
'notifiedUserIds' => 'notified users',
'description' => 'description',
'attachment' => 'file attachment',
];
}
}

View File

@ -0,0 +1,11 @@
All the forms used in the application are stored here.
Each form should validate the data before sending it to the server.
This is done by using the `validate` method.
Validations that require the db, should not be live (realtime),
they should be done on the only once, preferably after the form is submitted.
Forms should not be used to create, update or any database operation.
Livewire form docs: https://livewire.laravel.com/docs/4.x/forms

View File

@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Forms;
use App\Data\ConnectedApp\ConnectedAppData;
use App\Services\ConnectedAppService;
use Livewire\Form;
class StoreConnectedAppFrom extends Form
{
public string $name = '';
public int $providerId = 0;
public int $protocolId = 0;
public int $statusId = 0;
public string $samlEntityId = '';
public string $samlAcsUrl = '';
protected ConnectedAppService $service;
public function boot(ConnectedAppService $service): void
{
$this->service = $service;
}
/**
* Get the dynamic validation rules for the form.
*
* @return array<string, string>
*/
public function rules(): array
{
$rules = [
'name' => 'required|string|max:255|min:3',
'providerId' => 'required|numeric|min:1|exists:connection_providers,id',
'protocolId' => 'required|numeric|min:1|exists:connection_protocols,id',
'statusId' => 'required|numeric|min:1|exists:connection_statuses,id',
];
$protocol = \App\Models\ConnectionProtocol::find($this->protocolId);
if ($protocol && 'saml' === mb_strtolower($protocol->name)) {
$rules['samlEntityId'] = 'required|string|min:3';
$rules['samlAcsUrl'] = 'required|url';
}
return $rules;
}
/**
* This sets the form to edit mode, after fetching the connected app of
* the given id.
*/
public function set(ConnectedAppData $data): void
{
$this->name = $data->name;
$this->protocolId = $data->protocolId;
$this->providerId = $data->providerId;
$this->statusId = $data->statusId;
$this->samlEntityId = $data->settings['saml']['entity_id'] ?? '';
$this->samlAcsUrl = $data->settings['saml']['acs_url'] ?? '';
}
}

View File

@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace App\Livewire\Forms;
use App\Data\ConnectedApp\ConnectionProviderData;
use App\Models\ConnectionProvider;
use Closure;
use Illuminate\Support\Str;
use Livewire\Form;
class StoreConnectionProviderForm extends Form
{
public string $name = '';
public ?int $connectionProviderId = null;
public function set(ConnectionProviderData $provider): void
{
$this->connectionProviderId = $provider->id;
$this->name = $provider->name;
}
public function rules(): array
{
return [
'name' => [
'required',
'string',
'min:3',
'max:255',
function (string $attribute, mixed $value, Closure $fail): void {
$slug = Str::slug($value);
$query = ConnectionProvider::query()
->where('slug', $slug);
if ($this->connectionProviderId) {
$query->where('id', '!=', $this->connectionProviderId);
}
if ($query->exists()) {
$fail("The name '{$value}' is already taken.");
}
},
],
];
}
}

View File

@ -15,6 +15,8 @@ final class DeleteUserForm extends Component
public string $password = '';
public bool $confirmingUserDeletion = false;
/**
* Delete the currently authenticated user.
*/

View File

@ -8,8 +8,7 @@
use Flux\Flux;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Title;
use Livewire\Attributes\{Computed, Title};
use Livewire\Component;
#[Title('Profile settings')]
@ -71,13 +70,18 @@ public function resendVerificationNotification(): void
#[Computed]
public function hasUnverifiedEmail(): bool
{
return Auth::user() instanceof MustVerifyEmail && ! Auth::user()->hasVerifiedEmail();
/** @var \App\Models\User|MustVerifyEmail|null $user */
$user = Auth::user();
return $user instanceof MustVerifyEmail && ! $user->hasVerifiedEmail();
}
#[Computed]
public function showDeleteUser(): bool
{
return ! Auth::user() instanceof MustVerifyEmail
|| (Auth::user() instanceof MustVerifyEmail && Auth::user()->hasVerifiedEmail());
/** @var \App\Models\User|MustVerifyEmail|null $user */
$user = Auth::user();
return ! $user instanceof MustVerifyEmail || $user->hasVerifiedEmail();
}
}

View File

@ -9,15 +9,9 @@
use Flux\Flux;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Laravel\Fortify\Features;
use Laravel\Fortify\Fortify;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Attributes\Validate;
use Laravel\Fortify\Actions\{ConfirmTwoFactorAuthentication, DisableTwoFactorAuthentication, EnableTwoFactorAuthentication};
use Laravel\Fortify\{Features, Fortify};
use Livewire\Attributes\{Computed, Locked, Title, Validate};
use Livewire\Component;
#[Title('Security settings')]

39
app/Models/AppRole.php Normal file
View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\{Relations\BelongsTo, Relations\Pivot};
use Spatie\Permission\Models\Role;
/**
* @property int $id
* @property int $app_id
* @property int $role_id
* @property string $duration Validity of the role
*/
#[Fillable(['app_id', 'role_id', 'duration'])]
class AppRole extends Pivot
{
public $timestamps = false;
protected $table = 'app_roles';
/**
* @return BelongsTo<ConnectedApp, $this>
*/
public function app(): BelongsTo
{
return $this->belongsTo(ConnectedApp::class, 'app_id');
}
/**
* @return BelongsTo<Role, $this>
*/
public function role(): BelongsTo
{
return $this->belongsTo(Role::class, 'role_id');
}
}

View File

@ -0,0 +1,90 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\{Model, Relations\BelongsTo, Relations\BelongsToMany, SoftDeletes};
use Illuminate\Database\Eloquent\Relations\HasOne;
use Spatie\Activitylog\Models\Concerns\LogsActivity;
use Spatie\Activitylog\Support\LogOptions;
/**
* @property int $id
* @property string $name
* @property AppRole $pivot
*/
#[Fillable([
'name',
'slug',
'connection_protocol_id',
'connection_provider_id',
'connection_status_id',
'settings',
])]
class ConnectedApp extends Model
{
use HasFactory, LogsActivity, SoftDeletes;
/**
* Get the attributes that should be cast.
*
* @return array<string, string>
*/
protected function casts(): array
{
return [
'settings' => 'array',
];
}
/**
* @return HasOne<ConnectionProtocol, $this>
*/
public function protocol(): HasOne
{
return $this->hasOne(ConnectionProtocol::class, 'id', 'connection_protocol_id');
}
/**
* @return BelongsTo<ConnectionProvider, $this>
*/
public function provider(): BelongsTo
{
return $this->belongsTo(ConnectionProvider::class, 'connection_provider_id');
}
/**
* @return HasOne<ConnectionStatus, $this>
*/
public function status(): HasOne
{
return $this->hasOne(ConnectionStatus::class, 'id', 'connection_status_id');
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logFillable() // Log all fillable attributes
->logOnlyDirty() // Log only attributes that actually changed
->dontLogIfAttributesChangedOnly(['updated_at']) // Skip if only updated_at changed
->dontLogEmptyChanges() // Prevent saving logs with no changed attributes
->setDescriptionForEvent(fn (
string $eventName
) => ":causer.name {$eventName} connected app ':subject.name'");
}
public function roles(): BelongsToMany
{
return $this->belongsToMany(
Role::class,
'app_roles',
'app_id',
'role_id',
)
->using(AppRole::class)
->withPivot('duration');
}
}

View File

@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\{Model, SoftDeletes};
/**
* @property int $id
* @property string $name
* @property string $slug
*/
final class ConnectionProtocol extends Model
{
use SoftDeletes;
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\{Model, Relations\HasMany, SoftDeletes};
use Spatie\Activitylog\Models\Concerns\LogsActivity;
use Spatie\Activitylog\Support\LogOptions;
#[Fillable(['name', 'slug'])]
final class ConnectionProvider extends Model
{
use LogsActivity, SoftDeletes;
public function connectedApps(): HasMany
{
return $this->hasMany(ConnectedApp::class);
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logFillable() // Log all fillable attributes
->logOnlyDirty() // Log only attributes that actually changed
->dontLogIfAttributesChangedOnly(['updated_at']) // Skip if only updated_at changed
->dontLogEmptyChanges() // Prevent saving logs with no changed attributes
->setDescriptionForEvent(fn (
string $eventName
) => ":causer.name {$eventName} connection provider ':subject.name'");
}
}

View File

@ -0,0 +1,14 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Table;
use Illuminate\Database\Eloquent\{Model, SoftDeletes};
#[Table('connection_statuses')]
class ConnectionStatus extends Model
{
use SoftDeletes;
}

View File

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Relations\{BelongsTo, Pivot};
use Spatie\Permission\Models\Permission;
#[Fillable(['user_id', 'role_id', 'permission_id'])]
class RestrictedUserPermission extends Pivot
{
protected $table = 'restricted_user_permissions';
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* @return BelongsTo<Role, $this>
*/
public function role(): BelongsTo
{
return $this->belongsTo(Role::class);
}
/**
* @return BelongsTo<Permission, $this>
*/
public function permission(): BelongsTo
{
return $this->belongsTo(Permission::class);
}
}

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

@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Spatie\Activitylog\Models\Concerns\LogsActivity;
use Spatie\Activitylog\Support\LogOptions;
use Spatie\Permission\Models\{Permission, Role as SpatieRole};
/**
* @property int $priority
* @property AppRole $pivot
* @property ConnectedApp $apps
*
* @method static Builder<Role> visible()
*/
#[Fillable(['name', 'guard_name', 'priority'])]
class Role extends SpatieRole
{
use LogsActivity;
public function apps(): BelongsToMany
{
return $this->belongsToMany(
ConnectedApp::class,
'app_roles',
'role_id',
'app_id'
)
->using(AppRole::class)
->withPivot('id', 'duration');
}
public function getActivitylogOptions(): LogOptions
{
return LogOptions::defaults()
->logOnly(['name', 'priority'])
->logOnlyDirty()
->useLogName('role_management');
}
/**
* Override the Spatie permissions relationship to provide precise type-hinting for Larastan.
*
* @return BelongsToMany<Permission, Role>
*/
public function permissions(): BelongsToMany
{
return parent::permissions();
}
/**
* Scope a query to only include roles with priority strictly greater than the logged in user's priority (lower privilege).
*/
public function scopeVisible(Builder $query): Builder
{
if (! auth()->check()) {
return $query;
}
$userPriority = auth()->user()?->roles()->min('priority') ?? 999999;
return $query->where('priority', '>', $userPriority);
}
}

111
app/Models/Ticket.php Normal file
View File

@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\{Model, SoftDeletes};
use Illuminate\Database\Eloquent\Relations\{BelongsTo, BelongsToMany, HasMany};
use Illuminate\Support\Str;
#[Fillable([
'ticket_id',
'user_id',
'connected_app_id',
'issue_category',
'description',
'status_id',
'assigned_user_id',
])]
class Ticket extends Model
{
use SoftDeletes;
protected static function booted(): void
{
static::creating(function (Ticket $ticket): void {
if (empty($ticket->ticket_id)) {
do {
$ticketId = 'TKT-'.mb_strtoupper(Str::random(8));
} while (static::query()->where('ticket_id', $ticketId)->exists());
$ticket->ticket_id = $ticketId;
}
});
}
/**
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
/**
* @return BelongsTo<ConnectedApp, $this>
*/
public function connectedApp(): BelongsTo
{
return $this->belongsTo(ConnectedApp::class, 'connected_app_id');
}
/**
* @return BelongsTo<TicketStatus, $this>
*/
public function status(): BelongsTo
{
return $this->belongsTo(TicketStatus::class, 'status_id');
}
/**
* @return BelongsTo<User, $this>
*/
public function assignedUser(): BelongsTo
{
return $this->belongsTo(User::class, 'assigned_user_id');
}
/**
* @return BelongsToMany<Role, $this>
*/
public function notifiedRoles(): BelongsToMany
{
return $this->belongsToMany(
Role::class,
'ticket_notified_roles',
'ticket_id',
'role_id'
);
}
/**
* @return BelongsToMany<User, $this>
*/
public function notifiedUsers(): BelongsToMany
{
return $this->belongsToMany(
User::class,
'ticket_notified_users',
'ticket_id',
'user_id'
);
}
/**
* @return HasMany<TicketAttachment, $this>
*/
public function attachments(): HasMany
{
return $this->hasMany(TicketAttachment::class, 'ticket_id');
}
/**
* @return HasMany<TicketReply, $this>
*/
public function replies(): HasMany
{
return $this->hasMany(TicketReply::class, 'ticket_id');
}
}

View File

@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable([
'ticket_id',
'file_path',
'file_name',
'file_size',
'mime_type',
])]
class TicketAttachment extends Model
{
use HasFactory;
/**
* @return BelongsTo<Ticket, $this>
*/
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class, 'ticket_id');
}
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
#[Fillable(['ticket_id', 'user_id', 'message'])]
class TicketReply extends Model
{
/**
* The table associated with the model.
*
* @var string
*/
protected $table = 'ticket_replies';
/**
* Get the ticket that this reply belongs to.
*
* @return BelongsTo<Ticket, $this>
*/
public function ticket(): BelongsTo
{
return $this->belongsTo(Ticket::class, 'ticket_id');
}
/**
* Get the user who posted this reply.
*
* @return BelongsTo<User, $this>
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class, 'user_id');
}
}

View File

@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Models;
use Illuminate\Database\Eloquent\Attributes\Fillable;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
#[Fillable(['name', 'slug'])]
class TicketStatus extends Model
{
use HasFactory;
}

Some files were not shown because too many files have changed in this diff Show More