diff --git a/.agents/skills/fluxui-development/SKILL.md b/.agents/skills/fluxui-development/SKILL.md deleted file mode 100644 index 4b5aabb..0000000 --- a/.agents/skills/fluxui-development/SKILL.md +++ /dev/null @@ -1,81 +0,0 @@ ---- -name: fluxui-development -description: "Use this skill for Flux UI development in Livewire applications only. Trigger when working with 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. - - -```blade -Click me -``` - -## 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. - - -```blade -Export -``` - -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 - - -```blade - - Email - - - -``` - -### Modals - - -```blade - - Title -

Content

-
-``` - -## 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 \ No newline at end of file diff --git a/.env.example b/.env.example index c0660ea..0f986fe 100644 --- a/.env.example +++ b/.env.example @@ -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" + diff --git a/.gitignore b/.gitignore index fb8a897..381e547 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,4 @@ yarn-error.log /.vscode /.zed /storage/framework/views/* +laradumps.yaml diff --git a/AGENTS.md b/AGENTS.md index 12f587b..fbc0295 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 12f587b..fbc0295 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/CONTRIBUTING.MD b/CONTRIBUTING.MD new file mode 100644 index 0000000..057931c --- /dev/null +++ b/CONTRIBUTING.MD @@ -0,0 +1,2 @@ +Always use `#[NoDiscard]` attribute in function which fetches data from DB. This ensures that +db does not called unnecessarily. diff --git a/DATABASE.md b/DATABASE.md new file mode 100644 index 0000000..a845589 --- /dev/null +++ b/DATABASE.md @@ -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) diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 3bdcd16..6cab060 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -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; diff --git a/app/Concerns/Confirmation.php b/app/Concerns/Confirmation.php new file mode 100644 index 0000000..6e37799 --- /dev/null +++ b/app/Concerns/Confirmation.php @@ -0,0 +1,65 @@ +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); + } + } +} diff --git a/app/Concerns/EnumValuesAsArray.php b/app/Concerns/EnumValuesAsArray.php new file mode 100644 index 0000000..97e4f2d --- /dev/null +++ b/app/Concerns/EnumValuesAsArray.php @@ -0,0 +1,13 @@ +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()); + } + } +} diff --git a/app/Concerns/PasswordValidationRules.php b/app/Concerns/PasswordValidationRules.php index cfafcd8..4337571 100644 --- a/app/Concerns/PasswordValidationRules.php +++ b/app/Concerns/PasswordValidationRules.php @@ -12,7 +12,7 @@ trait PasswordValidationRules /** * Get the validation rules used to validate passwords. * - * @return array|string> + * @return array */ protected function passwordRules(): array { diff --git a/app/Concerns/ProfileValidationRules.php b/app/Concerns/ProfileValidationRules.php index 3b82428..4a0e583 100644 --- a/app/Concerns/ProfileValidationRules.php +++ b/app/Concerns/ProfileValidationRules.php @@ -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>> + * @return array> */ 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|string> + * @return array */ protected function emailRules(?int $userId = null): array { diff --git a/app/Console/Commands/GenerateSamlKeysCommand.php b/app/Console/Commands/GenerateSamlKeysCommand.php new file mode 100644 index 0000000..7583774 --- /dev/null +++ b/app/Console/Commands/GenerateSamlKeysCommand.php @@ -0,0 +1,115 @@ +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; + } +} diff --git a/app/Data/ConnectedApp/ConnectAppRequest.php b/app/Data/ConnectedApp/ConnectAppRequest.php new file mode 100644 index 0000000..518d8e7 --- /dev/null +++ b/app/Data/ConnectedApp/ConnectAppRequest.php @@ -0,0 +1,22 @@ +slug = str($name)->slug()->toString(); + } +} diff --git a/app/Data/MicrosoftGraph/MicrosoftGraphCredentialsData.php b/app/Data/MicrosoftGraph/MicrosoftGraphCredentialsData.php new file mode 100644 index 0000000..8983f27 --- /dev/null +++ b/app/Data/MicrosoftGraph/MicrosoftGraphCredentialsData.php @@ -0,0 +1,23 @@ +name); + $resource = count($parts) > 1 ? $parts[0] : 'general'; + + return new self( + id: $permission->id, + name: $permission->name, + type: AppPermission::type($permission->name), + resource: $resource, + ); + } +} diff --git a/app/Data/Role/AssignAppsToRoleData.php b/app/Data/Role/AssignAppsToRoleData.php new file mode 100644 index 0000000..32f0b22 --- /dev/null +++ b/app/Data/Role/AssignAppsToRoleData.php @@ -0,0 +1,17 @@ +pivot->id, + appId: $app->id, + appName: $app->name, + duration: $app->pivot->duration + ); + } +} diff --git a/app/Data/Role/RoleData.php b/app/Data/Role/RoleData.php new file mode 100644 index 0000000..7569917 --- /dev/null +++ b/app/Data/Role/RoleData.php @@ -0,0 +1,24 @@ +id, + name: $user->name, + initials: $user->initials() + ); + } +} diff --git a/app/Data/Role/UpdateRolePriorityData.php b/app/Data/Role/UpdateRolePriorityData.php new file mode 100644 index 0000000..91cd31f --- /dev/null +++ b/app/Data/Role/UpdateRolePriorityData.php @@ -0,0 +1,15 @@ +id, + name: $app->name, + protocol: ConnectionProtocolEnum::tryFrom($app->protocol->name), + provider: $app->provider?->name, + status: ConnectionStatusEnum::tryFrom($app->status->name) + ); + } +} diff --git a/app/Data/Ui/ConnectedServices/ConnectedServiceCollectionData.php b/app/Data/Ui/ConnectedServices/ConnectedServiceCollectionData.php deleted file mode 100644 index 68539b7..0000000 --- a/app/Data/Ui/ConnectedServices/ConnectedServiceCollectionData.php +++ /dev/null @@ -1,14 +0,0 @@ - - */ -final class ConnectedServiceCollectionData extends Collection {} diff --git a/app/Data/Ui/ConnectedServices/ConnectedServiceData.php b/app/Data/Ui/ConnectedServices/ConnectedServiceData.php deleted file mode 100644 index 2b2c10e..0000000 --- a/app/Data/Ui/ConnectedServices/ConnectedServiceData.php +++ /dev/null @@ -1,24 +0,0 @@ - */ + #[DataCollectionOf(class: UserData::class), ] + public DataCollection $users, + + /** @var DataCollection */ + #[DataCollectionOf(class: ServiceData::class), ] + public DataCollection $services, + public RolesStatusEnum $status, ) {} } diff --git a/app/Data/Ui/ManageRoles/RolesCollection.php b/app/Data/Ui/ManageRoles/RolesCollection.php deleted file mode 100644 index 9b524f5..0000000 --- a/app/Data/Ui/ManageRoles/RolesCollection.php +++ /dev/null @@ -1,12 +0,0 @@ - - */ -final class RolesCollection extends Collection {} diff --git a/app/Data/Ui/ManageRoles/RolesData.php b/app/Data/Ui/ManageRoles/RolesData.php deleted file mode 100644 index b8c36f3..0000000 --- a/app/Data/Ui/ManageRoles/RolesData.php +++ /dev/null @@ -1,17 +0,0 @@ - - */ -final class ServiceCollection extends Collection {} diff --git a/app/Data/Ui/ManageRoles/ServiceData.php b/app/Data/Ui/ManageRoles/ServiceData.php index 46a73fb..d8f0f08 100644 --- a/app/Data/Ui/ManageRoles/ServiceData.php +++ b/app/Data/Ui/ManageRoles/ServiceData.php @@ -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) {} } diff --git a/app/Data/Ui/README.md b/app/Data/Ui/README.md new file mode 100644 index 0000000..3b16e88 --- /dev/null +++ b/app/Data/Ui/README.md @@ -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. diff --git a/app/Data/Ui/Sidebar/NavItemData.php b/app/Data/Ui/Sidebar/NavItemData.php new file mode 100644 index 0000000..dea089c --- /dev/null +++ b/app/Data/Ui/Sidebar/NavItemData.php @@ -0,0 +1,20 @@ +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(), + ); + } +} diff --git a/app/Data/Ui/Ticket/TicketData.php b/app/Data/Ui/Ticket/TicketData.php new file mode 100644 index 0000000..bd45aff --- /dev/null +++ b/app/Data/Ui/Ticket/TicketData.php @@ -0,0 +1,67 @@ +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, + ); + } +} diff --git a/app/Data/Ui/Ticket/TicketListData.php b/app/Data/Ui/Ticket/TicketListData.php new file mode 100644 index 0000000..cac6250 --- /dev/null +++ b/app/Data/Ui/Ticket/TicketListData.php @@ -0,0 +1,39 @@ +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, + ); + } +} diff --git a/app/Data/User/UserIndexData.php b/app/Data/User/UserIndexData.php new file mode 100644 index 0000000..8bf75d5 --- /dev/null +++ b/app/Data/User/UserIndexData.php @@ -0,0 +1,37 @@ +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 + ); + } +} diff --git a/app/Data/Users/UserCollection.php b/app/Data/Users/UserCollection.php deleted file mode 100644 index c52f593..0000000 --- a/app/Data/Users/UserCollection.php +++ /dev/null @@ -1,12 +0,0 @@ - - */ -final class UserCollection extends Collection {} diff --git a/app/Data/Users/UserData.php b/app/Data/Users/UserData.php index 2176714..34911a5 100644 --- a/app/Data/Users/UserData.php +++ b/app/Data/Users/UserData.php @@ -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|null */ + #[DataCollectionOf(class: RoleData::class)] + public ?DataCollection $roles = null, + ) {} } diff --git a/app/Data/Users/UsersData.php b/app/Data/Users/UsersData.php deleted file mode 100644 index 97935d9..0000000 --- a/app/Data/Users/UsersData.php +++ /dev/null @@ -1,14 +0,0 @@ -can(UserPermissionEnum::Create); + @can(UserPermissionEnum::Create); +``` diff --git a/app/Enums/Permissions/RoleAndAppsPermissionEnum.php b/app/Enums/Permissions/RoleAndAppsPermissionEnum.php new file mode 100644 index 0000000..3517609 --- /dev/null +++ b/app/Enums/Permissions/RoleAndAppsPermissionEnum.php @@ -0,0 +1,15 @@ + $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', + }; + } +} diff --git a/app/Helpers/AppPermission.php b/app/Helpers/AppPermission.php new file mode 100644 index 0000000..258a0ee --- /dev/null +++ b/app/Helpers/AppPermission.php @@ -0,0 +1,68 @@ +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; + } +} diff --git a/app/Helpers/DashboardRoute.php b/app/Helpers/DashboardRoute.php new file mode 100644 index 0000000..e96763b --- /dev/null +++ b/app/Helpers/DashboardRoute.php @@ -0,0 +1,23 @@ +contains(DefaultUserRole::Admin->value)) { + return 'dashboard.admin'; + } + + return 'dashboard.user'; + } +} diff --git a/app/Helpers/EnumExtractor.php b/app/Helpers/EnumExtractor.php new file mode 100644 index 0000000..a4e18a6 --- /dev/null +++ b/app/Helpers/EnumExtractor.php @@ -0,0 +1,90 @@ +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; + } + } +} diff --git a/app/Http/Controllers/ResolveDashboardRouteController.php b/app/Http/Controllers/ResolveDashboardRouteController.php new file mode 100644 index 0000000..cbdfb94 --- /dev/null +++ b/app/Http/Controllers/ResolveDashboardRouteController.php @@ -0,0 +1,23 @@ +has('saml_pending_request')) { + return redirect()->route('saml.sso'); + } + + $user = $request->user(); + $route = DashboardRoute::resolve($user->getRoleNames()); + + return to_route($route); + } +} diff --git a/app/Http/Controllers/ResolveNotificationRouteController.php b/app/Http/Controllers/ResolveNotificationRouteController.php new file mode 100644 index 0000000..0f6be7e --- /dev/null +++ b/app/Http/Controllers/ResolveNotificationRouteController.php @@ -0,0 +1,39 @@ +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); + } +} diff --git a/app/Http/Controllers/SamlIdpController.php b/app/Http/Controllers/SamlIdpController.php new file mode 100644 index 0000000..f45a0bb --- /dev/null +++ b/app/Http/Controllers/SamlIdpController.php @@ -0,0 +1,150 @@ +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', + ]); + } +} diff --git a/app/Http/Responses/LoginResponse.php b/app/Http/Responses/LoginResponse.php new file mode 100644 index 0000000..99d27ce --- /dev/null +++ b/app/Http/Responses/LoginResponse.php @@ -0,0 +1,28 @@ +user(); + + activity() + ->causedBy($user) + ->withProperties([ + 'roles' => $user->getRoleNames(), + ]) + ->log('User logged in}'); + + return to_route('dashboard'); + } +} diff --git a/app/Livewire/Actions/Logout.php b/app/Livewire/Actions/Logout.php index 9f0143e..68685d4 100644 --- a/app/Livewire/Actions/Logout.php +++ b/app/Livewire/Actions/Logout.php @@ -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 { diff --git a/app/Livewire/Forms/AssignAppToRoleForm.php b/app/Livewire/Forms/AssignAppToRoleForm.php new file mode 100644 index 0000000..b1a20e1 --- /dev/null +++ b/app/Livewire/Forms/AssignAppToRoleForm.php @@ -0,0 +1,29 @@ + '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; + } +} diff --git a/app/Livewire/Forms/AssignRolesForm.php b/app/Livewire/Forms/AssignRolesForm.php new file mode 100644 index 0000000..05054ed --- /dev/null +++ b/app/Livewire/Forms/AssignRolesForm.php @@ -0,0 +1,33 @@ + + */ + #[Validate('required|array')] + public array $roleIds = []; + + /** + * Holds the direct permissions toggled for the user (from all selected roles). + * + * @var array + */ + #[Validate('nullable|array')] + public array $permissions = []; + + /** + * Holds the SSO Immutable ID for the user. + */ + #[Validate('nullable|string|max:255')] + public string $immutableId = ''; +} diff --git a/app/Livewire/Forms/CreateRoleForm.php b/app/Livewire/Forms/CreateRoleForm.php new file mode 100644 index 0000000..cfc1972 --- /dev/null +++ b/app/Livewire/Forms/CreateRoleForm.php @@ -0,0 +1,16 @@ + + */ + 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 + */ + public function validationAttributes(): array + { + return [ + 'connectedAppId' => 'service', + 'issueCategory' => 'issue category', + 'routingType' => 'routing type', + 'notifiedRoleIds' => 'notified roles', + 'notifiedUserIds' => 'notified users', + 'description' => 'description', + 'attachment' => 'file attachment', + ]; + } +} diff --git a/app/Livewire/Forms/README.md b/app/Livewire/Forms/README.md new file mode 100644 index 0000000..c56ed87 --- /dev/null +++ b/app/Livewire/Forms/README.md @@ -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 diff --git a/app/Livewire/Forms/StoreConnectedAppFrom.php b/app/Livewire/Forms/StoreConnectedAppFrom.php new file mode 100644 index 0000000..6d9cee4 --- /dev/null +++ b/app/Livewire/Forms/StoreConnectedAppFrom.php @@ -0,0 +1,68 @@ +service = $service; + } + + /** + * Get the dynamic validation rules for the form. + * + * @return array + */ + 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'] ?? ''; + } +} diff --git a/app/Livewire/Forms/StoreConnectionProviderForm.php b/app/Livewire/Forms/StoreConnectionProviderForm.php new file mode 100644 index 0000000..0e2b8a2 --- /dev/null +++ b/app/Livewire/Forms/StoreConnectionProviderForm.php @@ -0,0 +1,50 @@ +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."); + } + }, + ], + ]; + } +} diff --git a/app/Livewire/Settings/DeleteUserForm.php b/app/Livewire/Settings/DeleteUserForm.php index 74a2ba2..2906ace 100644 --- a/app/Livewire/Settings/DeleteUserForm.php +++ b/app/Livewire/Settings/DeleteUserForm.php @@ -15,6 +15,8 @@ final class DeleteUserForm extends Component public string $password = ''; + public bool $confirmingUserDeletion = false; + /** * Delete the currently authenticated user. */ diff --git a/app/Livewire/Settings/Profile.php b/app/Livewire/Settings/Profile.php index 537debc..3edd38b 100644 --- a/app/Livewire/Settings/Profile.php +++ b/app/Livewire/Settings/Profile.php @@ -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(); } } diff --git a/app/Livewire/Settings/Security.php b/app/Livewire/Settings/Security.php index bb6e3e7..75597ec 100644 --- a/app/Livewire/Settings/Security.php +++ b/app/Livewire/Settings/Security.php @@ -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')] @@ -102,7 +96,7 @@ public function enable(EnableTwoFactorAuthentication $enableTwoFactorAuthenticat { $enableTwoFactorAuthentication(auth()->user()); - if ( ! $this->requiresConfirmation) { + if (! $this->requiresConfirmation) { $this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication(); } @@ -176,7 +170,7 @@ public function closeModal(): void $this->resetErrorBag(); - if ( ! $this->requiresConfirmation) { + if (! $this->requiresConfirmation) { $this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication(); } } diff --git a/app/Models/AppRole.php b/app/Models/AppRole.php new file mode 100644 index 0000000..a0f5368 --- /dev/null +++ b/app/Models/AppRole.php @@ -0,0 +1,39 @@ + + */ + public function app(): BelongsTo + { + return $this->belongsTo(ConnectedApp::class, 'app_id'); + } + + /** + * @return BelongsTo + */ + public function role(): BelongsTo + { + return $this->belongsTo(Role::class, 'role_id'); + } +} diff --git a/app/Models/ConnectedApp.php b/app/Models/ConnectedApp.php new file mode 100644 index 0000000..6ebd9af --- /dev/null +++ b/app/Models/ConnectedApp.php @@ -0,0 +1,90 @@ + + */ + protected function casts(): array + { + return [ + 'settings' => 'array', + ]; + } + + /** + * @return HasOne + */ + public function protocol(): HasOne + { + return $this->hasOne(ConnectionProtocol::class, 'id', 'connection_protocol_id'); + } + + /** + * @return BelongsTo + */ + public function provider(): BelongsTo + { + return $this->belongsTo(ConnectionProvider::class, 'connection_provider_id'); + } + + /** + * @return HasOne + */ + 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'); + } +} diff --git a/app/Models/ConnectionProtocol.php b/app/Models/ConnectionProtocol.php new file mode 100644 index 0000000..20691bc --- /dev/null +++ b/app/Models/ConnectionProtocol.php @@ -0,0 +1,17 @@ +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'"); + } +} diff --git a/app/Models/ConnectionStatus.php b/app/Models/ConnectionStatus.php new file mode 100644 index 0000000..7726f4c --- /dev/null +++ b/app/Models/ConnectionStatus.php @@ -0,0 +1,14 @@ + + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * @return BelongsTo + */ + public function role(): BelongsTo + { + return $this->belongsTo(Role::class); + } + + /** + * @return BelongsTo + */ + public function permission(): BelongsTo + { + return $this->belongsTo(Permission::class); + } +} diff --git a/app/Models/Role.php b/app/Models/Role.php new file mode 100644 index 0000000..b164ce6 --- /dev/null +++ b/app/Models/Role.php @@ -0,0 +1,69 @@ + 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 + */ + 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); + } +} diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php new file mode 100644 index 0000000..67efa3a --- /dev/null +++ b/app/Models/Ticket.php @@ -0,0 +1,111 @@ +ticket_id)) { + do { + $ticketId = 'TKT-'.mb_strtoupper(Str::random(8)); + } while (static::query()->where('ticket_id', $ticketId)->exists()); + + $ticket->ticket_id = $ticketId; + } + }); + } + + /** + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * @return BelongsTo + */ + public function connectedApp(): BelongsTo + { + return $this->belongsTo(ConnectedApp::class, 'connected_app_id'); + } + + /** + * @return BelongsTo + */ + public function status(): BelongsTo + { + return $this->belongsTo(TicketStatus::class, 'status_id'); + } + + /** + * @return BelongsTo + */ + public function assignedUser(): BelongsTo + { + return $this->belongsTo(User::class, 'assigned_user_id'); + } + + /** + * @return BelongsToMany + */ + public function notifiedRoles(): BelongsToMany + { + return $this->belongsToMany( + Role::class, + 'ticket_notified_roles', + 'ticket_id', + 'role_id' + ); + } + + /** + * @return BelongsToMany + */ + public function notifiedUsers(): BelongsToMany + { + return $this->belongsToMany( + User::class, + 'ticket_notified_users', + 'ticket_id', + 'user_id' + ); + } + + /** + * @return HasMany + */ + public function attachments(): HasMany + { + return $this->hasMany(TicketAttachment::class, 'ticket_id'); + } + + /** + * @return HasMany + */ + public function replies(): HasMany + { + return $this->hasMany(TicketReply::class, 'ticket_id'); + } +} diff --git a/app/Models/TicketAttachment.php b/app/Models/TicketAttachment.php new file mode 100644 index 0000000..288fcfe --- /dev/null +++ b/app/Models/TicketAttachment.php @@ -0,0 +1,30 @@ + + */ + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class, 'ticket_id'); + } +} diff --git a/app/Models/TicketReply.php b/app/Models/TicketReply.php new file mode 100644 index 0000000..9a945f2 --- /dev/null +++ b/app/Models/TicketReply.php @@ -0,0 +1,40 @@ + + */ + public function ticket(): BelongsTo + { + return $this->belongsTo(Ticket::class, 'ticket_id'); + } + + /** + * Get the user who posted this reply. + * + * @return BelongsTo + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } +} diff --git a/app/Models/TicketStatus.php b/app/Models/TicketStatus.php new file mode 100644 index 0000000..8efb8dc --- /dev/null +++ b/app/Models/TicketStatus.php @@ -0,0 +1,15 @@ + */ - use HasFactory; + use HasFactory, SoftDeletes; + + use HasRoles; use Notifiable; use TwoFactorAuthenticatable; + public function restrictedPermissions(): \Illuminate\Database\Eloquent\Relations\BelongsToMany + { + return $this->belongsToMany( + \Spatie\Permission\Models\Permission::class, + 'restricted_user_permissions', + 'user_id', + 'permission_id' + )->withPivot('role_id')->withTimestamps(); + } + /** * Get the user's initials */ @@ -31,7 +44,7 @@ public function initials(): string return Str::of($this->name) ->explode(' ') ->take(2) - ->map(fn($word) => Str::substr($word, 0, 1)) + ->map(fn ($word) => Str::substr($word, 0, 1)) ->implode(''); } diff --git a/app/Notifications/TicketAssignedNotification.php b/app/Notifications/TicketAssignedNotification.php new file mode 100644 index 0000000..1d823e0 --- /dev/null +++ b/app/Notifications/TicketAssignedNotification.php @@ -0,0 +1,66 @@ + + */ + public function via(object $notifiable): array + { + return ['database', 'mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage()) + ->subject("SSO Support Ticket Assigned: {$this->ticket->ticket_id}") + ->greeting('Hello,') + ->line("You have been assigned as the support owner for ticket {$this->ticket->ticket_id} regarding {$this->ticket->issue_category}.") + ->line('Customer: '.$this->ticket->user->name) + ->line('Description: '.($this->ticket->description ?? 'N/A')) + ->action('View Ticket Details', route('tickets.index')) + ->line('Thank you for using our application!'); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + $appName = $this->ticket->connectedApp->name ?? 'General Inquiry'; + + return [ + 'ticket_id' => $this->ticket->id, + 'ticket_code' => $this->ticket->ticket_id, + 'user_name' => $this->ticket->user->name, + 'issue_category' => $this->ticket->issue_category, + 'app_name' => $appName, + 'description' => Str::limit($this->ticket->description ?? '', 100), + 'message' => "You have been assigned to ticket {$this->ticket->ticket_id} by the administrator.", + ]; + } +} diff --git a/app/Notifications/TicketCommentedNotification.php b/app/Notifications/TicketCommentedNotification.php new file mode 100644 index 0000000..50c026a --- /dev/null +++ b/app/Notifications/TicketCommentedNotification.php @@ -0,0 +1,69 @@ + + */ + public function via(object $notifiable): array + { + return ['database', 'mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + $commenterName = $this->comment->user->name; + + return (new MailMessage()) + ->subject("New Comment on Ticket: {$this->ticket->ticket_id}") + ->greeting('Hello,') + ->line("{$commenterName} left a new comment on ticket {$this->ticket->ticket_id} regarding {$this->ticket->issue_category}.") + ->line('Comment: "'.Str::limit($this->comment->message, 300).'"') + ->action('View Discussion', route('tickets.index')) + ->line('Thank you for using our application!'); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + $commenterName = $this->comment->user->name; + $appName = $this->ticket->connectedApp->name ?? 'General Inquiry'; + + return [ + 'ticket_id' => $this->ticket->id, + 'ticket_code' => $this->ticket->ticket_id, + 'user_name' => $commenterName, + 'issue_category' => $this->ticket->issue_category, + 'app_name' => $appName, + 'description' => Str::limit($this->ticket->description ?? '', 100), + 'message' => "{$commenterName} commented on ticket {$this->ticket->ticket_id}.", + ]; + } +} diff --git a/app/Notifications/TicketRaisedNotification.php b/app/Notifications/TicketRaisedNotification.php new file mode 100644 index 0000000..d3d9cb9 --- /dev/null +++ b/app/Notifications/TicketRaisedNotification.php @@ -0,0 +1,67 @@ + + */ + public function via(object $notifiable): array + { + return ['database', 'mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + return (new MailMessage()) + ->subject("SSO Support Ticket Raised: {$this->ticket->ticket_id}") + ->greeting('Hello,') + ->line("A new support ticket has been raised regarding {$this->ticket->issue_category}.") + ->line('Ticket ID: '.$this->ticket->ticket_id) + ->line('Raised By: '.$this->ticket->user->name) + ->line('Description: '.($this->ticket->description ?? 'N/A')) + ->action('View Ticket Details', route('tickets.index')) + ->line('Thank you for using our application!'); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + $appName = $this->ticket->connectedApp->name ?? 'General Inquiry'; + + return [ + 'ticket_id' => $this->ticket->id, + 'ticket_code' => $this->ticket->ticket_id, + 'user_name' => $this->ticket->user->name, + 'issue_category' => $this->ticket->issue_category, + 'app_name' => $appName, + 'description' => Str::limit($this->ticket->description ?? '', 100), + 'message' => "New ticket {$this->ticket->ticket_id} raised by {$this->ticket->user->name} ({$appName}).", + ]; + } +} diff --git a/app/Notifications/TicketStatusChangedNotification.php b/app/Notifications/TicketStatusChangedNotification.php new file mode 100644 index 0000000..98bb6da --- /dev/null +++ b/app/Notifications/TicketStatusChangedNotification.php @@ -0,0 +1,69 @@ + + */ + public function via(object $notifiable): array + { + return ['database', 'mail']; + } + + /** + * Get the mail representation of the notification. + */ + public function toMail(object $notifiable): MailMessage + { + $statusLabel = TicketStatusEnum::tryFrom($this->ticket->status->name)?->label() ?? ucfirst($this->ticket->status->name); + + return (new MailMessage()) + ->subject("SSO Support Ticket Status Changed: {$this->ticket->ticket_id}") + ->greeting('Hello,') + ->line("The status of your support ticket {$this->ticket->ticket_id} regarding {$this->ticket->issue_category} has been updated.") + ->line("New Status: {$statusLabel}") + ->action('View Ticket Details', route('tickets.index')) + ->line('Thank you for using our application!'); + } + + /** + * Get the array representation of the notification. + * + * @return array + */ + public function toArray(object $notifiable): array + { + $appName = $this->ticket->connectedApp->name ?? 'General Inquiry'; + $statusLabel = TicketStatusEnum::tryFrom($this->ticket->status->name)?->label() ?? ucfirst($this->ticket->status->name); + + return [ + 'ticket_id' => $this->ticket->id, + 'ticket_code' => $this->ticket->ticket_id, + 'user_name' => $this->ticket->user->name, + 'issue_category' => $this->ticket->issue_category, + 'app_name' => $appName, + 'description' => Str::limit($this->ticket->description ?? '', 100), + 'message' => "The status of your support ticket {$this->ticket->ticket_id} has been changed to {$statusLabel}.", + ]; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index a1d2a27..03d09ba 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -5,8 +5,7 @@ namespace App\Providers; use Carbon\CarbonImmutable; -use Illuminate\Support\Facades\Date; -use Illuminate\Support\Facades\DB; +use Illuminate\Support\Facades\{DB, Date}; use Illuminate\Support\ServiceProvider; use Illuminate\Validation\Rules\Password; @@ -37,7 +36,7 @@ protected function configureDefaults(): void ); Password::defaults( - fn(): ?Password => app()->isProduction() + fn (): ?Password => app()->isProduction() ? Password::min(12) ->mixedCase() ->letters() diff --git a/app/Providers/DirectiveServiceProvider.php b/app/Providers/DirectiveServiceProvider.php new file mode 100644 index 0000000..ad93453 --- /dev/null +++ b/app/Providers/DirectiveServiceProvider.php @@ -0,0 +1,28 @@ +registerInitialsDirective(); + } + + private function registerInitialsDirective(): void + { + Blade::directive('initials', fn ($expression) => ""); + } +} diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index e8bde16..f57951d 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -4,13 +4,13 @@ namespace App\Providers; -use App\Actions\Fortify\CreateNewUser; -use App\Actions\Fortify\ResetUserPassword; +use App\Actions\Fortify\{CreateNewUser, ResetUserPassword}; +use App\Http\Responses\LoginResponse; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\RateLimiter; -use Illuminate\Support\ServiceProvider; -use Illuminate\Support\Str; +use Illuminate\Support\{ServiceProvider, Str}; +use Laravel\Fortify\Contracts\LoginResponse as LoginResponseContract; use Laravel\Fortify\Fortify; final class FortifyServiceProvider extends ServiceProvider @@ -18,7 +18,13 @@ final class FortifyServiceProvider extends ServiceProvider /** * Register any application services. */ - public function register(): void {} + public function register(): void + { + $this->app->singleton( + LoginResponseContract::class, + LoginResponse::class + ); + } /** * Bootstrap any application services. @@ -44,13 +50,13 @@ private function configureActions(): void */ private function configureViews(): void { - Fortify::loginView(fn() => view('livewire.auth.login')); - Fortify::verifyEmailView(fn() => view('livewire.auth.verify-email')); - Fortify::twoFactorChallengeView(fn() => view('livewire.auth.two-factor-challenge')); - Fortify::confirmPasswordView(fn() => view('livewire.auth.confirm-password')); - Fortify::registerView(fn() => view('livewire.auth.register')); - Fortify::resetPasswordView(fn() => view('livewire.auth.reset-password')); - Fortify::requestPasswordResetLinkView(fn() => view('livewire.auth.forgot-password')); + Fortify::loginView(fn () => view('livewire.auth.login')); + Fortify::verifyEmailView(fn () => view('livewire.auth.verify-email')); + Fortify::twoFactorChallengeView(fn () => view('livewire.auth.two-factor-challenge')); + Fortify::confirmPasswordView(fn () => view('livewire.auth.confirm-password')); + Fortify::registerView(fn () => view('livewire.auth.register')); + Fortify::resetPasswordView(fn () => view('livewire.auth.reset-password')); + Fortify::requestPasswordResetLinkView(fn () => view('livewire.auth.forgot-password')); } /** @@ -58,10 +64,10 @@ private function configureViews(): void */ private function configureRateLimiting(): void { - RateLimiter::for('two-factor', fn(Request $request) => Limit::perMinute(5)->by($request->session()->get('login.id'))); + RateLimiter::for('two-factor', fn (Request $request) => Limit::perMinute(5)->by($request->session()->get('login.id'))); RateLimiter::for('login', function (Request $request) { - $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())) . '|' . $request->ip()); + $throttleKey = Str::transliterate(Str::lower($request->input(Fortify::username())).'|'.$request->ip()); return Limit::perMinute(5)->by($throttleKey); }); diff --git a/app/Providers/ScopeServiceProvider.php b/app/Providers/ScopeServiceProvider.php deleted file mode 100644 index 31612da..0000000 --- a/app/Providers/ScopeServiceProvider.php +++ /dev/null @@ -1,55 +0,0 @@ -registerScopeDirective(); - } - public function registerScopeDirective(): void - { - /** - * All credits from this blade directive goes to Konrad Kalemba. - * Just copied and modified for my very specific use case. - * - * https://github.com/konradkalemba/blade-components-scoped-slots - */ - Blade::directive('scope', function ($expression) { - // Split the expression by `top-level` commas (not in parentheses) - $directiveArguments = preg_split("/,(?![^\(\(]*[\)\)])/", $expression); - $directiveArguments = array_map('trim', $directiveArguments); - - [$name, $functionArguments] = $directiveArguments; - - // Build function "uses" to inject extra external variables - $uses = Arr::except(array_flip($directiveArguments), [$name, $functionArguments]); - $uses = array_flip($uses); - $uses[] = '$__env'; - $uses[] = '$__bladeCompiler'; - $uses = implode(',', $uses); - - /** - * Slot names can`t contains dot , eg: `user.city`. - * So we convert `user.city` to `user___city` - * - * Later, on component it will be replaced back. - */ - $name = str_replace('.', '___', $name); - - return "slot({$name}, function({$functionArguments}) use ({$uses}) { \$loop = !empty(\$__env->getLoopStack()) ? (object) \$__env->getLoopStack()[0] : null; ?>"; - }); - - Blade::directive('endscope', fn() => ''); - } -} diff --git a/app/Services/AppRoleService.php b/app/Services/AppRoleService.php new file mode 100644 index 0000000..50d561a --- /dev/null +++ b/app/Services/AppRoleService.php @@ -0,0 +1,280 @@ +appIds)) { + return; + } + DB::transaction(function () use ($data): void { + $role = Role::query()->findOrFail($data->roleId); + + $this->assignAppPermissionsRole($role, $data->appIds); + + $records = array_map(fn (int $appId) => [ + 'role_id' => $data->roleId, + 'app_id' => $appId, + 'duration' => $data->validUpto, + ], $data->appIds); + + AppRole::query()->upsert( + $records, + ['role_id', 'app_id'], + ['duration'] + ); + }); + } + + /** + * @param array $appIds Apps, of which all permissions will be assigned to the role. + */ + private function assignAppPermissionsRole(Role $role, array $appIds): void + { + array_map(function ($appId) use ($role): void { + $app = $this->connectedAppService->getConnectedApp($appId); + $permissions = $this->appsPermissionService->getAllPermissions($app); + $permissions + ->toCollection() + ->map(fn ($permission) => $role->givePermissionTo($permission->id)); + }, $appIds); + } + + /** + * Detach an app from a role; + * + * @param array $appIds Apps to be detached. + */ + public function detachApps(array $appIds): void + { + AppRole::query() + ->whereIn('app_id', $appIds) + ->delete(); + } + + /** + * Remove an app-role assignment. + * + * @param int $id The ID of the pivot table record. + * + * @throws InvalidArgumentException when id is invalid. + * @throws Throwable when an error occurs during the deletion. + */ + public function detach(int $id): void + { + if ($id <= 0) { + throw new InvalidArgumentException('Invalid id'); + } + + DB::transaction(function () use ($id): void { + $appRole = AppRole::query()->findOrFail($id); + $role = Role::query()->findOrFail($appRole->role_id); + $this->removeAppPermissionsRole($role, $appRole->app_id); + $appRole->delete(); + }); + } + + /** + * @param array|int $appIds + */ + private function removeAppPermissionsRole(Role $role, ...$appIds): void + { + array_map(function ($appId) use ($role): void { + $app = $this->connectedAppService->getConnectedApp($appId); + $permissions = $this->appsPermissionService->getAllPermissions($app); + $permissions + ->toCollection() + ->map(fn ($permission) => $role->revokePermissionTo($permission->name)); + }, $appIds); + } + + /** + * Get all apps assigned to a given role. + * + * @return DataCollection + */ + #[NoDiscard] + public function getAppsForRole(int $roleId): DataCollection + { + $assignments = AppRole::query() + ->with('app:id,name') + ->where('role_id', $roleId) + ->orderBy('id') + ->get(['id', 'app_id', 'role_id', 'duration']); + + return RoleAppData::collect( + $assignments->map(fn (AppRole $row): array => [ + 'id' => $row->id, + 'appId' => $row->app_id, + 'appName' => $row->app->name ?? '', + 'duration' => $row->duration, + ])->all(), + DataCollection::class, + ); + } + + /** + * Search available apps for the select dropdown. + */ + #[NoDiscard] + public function searchAppsForSelect(string $search = ''): Collection + { + $query = ConnectedApp::query() + ->select(['id', 'name']); + + if (auth()->check()) { + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + $query->whereHas('roles', function ($q) use ($userPriority): void { + $q->where('priority', '>', $userPriority); + }); + } + + return $query->when($search, function ($q, $search): void { + $q->where('name', 'like', '%'.$search.'%'); + }) + ->limit(50) // Limit the results so the dropdown doesn't lag + ->orderBy('name') + ->get(['id', 'name']); + } + + /** + * Get all connected app IDs for the "Select All" feature. + * + * @return array + */ + #[NoDiscard] + public function getAllAppIds(): array + { + $query = ConnectedApp::query(); + + if (auth()->check()) { + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + $query->whereHas('roles', function ($q) use ($userPriority): void { + $q->where('priority', '>', $userPriority); + }); + } + + return $query->pluck('id')->toArray(); + } + + /** + * Assign users to a role using spatie/laravel-permission. + * + * @throws Throwable when an error occurs during the assignment. + */ + public function assignUsersToRole(AssignUsersToRoleData $data): void + { + if (empty($data->userIds)) { + return; + } + + DB::transaction(function () use ($data): void { + $role = Role::query()->findOrFail($data->roleId); + $users = User::query()->whereIn('id', $data->userIds)->get(); + + /** @var User $user */ + foreach ($users as $user) { + if (! $user->hasRole($role)) { + $user->assignRole($role); + } + } + }); + } + + /** + * Remove a user from a role. + * + * @throws InvalidArgumentException when userId is invalid. + * @throws Throwable when an error occurs during the removal. + */ + public function detachUserFromRole(int $roleId, int $userId): void + { + if ($userId <= 0) { + throw new InvalidArgumentException('Invalid user id'); + } + + DB::transaction(function () use ($roleId, $userId): void { + $role = Role::query()->findOrFail($roleId); + $user = User::query()->findOrFail($userId); + + $user->removeRole($role); + }); + } + + /** + * Get all users assigned to a given role. + * + * @return DataCollection + */ + #[NoDiscard] + public function getUsersForRole(int $roleId): DataCollection + { + $role = Role::query()->findOrFail($roleId); + + $users = User::query() + ->select(['id', 'name', 'email']) + ->role($role) + ->orderBy('name') + ->get(); + + return RoleUserData::collect( + $users->map(fn (User $user): array => [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + ])->all(), + DataCollection::class, + ); + } + + /** + * Search users for the select dropdown, returning only id and name. + * + * @return DataCollection + */ + #[NoDiscard] + public function searchUsersForSelect(string $search = ''): DataCollection + { + $query = User::query() + ->select(['id', 'name']); + + if (auth()->check()) { + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + $query->whereDoesntHave('roles', function ($q) use ($userPriority): void { + $q->where('priority', '<=', $userPriority); + }); + } + + $users = $query->when($search, function ($q, $search): void { + $q->where('name', 'like', '%'.$search.'%'); + }) + ->limit(50) + ->orderBy('name') + ->get(); + + return UserSelectData::collect($users, DataCollection::class); + } +} diff --git a/app/Services/AppsPermissionService.php b/app/Services/AppsPermissionService.php new file mode 100644 index 0000000..f55ff59 --- /dev/null +++ b/app/Services/AppsPermissionService.php @@ -0,0 +1,50 @@ +slug) { + $connectedApp->slug = Str::slug($connectedApp->name); + } + $permissionName = AppPermission::name($permission, $connectedApp); + DB::transaction(function () use ($permissionName): void { + Permission::firstOrCreate(['name' => $permissionName]); + }); + } + + /** + * Returns all permissions for the connected app. + * + * @return DataCollection + */ + public function getAllPermissions(ConnectedAppData $connectedApp): DataCollection + { + if (! $connectedApp->slug) { + $connectedApp->slug = Str::slug($connectedApp->name); + } + $permissionName = AppPermission::name(ConnectedAppPermissonEnum::Use, $connectedApp); + $permissions = Permission::where('name', 'like', $permissionName)->get(); + + return PermissionData::collect($permissions, DataCollection::class); + } +} diff --git a/app/Services/ConnectedAppService.php b/app/Services/ConnectedAppService.php new file mode 100644 index 0000000..4c447c5 --- /dev/null +++ b/app/Services/ConnectedAppService.php @@ -0,0 +1,103 @@ +create( + $data->toArray() + ); + + $this->permissionService->add(ConnectedAppData::from($connectedApp), ConnectedAppPermissonEnum::Use); + } + ); + } + + /** + * @throws InvalidArgumentException when id is invalid + * @throws Throwable when an error occurs during the update. + */ + public function update(int $id, ConnectAppRequest $data): void + { + if ($id <= 0) { + throw new InvalidArgumentException('Invalid id'); + } + DB::transaction( + function () use ($id, $data): void { + $app = ConnectedApp::query()->findOrFail($id); + $app->update($data->toArray()); + } + ); + } + + /** + * @throws Throwable when an error occurs during the deletion. + * @throws InvalidArgumentException when id is invalid + */ + public function delete(int $id): void + { + if ($id <= 0) { + throw new InvalidArgumentException('Invalid id'); + } + + DB::transaction( + function () use ($id): void { + $app = ConnectedApp::query()->findOrFail($id); + AppRole::query()->where('app_id', $app->id)->delete(); + $app->delete(); + } + ); + } + + /** + * @return PaginatedDataCollection + */ + public function getAll(): PaginatedDataCollection + { + $data = ConnectedApp::with('provider:name,id', 'protocol:id,name', 'status:id,name') + ->paginate(); + + return UiConnectedAppData::collect($data, PaginatedDataCollection::class); + } + + /** + * @throws ModelNotFoundException when the connected app is not found. + * @throws InvalidArgumentException when id is invalid. + */ + public function getConnectedApp(int $id): ConnectedAppData + { + if ($id <= 0) { + throw new InvalidArgumentException('Invalid id'); + } + $data = ConnectedApp::with('provider:id', 'protocol:id', 'status:id') + ->findOrFail($id); + + return ConnectedAppData::from($data); + } +} diff --git a/app/Services/ConnectionProtocolService.php b/app/Services/ConnectionProtocolService.php new file mode 100644 index 0000000..17df7d9 --- /dev/null +++ b/app/Services/ConnectionProtocolService.php @@ -0,0 +1,30 @@ + + */ + private Builder $builder; + + public function __construct( + ) { + $this->builder = ConnectionProtocol::query(); + } + + /** + * @return Collection + */ + public function getAll(): Collection + { + return $this->builder->get(); + } +} diff --git a/app/Services/ConnectionProviderService.php b/app/Services/ConnectionProviderService.php new file mode 100644 index 0000000..b101e54 --- /dev/null +++ b/app/Services/ConnectionProviderService.php @@ -0,0 +1,127 @@ + + */ + #[NoDiscard] + public function search(string $value): Collection + { + return ConnectionProvider::query() + ->whereLike('slug', "%{$value}%") + ->orWhereLike('name', "%{$value}%") + ->get(); + } + + /** + * @throws Throwable + */ + public function create(StoreConnectionProviderRequestData $data): void + { + DB::transaction( + function () use ($data): void { + ConnectionProvider::query()->create($data->toArray()); + } + ); + } + + /** + * @return PaginatedDataCollection + */ + #[NoDiscard] + public function getAll(): PaginatedDataCollection + { + $data = ConnectionProvider::query()->select(['id', 'name', 'slug'])->paginate(); + + return ConnectionProviderData::collect($data, PaginatedDataCollection::class); + } + + /** + * @throws InvalidArgumentException when id is invalid + * @throws Throwable when an error occurs during the update. + */ + public function update(string $slug, StoreConnectionProviderRequestData $data): void + { + DB::transaction( + function () use ($slug, $data): void { + $app = ConnectionProvider::query() + ->where('slug', $slug); + + $app->update($data->toArray()); + } + ); + } + + /** + * Delete the connection provider and associated connected apps. + * + * @throws ModelNotFoundException when the connection provider is not found. + * @throws Throwable when an error occurs during the deletion. + */ + public function delete(string $slug): void + { + DB::transaction( + function () use ($slug): void { + $provider = ConnectionProvider::query() + ->where('slug', $slug) + ->firstOrFail(); + $deletedApps = $provider->connectedApps() + ->select(['id', 'name']) + ->get() + ->toArray(); + + $this->appRoleService->detachApps(array_column($deletedApps, 'id')); + // Note: This performs a mass soft-delete query. + // Model events for ConnectedApp will NOT fire. + $provider->connectedApps()->delete(); + // Stop the default logging + $provider->disableLogging()->delete(); + + activity() + ->performedOn($provider) + ->event('provider_cascade_deleted') + ->withProperties([ + 'slug' => $slug, + 'name' => $provider->name, + 'deleted_apps_count' => count($deletedApps), + 'deleted_apps' => $deletedApps, + ]) + ->log(":causer.name Deleted connection provider '{$provider->name}' alongside ".count($deletedApps).' connected apps.'); + } + ); + } + + /** + * @throws ModelNotFoundException when the connected app is not found. + * @throws InvalidArgumentException when id is invalid. + */ + #[NoDiscard] + public function getProvider(string $slug): ConnectionProviderData + { + $data = ConnectionProvider::query() + ->select(['id', 'name', 'slug']) + ->where('slug', $slug) + ->first(); + + return ConnectionProviderData::from($data); + } +} diff --git a/app/Services/ConnectionStatusService.php b/app/Services/ConnectionStatusService.php new file mode 100644 index 0000000..aebc687 --- /dev/null +++ b/app/Services/ConnectionStatusService.php @@ -0,0 +1,22 @@ + + */ + public function getAll(): DataCollection + { + $statuses = ConnectionStatus::query()->get(); + + return ConnectionStatusData::collect($statuses, DataCollection::class); + } +} diff --git a/app/Services/MicrosoftGraphService.php b/app/Services/MicrosoftGraphService.php new file mode 100644 index 0000000..5a75440 --- /dev/null +++ b/app/Services/MicrosoftGraphService.php @@ -0,0 +1,364 @@ +tenantId}/oauth2/v2.0/token"; + + $body = [ + 'client_id' => $credentials->clientId, + 'client_secret' => $credentials->clientSecret, + 'scope' => 'https://graph.microsoft.com/.default', + 'grant_type' => 'client_credentials', + ]; + + $requestHeaders = [ + 'Content-Type' => 'application/x-www-form-urlencoded', + ]; + + try { + $response = Http::asForm()->post($url, $body); + + $transaction = [ + 'name' => 'OAuth 2.0 Get Access Token', + 'url' => $url, + 'method' => 'POST', + 'request_headers' => $requestHeaders, + 'request_body' => collect($body)->except('client_secret')->put('client_secret', '********')->toArray(), + 'status' => $response->status(), + 'response_headers' => $response->headers(), + 'response_body' => $response->json(), + ]; + + if ($response->successful()) { + return [ + 'token' => $response->json('access_token'), + 'transaction' => $transaction, + ]; + } + } catch (Exception $e) { + $transaction = [ + 'name' => 'OAuth 2.0 Get Access Token', + 'url' => $url, + 'method' => 'POST', + 'request_headers' => $requestHeaders, + 'request_body' => collect($body)->except('client_secret')->put('client_secret', '********')->toArray(), + 'status' => 'ERROR', + 'response_body' => ['error' => $e->getMessage()], + ]; + } + + return [ + 'token' => null, + 'transaction' => $transaction, + ]; + } + + /** + * Retrieve federation configuration for the domain. + * + * @return array{status: string, message?: string, config: ?array, transactions: array} + */ + public function getFederationConfig(MicrosoftGraphCredentialsData $credentials): array + { + $auth = $this->getAccessToken($credentials); + $transactions = [$auth['transaction']]; + + if (! $auth['token']) { + return [ + 'status' => 'error', + 'message' => 'Authentication with Microsoft Graph failed.', + 'config' => null, + 'transactions' => $transactions, + ]; + } + + $url = "https://graph.microsoft.com/v1.0/domains/{$credentials->domainName}/federationConfiguration"; + $headers = [ + 'Authorization' => 'Bearer ********', + 'Content-Type' => 'application/json', + ]; + + try { + $response = Http::withToken($auth['token'])->get($url); + + $transactions[] = [ + 'name' => 'Get Federation Configuration', + 'url' => $url, + 'method' => 'GET', + 'request_headers' => $headers, + 'request_body' => null, + 'status' => $response->status(), + 'response_headers' => $response->headers(), + 'response_body' => $response->json(), + ]; + + if ($response->successful()) { + /** @var array $value */ + $value = $response->json('value', []); + + return [ + 'status' => 'success', + 'config' => ! empty($value) ? $value[0] : null, + 'transactions' => $transactions, + ]; + } + + /** @var string $errorMsg */ + $errorMsg = $response->json('error.message', 'Failed to retrieve federation config.'); + + return [ + 'status' => 'error', + 'message' => $errorMsg, + 'config' => null, + 'transactions' => $transactions, + ]; + } catch (Exception $e) { + $transactions[] = [ + 'name' => 'Get Federation Configuration', + 'url' => $url, + 'method' => 'GET', + 'request_headers' => $headers, + 'request_body' => null, + 'status' => 'ERROR', + 'response_body' => ['error' => $e->getMessage()], + ]; + + return [ + 'status' => 'error', + 'message' => $e->getMessage(), + 'config' => null, + 'transactions' => $transactions, + ]; + } + } + + /** + * Connect/Federate the domain. + * + * @return array{status: string, message?: string, config?: ?array, transactions: array} + */ + public function connectFederation( + MicrosoftGraphCredentialsData $credentials, + string $certPem, + string $issuerUri, + string $passiveSignInUri, + string $activeSignInUri, + string $signOutUri + ): array { + $auth = $this->getAccessToken($credentials); + $transactions = [$auth['transaction']]; + + if (! $auth['token']) { + return [ + 'status' => 'error', + 'message' => 'Authentication with Microsoft Graph failed.', + 'transactions' => $transactions, + ]; + } + + // Check and delete existing configuration to ensure a clean setup + $checkUrl = "https://graph.microsoft.com/v1.0/domains/{$credentials->domainName}/federationConfiguration"; + try { + $checkRes = Http::withToken($auth['token'])->get($checkUrl); + $transactions[] = [ + 'name' => 'Check Existing Federation Configuration', + 'url' => $checkUrl, + 'method' => 'GET', + 'status' => $checkRes->status(), + 'response_body' => $checkRes->json(), + ]; + + if ($checkRes->successful()) { + /** @var array $configs */ + $configs = $checkRes->json('value', []); + if (! empty($configs)) { + /** @var string $existingId */ + $existingId = $configs[0]['id']; + $deleteUrl = "https://graph.microsoft.com/v1.0/domains/{$credentials->domainName}/federationConfiguration/{$existingId}"; + $deleteRes = Http::withToken($auth['token'])->delete($deleteUrl); + $transactions[] = [ + 'name' => 'Delete Pre-existing Federation Configuration', + 'url' => $deleteUrl, + 'method' => 'DELETE', + 'status' => $deleteRes->status(), + 'response_body' => $deleteRes->json() ?: ['message' => 'Deleted successfully'], + ]; + + if (! $deleteRes->successful()) { + return [ + 'status' => 'error', + 'message' => 'Failed to delete pre-existing federation configuration.', + 'transactions' => $transactions, + ]; + } + } + } + } catch (Exception $e) { + // Suppress exception and proceed + } + + // Prepare the signing certificate PEM string + $signingCertificateClean = str_replace( + ['-----BEGIN CERTIFICATE-----', '-----END CERTIFICATE-----', "\r", "\n", ' '], + '', + $certPem + ); + + $url = "https://graph.microsoft.com/v1.0/domains/{$credentials->domainName}/federationConfiguration"; + $body = [ + '@odata.type' => '#microsoft.graph.internalDomainFederation', + 'displayName' => $credentials->displayName, + 'issuerUri' => $issuerUri, + 'signingCertificate' => $signingCertificateClean, + 'passiveSignInUri' => $passiveSignInUri, + 'preferredAuthenticationProtocol' => 'saml', + 'activeSignInUri' => $activeSignInUri, + 'signOutUri' => $signOutUri, + 'promptLoginBehavior' => 'nativeSupport', + 'isSignedAuthenticationRequestRequired' => false, + 'federatedIdpMfaBehavior' => $credentials->mfaBehavior->value, + ]; + + $headers = [ + 'Authorization' => 'Bearer ********', + 'Content-Type' => 'application/json', + ]; + + try { + $response = Http::withToken($auth['token'])->post($url, $body); + + $transactions[] = [ + 'name' => 'Create Federation Configuration', + 'url' => $url, + 'method' => 'POST', + 'request_headers' => $headers, + 'request_body' => $body, + 'status' => $response->status(), + 'response_headers' => $response->headers(), + 'response_body' => $response->json(), + ]; + + if ($response->successful()) { + return [ + 'status' => 'success', + 'config' => $response->json(), + 'transactions' => $transactions, + ]; + } + + /** @var string $errorMsg */ + $errorMsg = $response->json('error.message', 'Failed to create federation configuration.'); + + return [ + 'status' => 'error', + 'message' => $errorMsg, + 'transactions' => $transactions, + ]; + } catch (Exception $e) { + $transactions[] = [ + 'name' => 'Create Federation Configuration', + 'url' => $url, + 'method' => 'POST', + 'request_headers' => $headers, + 'request_body' => $body, + 'status' => 'ERROR', + 'response_body' => ['error' => $e->getMessage()], + ]; + + return [ + 'status' => 'error', + 'message' => $e->getMessage(), + 'transactions' => $transactions, + ]; + } + } + + /** + * Disconnect/Remove federation configuration. + * + * @return array{status: string, message?: string, transactions: array} + */ + public function disconnectFederation(MicrosoftGraphCredentialsData $credentials, string $federationId): array + { + $auth = $this->getAccessToken($credentials); + $transactions = [$auth['transaction']]; + + if (! $auth['token']) { + return [ + 'status' => 'error', + 'message' => 'Authentication with Microsoft Graph failed.', + 'transactions' => $transactions, + ]; + } + + $url = "https://graph.microsoft.com/v1.0/domains/{$credentials->domainName}/federationConfiguration/{$federationId}"; + $headers = [ + 'Authorization' => 'Bearer ********', + ]; + + try { + $response = Http::withToken($auth['token'])->delete($url); + + $transactions[] = [ + 'name' => 'Delete Federation Configuration', + 'url' => $url, + 'method' => 'DELETE', + 'request_headers' => $headers, + 'request_body' => null, + 'status' => $response->status(), + 'response_headers' => $response->headers(), + 'response_body' => $response->json() ?: ['message' => 'Federation removed successfully. Domain returned to Managed.'], + ]; + + if ($response->successful()) { + return [ + 'status' => 'success', + 'transactions' => $transactions, + ]; + } + + /** @var string $errorMsg */ + $errorMsg = $response->json('error.message', 'Failed to remove federation configuration.'); + + return [ + 'status' => 'error', + 'message' => $errorMsg, + 'transactions' => $transactions, + ]; + } catch (Exception $e) { + $transactions[] = [ + 'name' => 'Delete Federation Configuration', + 'url' => $url, + 'method' => 'DELETE', + 'request_headers' => $headers, + 'request_body' => null, + 'status' => 'ERROR', + 'response_body' => ['error' => $e->getMessage()], + ]; + + return [ + 'status' => 'error', + 'message' => $e->getMessage(), + 'transactions' => $transactions, + ]; + } + } +} diff --git a/app/Services/PermissionManagerService.php b/app/Services/PermissionManagerService.php new file mode 100644 index 0000000..4a7067d --- /dev/null +++ b/app/Services/PermissionManagerService.php @@ -0,0 +1,64 @@ + + */ + #[NoDiscard] + public function getRolesWithPermissionsAndUsers(): PaginatedDataCollection + { + $roles = Role::query() + ->visible() + ->with(['permissions:id,name', 'users:id,name']) + ->orderBy('priority', 'asc') + ->paginate(); + + return RoleData::collect($roles, PaginatedDataCollection::class); + } + + /** + * Get all non-app system permissions mapped to the PermissionData DTO. + * + * @return DataCollection + */ + #[NoDiscard] + public function getNonAppPermissions(): DataCollection + { + $permissions = Permission::all() + ->filter(fn ($p) => null === AppPermission::type($p->name)); + + return PermissionData::collect($permissions, DataCollection::class); + } + + /** + * Get a role by its ID, ensuring it has a priority strictly greater than the logged in user's priority. + * + * @throws \Symfony\Component\HttpKernel\Exception\HttpException + */ + public function getRoleForEditing(int $roleId): Role + { + $role = Role::query()->findOrFail($roleId); + + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + if ($role->priority <= $userPriority) { + abort(403, 'Unauthorized to manage permissions for this role.'); + } + + return $role; + } +} diff --git a/app/Services/README.md b/app/Services/README.md new file mode 100644 index 0000000..d831e2e --- /dev/null +++ b/app/Services/README.md @@ -0,0 +1,98 @@ +# Service Class Guidelines & Flow: ConnectionProviderService + +This document outlines the architectural flow, design patterns, and coding guidelines implemented within the +ConnectionProviderService class. It serves as a reference for maintaining this service and acts as a blueprint for +creating similar service classes within the application. + +## 1. General Coding Guidelines + +The service class adheres to several strict Laravel and general PHP best practices to ensure robustness, predictability, +and maintainability. + +1. Strict Typing: The file declares strict_types=1 at the top. All method arguments and return values must have explicit + type hints. + +2. Data Transfer Objects (DTOs): Instead of passing raw arrays or generic Laravel Request objects, the service utilizes + strongly typed DTOs (via Spatie\LaravelData). + + Input: StoreConnectionProviderRequestData + + Output: ConnectionProviderData, PaginatedDataCollection + +3. Database Transactions: All data mutation methods (Create, Update, Delete) are wrapped within DB::transaction() + closures to guarantee database integrity. If any part of the operation fails, the entire transaction rolls back. + +4. The #[NoDiscard] Attribute: Read-only methods (search, getAll, getProvider) utilize the #[NoDiscard] attribute. This + strictly enforces that the calling code must capture and use the returned data, preventing logic leaks where a + developer calls a getter but ignores the result. + +5. Explicit Exception Documentation: Every method that can throw an exception documents it clearly in the PHPDoc block ( + e.g., @throws ModelNotFoundException, @throws Throwable). + +## 2. Architectural Flow by Operation + +### A. Read Operations + +Read operations are optimized for memory and performance by explicitly selecting only the necessary columns. + +* search(string $value) + + Flow: Initiates an Eloquent query targeting the slug or name columns using a LIKE operator (%value%). + + Returns: An Eloquent Collection of ConnectionProvider models. + +* getAll() + + Flow: Queries the database selecting only id, name, and slug. It then paginates the results. + + Returns: The paginated result is transformed into a PaginatedDataCollection containing ConnectionProviderData objects. + +* getProvider(string $slug) + + Flow: Looks up a specific provider by its slug, selecting only id, name, and slug. + + Returns: Transforms the found model into a single ConnectionProviderData object. + +### B. Write Operations (Create / Update) + +Write operations rely on DTOs to ensure the data being inserted or updated is pre-validated and formatted correctly. + +* create(StoreConnectionProviderRequestData $data) + + Flow: Opens a DB transaction. Converts the validated DTO to an array and passes it to the Eloquent create method. + +* update(string $slug, StoreConnectionProviderRequestData $data) + + Flow: Opens a DB transaction. Locates the model by its slug and updates it using the array representation of the provided DTO. + +### C. Delete Operation (Cascade & Logging) + +The delete method contains the most complex flow, handling cascading soft-deletes and custom audit logging. + + delete(string $slug) + + Step 1 (Find): Locates the ConnectionProvider by slug or throws a ModelNotFoundException. + + Step 2 (Fetch Relations): Retrieves the id and name of all associated connectedApps to preserve their state for the audit log. + + Step 3 (Cascade Delete): Performs a mass query deletion ($provider->connectedApps()->delete()). + + Note: Because this is a mass delete, individual model events for ConnectedApp will not fire. + + Step 4 (Provider Delete): Disables default logging on the provider to prevent redundant logs, then deletes the provider. + + Step 5 (Audit Log): Manually triggers a comprehensive Spatie Activity log (provider_cascade_deleted). It records the specific user (:causer.name), the provider details, and an array of the deleted child apps. + +## 3. Performance & Security Best Practices + +When extending this service or building new ones, adhere strictly to the following patterns demonstrated in the code: + +1. Selective Querying: Always use ```$model->select(['id', 'column_name'])``` when returning data to the frontend or + DTOs to avoid memory bloat from fetching unused columns (e.g., timestamps, hidden credentials). + +2. Route/Key Binding vs. Service Lookups: Notice that update, delete, and getProvider accept a primitive string + ```$slug / $id``` rather than an already-resolved Model. This keeps the service decoupled from HTTP routing logic and + allows it to be called safely from CLI commands or background jobs. + +3. Auditable Actions: For destructive actions (like cascades), always disable automatic noise and implement a custom, + highly detailed activity log so system administrators can trace exactly what data was removed and by whom. diff --git a/app/Services/RoleService.php b/app/Services/RoleService.php new file mode 100644 index 0000000..7ef3352 --- /dev/null +++ b/app/Services/RoleService.php @@ -0,0 +1,128 @@ + Role::query()->create([ + 'name' => $data->name, + 'priority' => $data->priority, + 'guard_name' => 'web', + ])); + + return RoleData::from($role); + } + + /** + * @throws InvalidArgumentException when id is invalid. + * @throws Throwable when an error occurs during the deletion. + */ + public function delete(int $id): void + { + if ($id <= 0) { + throw new InvalidArgumentException('Invalid id'); + } + + DB::transaction(function () use ($id): void { + Role::query()->findOrFail($id)->delete(); + }); + } + + /** + * Returns a Paginated Collection of roles with their connected apps DTO + * + * @return PaginatedDataCollection + */ + #[NoDiscard] + public function getAll(): PaginatedDataCollection + { + $roles = Role::query() + ->visible() + ->with(['apps:id,name', 'users:id,name']) + ->orderBy('id') + ->paginate(); + + return RoleData::collect($roles, PaginatedDataCollection::class); + } + + public function searchRolesForSelect(string $search = ''): Collection + { + return Role::query() + ->visible() + ->when('' !== $search, fn ($query) => $query->where('name', 'like', "%{$search}%")) + ->select('id', 'name') + ->get(); + } + + public function getAllRoleIds(): array + { + return Role::query()->visible()->pluck('id')->toArray(); + } + + /** + * Return all permissions for the given roles, grouped by role name. + * Each permission is a plain array so Livewire can serialise the property cleanly. + * + * @param array $roleIds + * + * @return DataCollection|null + */ + #[NoDiscard] + public function getPermissionsGroupedByRole(array $roleIds): ?DataCollection + { + if (empty($roleIds)) { + return null; + } + + $roles = Role::query() + ->visible() + ->with('permissions:id,name') + ->whereIn('id', $roleIds) + ->get(['id', 'name', 'priority']); + + return RoleData::collect($roles, DataCollection::class); + } + + /** + * Update the priority of a role. + * + * @throws InvalidArgumentException when roleId is invalid. + * @throws Throwable when an error occurs during the update. + */ + public function updatePriority(UpdateRolePriorityData $data): RoleData + { + if ($data->roleId <= 0) { + throw new InvalidArgumentException('Invalid role id'); + } + + $role = DB::transaction(function () use ($data) { + $role = Role::query()->findOrFail($data->roleId); + $role->update([ + 'priority' => $data->priority, + ]); + + return $role; + }); + + return RoleData::from($role); + } +} diff --git a/app/Services/SamlIdpService.php b/app/Services/SamlIdpService.php new file mode 100644 index 0000000..d879f41 --- /dev/null +++ b/app/Services/SamlIdpService.php @@ -0,0 +1,404 @@ +preserveWhiteSpace = false; + if (! @$dom->loadXML($xml)) { + throw new Exception('Failed to load SAMLRequest XML.'); + } + + $issuerNode = $dom + ->getElementsByTagNameNS( + 'urn:oasis:names:tc:SAML:2.0:assertion', + 'Issuer', + ) + ->item(0); + if (! $issuerNode) { + $issuerNode = $dom->getElementsByTagName('Issuer')->item(0); + } + $issuer = $issuerNode ? mb_trim($issuerNode->nodeValue) : ''; + + $requestId = $dom->documentElement->getAttribute('ID'); + $acsUrl = $dom->documentElement->getAttribute( + 'AssertionConsumerServiceURL', + ); + + return [ + 'requestId' => $requestId, + 'issuer' => $issuer, + 'acsUrl' => $acsUrl, + ]; + } + + /** + * Verify if the SAML SP Issuer is registered as a connected app and active. + */ + public function findConnectedApp(string $issuer): ?ConnectedApp + { + return ConnectedApp::query() + ->where('settings->saml->entity_id', $issuer) + ->first(); + } + + /** + * Generate the XML SAML metadata for this Identity Provider. + */ + public function generateMetadata( + string $idpEntityId, + string $ssoUrl, + ): string { + $certPem = $this->getPublicCertificate(); + $certClean = $this->cleanPem($certPem); + + $xml = << + + + + + + {$certClean} + + + + + + + + XML; + + return $xml; + } + + /** + * Generate a signed SAMLResponse XML for a successful authentication. + * + * @throws Exception + */ + public function generateResponse( + User $user, + ConnectedApp $app, + string $requestId, + string $acsUrl, + ): string { + $idpEntityId = route('saml.metadata'); + /** + * @var array{ + * saml?: array{ + * entity_id?: string, + * acs_url?: string + * } + * } $settings + */ + $settings = (array) $app->settings; + $spEntityId = $settings['saml']['entity_id'] ?? $acsUrl; + + $responseId = '_'.Str::uuid()->toString(); + $assertionId = '_'.Str::uuid()->toString(); + + $now = time(); + $issueInstant = gmdate("Y-m-d\TH:i:s\Z", $now); + $notBefore = gmdate("Y-m-d\TH:i:s\Z", $now - 30); // 30 sec skew tolerance + $expireInstant = gmdate("Y-m-d\TH:i:s\Z", $now + 300); // 5 min validity + + $immutableId = $user->immutable_id ?? base64_encode((string) $user->id); + + $dom = new DOMDocument('1.0', 'UTF-8'); + $dom->preserveWhiteSpace = false; + + $response = $dom->createElementNS( + 'urn:oasis:names:tc:SAML:2.0:protocol', + 'samlp:Response', + ); + $response->setAttribute('ID', $responseId); + $response->setAttribute('InResponseTo', $requestId); + $response->setAttribute('Version', '2.0'); + $response->setAttribute('IssueInstant', $issueInstant); + $response->setAttribute('Destination', $acsUrl); + + $issuer = $dom->createElementNS( + 'urn:oasis:names:tc:SAML:2.0:assertion', + 'saml:Issuer', + $idpEntityId, + ); + $response->appendChild($issuer); + + $status = $dom->createElement('samlp:Status'); + $statusCode = $dom->createElement('samlp:StatusCode'); + $statusCode->setAttribute( + 'Value', + 'urn:oasis:names:tc:SAML:2.0:status:Success', + ); + $status->appendChild($statusCode); + $response->appendChild($status); + + $assertion = $dom->createElementNS( + 'urn:oasis:names:tc:SAML:2.0:assertion', + 'saml:Assertion', + ); + $assertion->setAttribute('ID', $assertionId); + $assertion->setAttribute('Version', '2.0'); + $assertion->setAttribute('IssueInstant', $issueInstant); + + $assertionIssuer = $dom->createElement('saml:Issuer', $idpEntityId); + $assertion->appendChild($assertionIssuer); + + // Subject + $subject = $dom->createElement('saml:Subject'); + $nameId = $dom->createElement('saml:NameID', $immutableId); + $nameId->setAttribute( + 'Format', + 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent', + ); + $subject->appendChild($nameId); + + $subjectConfirmation = $dom->createElement('saml:SubjectConfirmation'); + $subjectConfirmation->setAttribute( + 'Method', + 'urn:oasis:names:tc:SAML:2.0:cm:bearer', + ); + $subjectConfirmationData = $dom->createElement( + 'saml:SubjectConfirmationData', + ); + $subjectConfirmationData->setAttribute('InResponseTo', $requestId); + $subjectConfirmationData->setAttribute('NotOnOrAfter', $expireInstant); + $subjectConfirmationData->setAttribute('Recipient', $acsUrl); + $subjectConfirmation->appendChild($subjectConfirmationData); + $subject->appendChild($subjectConfirmation); + $assertion->appendChild($subject); + + // Conditions + $conditions = $dom->createElement('saml:Conditions'); + $conditions->setAttribute('NotBefore', $notBefore); + $conditions->setAttribute('NotOnOrAfter', $expireInstant); + $audienceRestriction = $dom->createElement('saml:AudienceRestriction'); + $audience = $dom->createElement('saml:Audience', $spEntityId); + $audienceRestriction->appendChild($audience); + $conditions->appendChild($audienceRestriction); + $assertion->appendChild($conditions); + + // AuthnStatement + $authnStatement = $dom->createElement('saml:AuthnStatement'); + $authnStatement->setAttribute('AuthnInstant', $issueInstant); + $authnStatement->setAttribute('SessionIndex', $assertionId); + $authnContext = $dom->createElement('saml:AuthnContext'); + $authnContextClassRef = $dom->createElement( + 'saml:AuthnContextClassRef', + 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport', + ); + $authnContext->appendChild($authnContextClassRef); + $authnStatement->appendChild($authnContext); + $assertion->appendChild($authnStatement); + + // AttributeStatement (SAML claims) + $attributeStatement = $dom->createElement('saml:AttributeStatement'); + + // IDPEmail (essential claim for Office 365 mapping to UserPrincipalName) + $this->addAttribute( + $dom, + $attributeStatement, + 'IDPEmail', + $user->email, + ); + + // mail + $this->addAttribute($dom, $attributeStatement, 'mail', $user->email); + + // UPN + $this->addAttribute( + $dom, + $attributeStatement, + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/upn', + $user->email, + ); + + // displayName + $this->addAttribute( + $dom, + $attributeStatement, + 'displayName', + $user->name, + ); + + // emailaddress (Required by Microsoft SAML 2.0 specifications) + $this->addAttribute( + $dom, + $attributeStatement, + 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', + $user->email, + ); + + $assertion->appendChild($attributeStatement); + $response->appendChild($assertion); + $dom->appendChild($response); + + // Sign the assertion node + $this->signAssertion($dom, $assertion); + + return base64_encode($dom->saveXML()); + } + + /** + * Add a simple attribute to SAML AttributeStatement. + */ + private function addAttribute( + DOMDocument $dom, + DOMElement $statement, + string $name, + string $value, + ): void { + $attribute = $dom->createElement('saml:Attribute'); + $attribute->setAttribute('Name', $name); + $attribute->setAttribute( + 'NameFormat', + 'urn:oasis:names:tc:SAML:2.0:attrname-format:basic', + ); + $val = $dom->createElement( + 'saml:AttributeValue', + htmlspecialchars($value, ENT_QUOTES | ENT_XML1, 'UTF-8'), + ); + $attribute->appendChild($val); + $statement->appendChild($attribute); + } + + /** + * Cryptographically sign the Assertion node in DOMDocument. + */ + private function signAssertion( + DOMDocument $dom, + DOMElement|DOMDocument $assertion, + ): void { + $privateKeyPem = $this->getPrivateKey(); + $certPem = $this->getPublicCertificate(); + + // XML Security Signature Setup + $objDSig = new XMLSecurityDSig(); + $objDSig->setCanonicalMethod(XMLSecurityDSig::C14N); + + // Explicitly designate Assertion ID attribute so xmlseclibs can locate it correctly + $assertion->setIdAttribute('ID', true); + + // Add reference to assertion + $objDSig->addReference( + $assertion, + XMLSecurityDSig::SHA256, + [ + 'http://www.w3.org/2000/09/xmldsig#enveloped-signature', + 'http://www.w3.org/2001/10/xml-exc-c14n#', + ], + [ + 'id_name' => 'ID', + ], + ); + + $objKey = new XMLSecurityKey(XMLSecurityKey::RSA_SHA256, [ + 'type' => 'private', + ]); + $objKey->loadKey($privateKeyPem, false); + + // Sign the XML + $objDSig->sign($objKey, $assertion); + $objDSig->add509Cert($certPem); + + // Enforce SAML Schema order compliance: Signature node must precede Subject. + $signatureNode = $assertion + ->getElementsByTagNameNS( + 'http://www.w3.org/2000/09/xmldsig#', + 'Signature', + ) + ->item(0); + $subjectNode = $assertion + ->getElementsByTagNameNS( + 'urn:oasis:names:tc:SAML:2.0:assertion', + 'Subject', + ) + ->item(0); + + if ($signatureNode && $subjectNode) { + $assertion->insertBefore($signatureNode, $subjectNode); + } + } + + /** + * Load IdP Private Key. + */ + private function getPrivateKey(): string + { + $path = storage_path('app/saml/idp.key'); + if (! File::exists($path)) { + throw new RuntimeException( + "SAML private key not found at [{$path}]. Run 'php artisan saml:generate-keys'.", + ); + } + + return File::get($path); + } + + /** + * Load IdP Public Certificate. + */ + private function getPublicCertificate(): string + { + $path = storage_path('app/saml/idp.crt'); + if (! File::exists($path)) { + throw new RuntimeException( + "SAML public certificate not found at [{$path}]. Run 'php artisan saml:generate-keys'.", + ); + } + + return File::get($path); + } + + /** + * Helper to clean up PEM certificate for metadata insertion. + */ + private function cleanPem(string $pem): string + { + return str_replace( + [ + '-----BEGIN CERTIFICATE-----', + '-----END CERTIFICATE-----', + "\r", + "\n", + ' ', + ], + '', + $pem, + ); + } +} diff --git a/app/Services/TicketAttachmentService.php b/app/Services/TicketAttachmentService.php new file mode 100644 index 0000000..c37cd6f --- /dev/null +++ b/app/Services/TicketAttachmentService.php @@ -0,0 +1,40 @@ +store('tickets/attachments', 'public'); + + /** @var TicketAttachment $attachment */ + $attachment = TicketAttachment::query()->create([ + 'ticket_id' => $ticketId, + 'file_path' => $path, + 'file_name' => $file->getClientOriginalName(), + 'file_size' => $file->getSize(), + 'mime_type' => $file->getMimeType() ?? 'application/octet-stream', + ]); + + return $attachment; + }); + } +} diff --git a/app/Services/TicketService.php b/app/Services/TicketService.php new file mode 100644 index 0000000..e5d422b --- /dev/null +++ b/app/Services/TicketService.php @@ -0,0 +1,397 @@ + + */ + public function getAllForUser(int $userId): Collection + { + if ($userId <= 0) { + throw new InvalidArgumentException('Invalid user ID'); + } + + $tickets = Ticket::with(['user', 'connectedApp', 'status', 'notifiedRoles', 'notifiedUsers', 'attachments', 'assignedUser', 'replies.user.roles']) + ->where('user_id', $userId) + ->latest() + ->get(); + + return TicketData::collect($tickets); + } + + /** + * Get all tickets in the system (for admins/support). + * + * @return Collection + */ + public function getAll(): Collection + { + $tickets = Ticket::with(['user', 'connectedApp', 'status', 'notifiedRoles', 'notifiedUsers', 'attachments', 'assignedUser', 'replies.user.roles']) + ->latest() + ->get(); + + return TicketData::collect($tickets); + } + + /** + * Get lightweight ticket list for a specific user with search and status filtering. + * + * @return PaginatedDataCollection + */ + public function getListForUser(int $userId, ?string $search = null, ?string $statusName = null, int $perPage = 10): PaginatedDataCollection + { + if ($userId <= 0) { + throw new InvalidArgumentException('Invalid user ID'); + } + + $user = User::findOrFail($userId); + $roleIds = $user->roles->pluck('id')->toArray(); + + $tickets = Ticket::with(['user', 'connectedApp', 'status']) + ->where(function ($query) use ($userId, $roleIds): void { + $query->where('user_id', $userId) + ->orWhere('assigned_user_id', $userId) + ->orWhereHas('notifiedUsers', function ($q) use ($userId): void { + $q->where('users.id', $userId); + }) + ->orWhereHas('notifiedRoles', function ($q) use ($roleIds): void { + $q->whereIn('roles.id', $roleIds); + }); + }) + ->when($search, function ($query) use ($search): void { + $query->where('ticket_id', 'like', "%{$search}%"); + }) + ->when($statusName && 'all' !== $statusName, function ($query) use ($statusName): void { + $query->whereHas('status', function ($q) use ($statusName): void { + $q->where('name', $statusName); + }); + }) + ->latest() + ->paginate($perPage); + + return TicketListData::collect($tickets, PaginatedDataCollection::class); + } + + /** + * Get lightweight ticket list of all tickets with search and status filtering. + * + * @return PaginatedDataCollection + */ + public function getList(?string $search = null, ?string $statusName = null, int $perPage = 10): PaginatedDataCollection + { + $tickets = Ticket::with(['user', 'connectedApp', 'status']) + ->when($search, function ($query) use ($search): void { + $query->where('ticket_id', 'like', "%{$search}%"); + }) + ->when($statusName && 'all' !== $statusName, function ($query) use ($statusName): void { + $query->whereHas('status', function ($q) use ($statusName): void { + $q->where('name', $statusName); + }); + }) + ->latest() + ->paginate($perPage); + + return TicketListData::collect($tickets, PaginatedDataCollection::class); + } + + /** + * Get a specific ticket. + */ + public function getTicket(int $id): TicketData + { + if ($id <= 0) { + throw new InvalidArgumentException('Invalid ticket ID'); + } + + $ticket = Ticket::with(['user', 'connectedApp', 'status', 'notifiedRoles', 'notifiedUsers', 'attachments', 'assignedUser', 'replies.user.roles']) + ->findOrFail($id); + + $user = auth()->user(); + if ($user) { + activity() + ->performedOn($ticket) + ->causedBy($user) + ->event('ticket_opened') + ->log("Ticket {$ticket->ticket_id} opened by {$user->name}"); + } + + return TicketData::fromModel($ticket); + } + + /** + * Update the status of a ticket. + * + * @throws Throwable + */ + public function updateStatus(int $ticketId, string $statusSlug): void + { + if ($ticketId <= 0) { + throw new InvalidArgumentException('Invalid ticket ID'); + } + + DB::transaction(function () use ($ticketId, $statusSlug): void { + $ticket = Ticket::findOrFail($ticketId); + + $user = auth()->user(); + if (! $user) { + throw new AuthorizationException('Unauthenticated'); + } + + if (! $user->hasRole('Admin') && ! $user->hasRole('admin') && ! $user->can('ticket:update') && $ticket->assigned_user_id !== $user->id) { + throw new AuthorizationException('You are not authorized to update this ticket\'s status.'); + } + + $status = TicketStatus::where('name', $statusSlug)->firstOrFail(); + $oldStatus = $ticket->status->name; + + if ($oldStatus !== $status->name) { + $ticket->update(['status_id' => $status->id]); + + activity() + ->performedOn($ticket) + ->causedBy($user) + ->event('status_changed') + ->withProperties([ + 'old_status' => $oldStatus, + 'new_status' => $status->name, + ]) + ->log("Status updated from {$oldStatus} to {$status->name}"); + + if ($ticket->user_id !== $user->id) { + $ticket->user->notify(new TicketStatusChangedNotification($ticket)); + $this->logNotificationActivity($ticket, $ticket->user, TicketStatusChangedNotification::class); + } + } + }); + } + + /** + * Assign/remove assigned user to ticket. + * + * @throws Throwable + */ + public function assignUser(int $ticketId, ?int $assignedUserId): void + { + if ($ticketId <= 0) { + throw new InvalidArgumentException('Invalid ticket ID'); + } + + DB::transaction(function () use ($ticketId, $assignedUserId): void { + $ticket = Ticket::findOrFail($ticketId); + $oldAssignedUserId = $ticket->assigned_user_id; + + $ticket->update(['assigned_user_id' => $assignedUserId]); + + if ($assignedUserId && $assignedUserId !== $oldAssignedUserId) { + $assignedUser = User::findOrFail($assignedUserId); + $assignedUser->notify(new TicketAssignedNotification($ticket)); + $this->logNotificationActivity($ticket, $assignedUser, TicketAssignedNotification::class); + } + }); + } + + /** + * Post a comment/reply to a ticket. + * + * @throws Throwable + */ + public function postReply(PostReplyRequest $request): ReplyData + { + return DB::transaction(function () use ($request): ReplyData { + $user = User::findOrFail($request->userId); + $ticket = Ticket::findOrFail($request->ticketId); + + // Authorization check: Admin, Creator, Assigned, or Notified directly/via role + $isAuthorized = $user->hasRole('Admin') + || $user->hasRole('admin') + || $ticket->user_id === $user->id + || $ticket->assigned_user_id === $user->id + || $ticket->notifiedUsers()->where('users.id', $user->id)->exists() + || $ticket->notifiedRoles()->whereIn('roles.id', $user->roles->pluck('id')->toArray())->exists(); + + if (! $isAuthorized) { + throw new AuthorizationException('You are not authorized to comment on this ticket.'); + } + + /** @var TicketReply $reply */ + $reply = TicketReply::query()->create([ + 'ticket_id' => $request->ticketId, + 'user_id' => $request->userId, + 'message' => mb_trim($request->message), + ]); + + activity() + ->performedOn($ticket) + ->causedBy($user) + ->event('comment_posted') + ->withProperties([ + 'comment_id' => $reply->id, + 'message' => $reply->message, + ]) + ->log("Comment posted on ticket {$ticket->ticket_id}"); + + // Transition ticket status from open to in-progress if it's currently open + $ticket->load(['status', 'user', 'assignedUser']); + if ($ticket->status->name === TicketStatusEnum::Open->value) { + $inProgressStatus = TicketStatus::where('name', TicketStatusEnum::InProgress->value)->first(); + if ($inProgressStatus) { + $oldStatus = $ticket->status->name; + $ticket->update(['status_id' => $inProgressStatus->id]); + + activity() + ->performedOn($ticket) + ->causedBy($user) + ->event('status_changed') + ->withProperties([ + 'old_status' => $oldStatus, + 'new_status' => TicketStatusEnum::InProgress->value, + ]) + ->log("Status updated from {$oldStatus} to ".TicketStatusEnum::InProgress->value); + + if ($ticket->user_id !== $user->id) { + $ticket->user->notify(new TicketStatusChangedNotification($ticket)); + $this->logNotificationActivity($ticket, $ticket->user, TicketStatusChangedNotification::class); + } + } + } + + // Notify ticket raiser if they did not make this comment + if ($ticket->user_id !== $request->userId) { + $ticket->user->notify(new TicketCommentedNotification($ticket, $reply)); + $this->logNotificationActivity($ticket, $ticket->user, TicketCommentedNotification::class); + } + + // Notify assigned support user if they are set and did not make this comment + if ($ticket->assigned_user_id && $ticket->assigned_user_id !== $request->userId) { + $ticket->assignedUser->notify(new TicketCommentedNotification($ticket, $reply)); + $this->logNotificationActivity($ticket, $ticket->assignedUser, TicketCommentedNotification::class); + } + + return ReplyData::fromModel($reply->load('user.roles')); + }); + } + + /** + * Create a new support ticket. + * + * @throws Throwable + */ + public function create(CreateTicketRequest $request): TicketData + { + return DB::transaction(function () use ($request): TicketData { + $defaultStatus = TicketStatus::query() + ->where('name', TicketStatusEnum::Open->value) + ->firstOrFail(); + + /** @var Ticket $ticket */ + $ticket = Ticket::query()->create([ + 'user_id' => $request->userId, + 'connected_app_id' => $request->connectedAppId, + 'issue_category' => $request->issueCategory, + 'description' => $request->description, + 'status_id' => $defaultStatus->id, + ]); + + if (! empty($request->notifiedRoleIds)) { + $ticket->notifiedRoles()->sync($request->notifiedRoleIds); + } + + if (! empty($request->notifiedUserIds)) { + $ticket->notifiedUsers()->sync($request->notifiedUserIds); + } + + if ($request->attachment instanceof UploadedFile) { + $this->attachmentService->store($ticket->id, $request->attachment); + } + + // Load relations to build complete DTO and trigger notifications + $ticket->load(['user', 'connectedApp', 'status', 'notifiedRoles', 'notifiedUsers', 'attachments', 'assignedUser', 'replies.user.roles']); + + $this->sendNotifications($ticket); + + return TicketData::fromModel($ticket); + }); + } + + /** + * Send notifications to all routed recipients. + */ + private function sendNotifications(Ticket $ticket): void + { + $notification = new TicketRaisedNotification($ticket); + $notifiedUserIds = []; + + // Notify specific users + foreach ($ticket->notifiedUsers as $user) { + $user->notify($notification); + $this->logNotificationActivity($ticket, $user, TicketRaisedNotification::class); + $notifiedUserIds[] = $user->id; + } + + // Notify role users + foreach ($ticket->notifiedRoles as $role) { + $users = User::role($role->name)->get(); + foreach ($users as $user) { + // Avoid double notifications + if (! in_array($user->id, $notifiedUserIds, true)) { + $user->notify($notification); + $this->logNotificationActivity($ticket, $user, TicketRaisedNotification::class); + $notifiedUserIds[] = $user->id; + } + } + } + + // Always notify all administrators when a ticket is raised + $admins = User::whereHas('roles', function ($q): void { + $q->whereIn('name', ['Admin', 'admin']); + })->get(); + foreach ($admins as $admin) { + if (! in_array($admin->id, $notifiedUserIds, true)) { + $admin->notify($notification); + $this->logNotificationActivity($ticket, $admin, TicketRaisedNotification::class); + $notifiedUserIds[] = $admin->id; + } + } + } + + /** + * Log notification dispatch activity. + */ + private function logNotificationActivity(Ticket $ticket, User $recipient, string $notificationClass): void + { + $user = auth()->user(); + activity() + ->performedOn($ticket) + ->causedBy($user) + ->event('user_notified') + ->withProperties([ + 'recipient_id' => $recipient->id, + 'recipient_name' => $recipient->name, + 'notification_type' => $notificationClass, + ]) + ->log("Notified user {$recipient->name} about ticket {$ticket->ticket_id}"); + } +} diff --git a/app/Services/UserAppAccessService.php b/app/Services/UserAppAccessService.php new file mode 100644 index 0000000..268f3e3 --- /dev/null +++ b/app/Services/UserAppAccessService.php @@ -0,0 +1,137 @@ + + */ + public function getActiveServices(User $user): Collection + { + $user->loadMissing(['roles.apps', 'roles.permissions']); + + $restrictedPermissions = $this->getRestrictedPermissions($user); + + /** @var Collection $appsMap */ + $appsMap = collect(); + + /** @var Collection $roles */ + $roles = $user->roles; + + foreach ($roles as $role) { + /** @var Collection $apps */ + $apps = $role->apps; + + foreach ($apps as $app) { + if ($this->isAppRestrictedForRole($role, $app, $restrictedPermissions)) { + continue; + } + + /** @var mixed $pivot */ + $pivot = $app->pivot; + $durationStr = (string) $pivot->duration; + + $daysRemaining = $this->getDaysRemainingIfActive($durationStr); + + // If null is returned, the app has expired. + if (null === $daysRemaining) { + continue; + } + + $this->addOrUpdateAppInMap($appsMap, $app, $durationStr, $daysRemaining); + } + } + + return $appsMap->values(); + } + + /** + * Retrieves a map of restricted permissions for the user, grouped by role ID. + * + * * @return array> + */ + private function getRestrictedPermissions(User $user): array + { + return RestrictedUserPermission::query() + ->where('user_id', $user->id) + ->get() + ->groupBy('role_id') + ->map(fn ($item) => $item->pluck('permission_id')->all()) + ->all(); + } + + /** + * Checks if the app is restricted for the given role. + * + * * @param array> $restrictedPermissions + */ + private function isAppRestrictedForRole(Role $role, ConnectedApp $app, array $restrictedPermissions): bool + { + $permissionName = AppPermission::name(ConnectedAppPermissonEnum::Use, ConnectedAppData::from($app)); + $permission = $role->permissions->firstWhere('name', $permissionName); + + if (! $permission) { + return false; + } + + return isset($restrictedPermissions[$role->id]) + && in_array($permission->id, $restrictedPermissions[$role->id], true); + } + + /** + * Calculates the days remaining. Returns null if the duration has expired. + */ + private function getDaysRemainingIfActive(string $durationStr): ?int + { + $duration = Carbon::parse($durationStr); + $now = now(); + + if ($duration->isPast()) { + return null; + } + + $daysRemaining = (int) $now->diffInDays($duration, false); + + if (0 === $daysRemaining && $now->lt($duration)) { + return 1; + } + + return $daysRemaining; + } + + /** + * Adds the app to the map or updates it if the new duration is greater than the existing one. + * + * * @param Collection $appsMap + */ + private function addOrUpdateAppInMap(Collection $appsMap, ConnectedApp $app, string $durationStr, int $daysRemaining): void + { + /** @var ServiceAppDto|null $existing */ + $existing = $appsMap->get($app->id); + $duration = Carbon::parse($durationStr); + + if (! $existing || Carbon::parse($existing->duration)->lt($duration)) { + $appsMap->put($app->id, new ServiceAppDto( + id: $app->id, + name: $app->name, + slug: $app->slug, + duration: $durationStr, + days_remaining: $daysRemaining, + is_warning: $daysRemaining <= 3, + )); + } + } +} diff --git a/app/Services/UserService.php b/app/Services/UserService.php new file mode 100644 index 0000000..a50ba08 --- /dev/null +++ b/app/Services/UserService.php @@ -0,0 +1,141 @@ + + */ + #[NoDiscard] + public function getAll(): PaginatedDataCollection + { + $users = User::query() + ->with([ + 'roles:id,name,priority', + 'roles.apps:id,name', + 'roles.permissions:id,name', + 'restrictedPermissions', + ]) + ->orderBy('id') + ->paginate(); + + return UserIndexData::collect($users, PaginatedDataCollection::class); + } + + /** + * Get the restricted permission IDs for a user. + * + * @return array + */ + public function getRestrictedPermissionIds(int $userId): array + { + if ($userId <= 0) { + return []; + } + + return RestrictedUserPermission::query() + ->where('user_id', $userId) + ->pluck('permission_id') + ->toArray(); + } + + /** + * Assign roles to a user and update their SSO settings. + */ + public function assignRoles(int $userId, array $roleIds, ?string $immutableId = null): void + { + if ($userId <= 0) { + throw new InvalidArgumentException('Invalid id'); + } + + DB::transaction(function () use ($userId, $roleIds, $immutableId): void { + $user = User::query()->findOrFail($userId); + + $user->immutable_id = $immutableId ?: null; + $user->save(); + + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + + // Get user's existing roles that have priority <= $userPriority (which are forbidden to the logged in user) + $forbiddenRoleIds = $user->roles() + ->where('priority', '<=', $userPriority) + ->pluck('id') + ->toArray(); + + // Merge them with the newly assigned roles so they are preserved + $mergedRoleIds = array_values(array_unique(array_merge($forbiddenRoleIds, $roleIds))); + + $user->syncRoles($mergedRoleIds); + }); + } + + /** + * Sync restricted permissions for a user. + * + * @param DataCollection $deactivatedPermissions + * + * @throws Throwable + */ + public function syncRestrictedPermissions(int $userId, DataCollection $deactivatedPermissions): void + { + if ($userId <= 0) { + throw new InvalidArgumentException('Invalid user ID'); + } + + $payload = $deactivatedPermissions->toCollection()->map(fn ($data) => [ + 'user_id' => $userId, + 'role_id' => $data->roleId, + 'permission_id' => $data->permissionId, + ])->toArray(); + + DB::transaction(function () use ($userId, $payload): void { + + RestrictedUserPermission::query() + ->where('user_id', $userId) + ->delete(); + + if (! empty($payload)) { + RestrictedUserPermission::query()->insert($payload); + } + + }); + } + + /** + * @throws InvalidArgumentException when id is invalid. + * @throws Throwable when an error occurs during the deletion. + */ + public function delete(int $id): void + { + if ($id <= 0) { + throw new InvalidArgumentException('Invalid id'); + } + + DB::transaction(function () use ($id): void { + $user = User::query()->findOrFail($id); + + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + $targetUserMinPriority = $user->roles()->min('priority') ?? 999999; + + if ($targetUserMinPriority <= $userPriority) { + abort(403, 'Unauthorized to delete this user due to role priority hierarchy.'); + } + + $user->delete(); + }); + } +} diff --git a/app/View/Components/Table.php b/app/View/Components/Table.php deleted file mode 100644 index c0db971..0000000 --- a/app/View/Components/Table.php +++ /dev/null @@ -1,269 +0,0 @@ -rowDecoration; - $cellDecoration = $this->cellDecoration; - $headers = $this->headers; - - // Remove closures from serialization - unset($this->rowDecoration, $this->cellDecoration, $this->headers); - - - - // Serialize a unique ID for component tracking - $this->uuid = "table-" . md5(serialize($this)) . $id; - - // Restore closures - $this->rowDecoration = $rowDecoration; - $this->cellDecoration = $cellDecoration; - $this->headers = $headers; - } - - // Check if header is hidden - public function isHidden(mixed $header): bool - { - return $header['hidden'] ?? false; - } - - // Format header contents - public function format(mixed $row, mixed $field, mixed $header): mixed - { - $format = $header['format'] ?? null; - - if ( ! $format) { - return $field; - } - - if (is_callable($format)) { - return $format($row, $field); - } - - if ('currency' === $format[0]) { - return ($format[2] ?? '') . number_format((float) $field, ...mb_str_split($format[1])); - } - - if ('date' === $format[0] && $field) { - return Carbon::parse($field)->translatedFormat($format[1]); - } - - return $field; - } - - // Check if link should be shown in cell - public function hasLink(mixed $header): bool - { - return $this->link && empty($header['disableLink']); - } - - // Build row link - public function redirectLink(mixed $row): string - { - $link = $this->link; - - // Transform from `route()` pattern - $link = Str::of($link)->replace('%5B', '{')->replace('%5D', '}'); - - // Extract tokens like {id}, {city.name} ... - $tokens = Str::of($link)->matchAll('/\{(.*?)\}/'); - - // Replace tokens with actual row values - $tokens->each(function (string $token) use ($row, &$link): void { - $link = Str::of($link)->replace("{" . $token . "}", data_get($row, $token))->toString(); - }); - - return $link; - } - - public function rowClasses(mixed $row): ?string - { - $classes = []; - - foreach ($this->rowDecoration as $class => $condition) { - if ($condition($row)) { - $classes[] = $class; - } - } - - return Arr::join($classes, ' '); - } - - public function cellClasses(mixed $row, array $header): ?string - { - $classes = Str::of($header['class'] ?? '')->explode(' ')->all(); - - foreach ($this->cellDecoration[$header['key']] ?? [] as $class => $condition) { - if ($condition($row)) { - $classes[] = $class; - } - } - - return Arr::join($classes, ' '); - } - - public function render(): View|Closure|string - { - return <<<'HTML' -
-
- whereDoesntStartWith('wire:model') - ->class([ - 'w-full text-sm text-left rtl:text-right text-gray-500 dark:text-gray-400', - ]) - }} - > - $noHeaders - ])> - - @foreach($headers as $header) - @php - if($isHidden($header)) continue; - - # Scoped slot's name like `user.city` are compiled to `user___city` through `@scope / @endscope`. - # So we use current `$header` key to find that slot on context. - $temp_key = str_replace('.', '___', $header['key']) - @endphp - - - @endforeach - - @if($actions) - - @endif - - - - - @foreach($rows as $k => $row) - $striped, - 'hover:bg-gray-50' => !$noHover, - 'cursor-pointer' => $attributes->has('@row-click') || $link - ]) - @if($attributes->has('@row-click')) - @click="$dispatch('row-click', {{ json_encode($row) }});" - @endif - > - @foreach($headers as $header) - @php - if($isHidden($header)) continue; - $temp_key = str_replace('.', '___', $header['key']) - @endphp - - @if(isset(${"cell_".$temp_key})) - - @else - - @endif - @endforeach - - @if($actions) - - @endif - - @endforeach - - - @isset ($footer) - attributes->get('class') ?? '' - ])> - {{ $footer }} - - @endisset -
- {{ isset(${"header_".$temp_key}) ? ${"header_".$temp_key}($header) : $header['label'] }} - - Actions -
$hasLink($header)])> - @if($hasLink($header)) - - @endif - - {{ ${"cell_".$temp_key}($row) }} - - @if($hasLink($header)) - - @endif - $hasLink($header)])> - @if($hasLink($header)) - - @endif - - {{ $format($row, data_get($row, $header['key']), $header) }} - - @if($hasLink($header)) - - @endif - - {{ $actions($row) }} -
- - @if(count($rows) === 0) - @if($showEmptyText) -
- {{ $emptyText }} -
- @endif - @if($empty) -
- {{ $empty }} -
- @endif - @endif -
- - @if($withPagination && method_exists($rows, 'links')) -
- {{ $rows->links() }} -
- @endif -
- HTML; - } -} diff --git a/art/STRUCTURE.md b/art/STRUCTURE.md new file mode 100644 index 0000000..c0ea8e1 --- /dev/null +++ b/art/STRUCTURE.md @@ -0,0 +1,6 @@ +## Service Layer + +Service layer should return DTO objects to application layer (Livewire Components, Forms, Views). +As Livewire sends whole properties to the component, any data returned from Repository should not be returned to the +components. +It's a critical security concern not to expose any sensitive data to the components. diff --git a/art/db-diagram.svg b/art/db-diagram.svg new file mode 100644 index 0000000..b721c2f --- /dev/null +++ b/art/db-diagram.svg @@ -0,0 +1,20 @@ +0..11*10..110..11111111111111usersidbigintnamestringemailstringactivebooltwo_factor_secrettexttwo_factor_recovery_codetexttwo_factor_confirmed_attimestampcreated_attimestampupdated_attimestampconnected_servicesidbigintnamestringprovider_idbigintslugstringprotocol_idbigintstatus_idbigintlogo_urlstringcreated_attimestampupdated_attimestampservice_providersidbigintnamestringslugstringservice_protocolsidbigintnamestringservice_statusidbigintnamestringpermissionsidbigintnamestringconnected_service_idbigintrolesidbigintnamestringrole_has_permissionsrole_idbigintpermission_idbigintmodel_has_rolesrole_idbigintmodel_typestringmodel_idbigintmodel_has_permissionspermission_idbigintmodel_typestringmodel_idbigintrole_has_servicesrole_idbigintservice_idbigint \ No newline at end of file diff --git a/art/er-diagram-1.png b/art/er-diagram-1.png new file mode 100644 index 0000000..38b41eb Binary files /dev/null and b/art/er-diagram-1.png differ diff --git a/art/er-diagram-2.png b/art/er-diagram-2.png new file mode 100644 index 0000000..67b877f Binary files /dev/null and b/art/er-diagram-2.png differ diff --git a/boost.json b/boost.json index 1129526..b327db2 100644 --- a/boost.json +++ b/boost.json @@ -1,9 +1,5 @@ { - "agents": [ - "claude_code", - "copilot", - "opencode" - ], + "agents": ["claude_code", "copilot", "opencode"], "cloud": false, "guidelines": true, "mcp": true, @@ -12,7 +8,6 @@ "skills": [ "fortify-development", "laravel-best-practices", - "fluxui-development", "livewire-development", "pest-testing", "tailwindcss-development" diff --git a/bootstrap/app.php b/bootstrap/app.php index 944f4a5..b645e1c 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -3,13 +3,12 @@ declare(strict_types=1); use Illuminate\Foundation\Application; -use Illuminate\Foundation\Configuration\Exceptions; -use Illuminate\Foundation\Configuration\Middleware; +use Illuminate\Foundation\Configuration\{Exceptions, Middleware}; return Application::configure(basePath: dirname(__DIR__)) ->withRouting( - web: __DIR__ . '/../routes/web.php', - commands: __DIR__ . '/../routes/console.php', + web: __DIR__.'/../routes/web.php', + commands: __DIR__.'/../routes/console.php', health: '/up', ) ->withMiddleware(function (Middleware $middleware): void {}) diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 7b0123d..6e73c74 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -5,5 +5,5 @@ return [ App\Providers\AppServiceProvider::class, App\Providers\FortifyServiceProvider::class, - App\Providers\ScopeServiceProvider::class, + App\Providers\DirectiveServiceProvider::class, ]; diff --git a/captainhook.json b/captainhook.json new file mode 100644 index 0000000..2bda658 --- /dev/null +++ b/captainhook.json @@ -0,0 +1,28 @@ +{ + "commit-msg": { + "enabled": true, + "actions": [ + { + "action": "\\CaptainHook\\App\\Hook\\Message\\Action\\Beams", + "options": { + "subjectLength": 100, + "bodyLineLength": 300 + } + } + ] + }, + "pre-commit": { + "enabled": true, + "actions": [ + { + "action": "composer lint" + }, + { + "action": "composer check" + }, + { + "action": "composer test:arch" + } + ] + } +} diff --git a/composer.json b/composer.json index 78fd7af..bf0e2e4 100644 --- a/composer.json +++ b/composer.json @@ -9,18 +9,26 @@ ], "license": "MIT", "require": { - "php": "^8.3", + "php": "^8.5", "gehrisandro/tailwind-merge-laravel": "^1.4", "laravel/fortify": "^1.34", "laravel/framework": "^13.7", "laravel/tinker": "^3.0", "livewire/flux": "^2.13.1", - "livewire/livewire": "^4.1", + "livewire/livewire": "^4.3", "mallardduck/blade-lucide-icons": "^1.26", - "spatie/laravel-data": "^4.22" + "robrichards/xmlseclibs": "^3.1", + "robsontenorio/mary": "^2.8", + "spatie/laravel-activitylog": "^5.0", + "spatie/laravel-data": "^4.22", + "spatie/laravel-permission": "^7.4", + "ext-openssl": "*" }, "require-dev": { + "captainhook/captainhook": "^5.29", "fakerphp/faker": "^1.24", + "laradumps/laradumps": "^5.3", + "larastan/larastan": "^3.9", "laravel/boost": "^2.2", "laravel/pail": "^1.2.5", "laravel/pao": "^1.0.6", @@ -29,7 +37,8 @@ "mockery/mockery": "^1.6", "nunomaduro/collision": "^8.9.3", "pestphp/pest": "^4.7", - "pestphp/pest-plugin-laravel": "^4.1" + "pestphp/pest-plugin-laravel": "^4.1", + "ext-zlib": "*" }, "autoload": { "psr-4": { @@ -54,7 +63,11 @@ ], "dev": [ "Composer\\Config::disableProcessTimeout", - "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan queue:listen --tries=1 --timeout=0\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" + "npx concurrently -c \"#93c5fd,#c4b5fd,#fb7185,#fdba74\" \"php artisan serve\" \"php artisan pail --timeout=0\" \"npm run dev\" --names=server,queue,logs,vite --kill-others" + ], + "check": [ + "vendor/bin/pint --test", + "vendor/bin/phpstan analyse --memory-limit=4G" ], "lint": [ "pint --parallel" @@ -66,6 +79,7 @@ "Composer\\Config::disableProcessTimeout", "@test" ], + "test:arch": "pest tests/ArchTest.php", "test": [ "@php artisan config:clear --ansi", "@lint:check", diff --git a/composer.lock b/composer.lock index abc83d8..f60b855 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2e46083521ccb7753e903364bb87f40b", + "content-hash": "63deb63c42b1c08194cd70e47ae3f312", "packages": [ { "name": "bacon/bacon-qr-code", @@ -61,6 +61,75 @@ }, "time": "2026-04-05T21:06:35+00:00" }, + { + "name": "blade-ui-kit/blade-heroicons", + "version": "2.7.0", + "source": { + "type": "git", + "url": "https://github.com/driesvints/blade-heroicons.git", + "reference": "66fa8ba09dba12e0cdb410b8cb94f3b890eca440" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/driesvints/blade-heroicons/zipball/66fa8ba09dba12e0cdb410b8cb94f3b890eca440", + "reference": "66fa8ba09dba12e0cdb410b8cb94f3b890eca440", + "shasum": "" + }, + "require": { + "blade-ui-kit/blade-icons": "^1.6", + "illuminate/support": "^9.0|^10.0|^11.0|^12.0|^13.0", + "php": "^8.0" + }, + "require-dev": { + "orchestra/testbench": "^7.0|^8.0|^9.0|^10.0|^11.0", + "phpunit/phpunit": "^9.0|^10.5|^11.0|^12.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "BladeUI\\Heroicons\\BladeHeroiconsServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "BladeUI\\Heroicons\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Dries Vints", + "homepage": "https://driesvints.com" + } + ], + "description": "A package to easily make use of Heroicons in your Laravel Blade views.", + "homepage": "https://github.com/driesvints/blade-heroicons", + "keywords": [ + "Heroicons", + "blade", + "laravel" + ], + "support": { + "issues": "https://github.com/driesvints/blade-heroicons/issues", + "source": "https://github.com/driesvints/blade-heroicons/tree/2.7.0" + }, + "funding": [ + { + "url": "https://github.com/sponsors/driesvints", + "type": "github" + }, + { + "url": "https://www.paypal.com/paypalme/driesvints", + "type": "paypal" + } + ], + "time": "2026-03-16T13:00:23+00:00" + }, { "name": "blade-ui-kit/blade-icons", "version": "1.10.0", @@ -1018,16 +1087,16 @@ }, { "name": "guzzlehttp/guzzle", - "version": "7.10.0", + "version": "7.10.1", "source": { "type": "git", "url": "https://github.com/guzzle/guzzle.git", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4" + "reference": "b777df1776c667e287664dda75b0298ad8ae3a14" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", - "reference": "b51ac707cfa420b7bfd4e4d5e510ba8008e822b4", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b777df1776c667e287664dda75b0298ad8ae3a14", + "reference": "b777df1776c667e287664dda75b0298ad8ae3a14", "shasum": "" }, "require": { @@ -1045,8 +1114,9 @@ "bamarni/composer-bin-plugin": "^1.8.2", "ext-curl": "*", "guzzle/client-integration-tests": "3.0.2", + "guzzlehttp/test-server": "^0.3.2", "php-http/message-factory": "^1.1", - "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "phpunit/phpunit": "^8.5.52 || ^9.6.34", "psr/log": "^1.1 || ^2.0 || ^3.0" }, "suggest": { @@ -1124,7 +1194,7 @@ ], "support": { "issues": "https://github.com/guzzle/guzzle/issues", - "source": "https://github.com/guzzle/guzzle/tree/7.10.0" + "source": "https://github.com/guzzle/guzzle/tree/7.10.1" }, "funding": [ { @@ -1140,20 +1210,20 @@ "type": "tidelift" } ], - "time": "2025-08-23T22:36:01+00:00" + "time": "2026-05-19T18:01:31+00:00" }, { "name": "guzzlehttp/promises", - "version": "2.3.0", + "version": "2.3.1", "source": { "type": "git", "url": "https://github.com/guzzle/promises.git", - "reference": "481557b130ef3790cf82b713667b43030dc9c957" + "reference": "d2d8dfae4757f384d630fdffc2d8d6618d8f4c5e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/promises/zipball/481557b130ef3790cf82b713667b43030dc9c957", - "reference": "481557b130ef3790cf82b713667b43030dc9c957", + "url": "https://api.github.com/repos/guzzle/promises/zipball/d2d8dfae4757f384d630fdffc2d8d6618d8f4c5e", + "reference": "d2d8dfae4757f384d630fdffc2d8d6618d8f4c5e", "shasum": "" }, "require": { @@ -1161,7 +1231,7 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "type": "library", "extra": { @@ -1207,7 +1277,7 @@ ], "support": { "issues": "https://github.com/guzzle/promises/issues", - "source": "https://github.com/guzzle/promises/tree/2.3.0" + "source": "https://github.com/guzzle/promises/tree/2.3.1" }, "funding": [ { @@ -1223,20 +1293,20 @@ "type": "tidelift" } ], - "time": "2025-08-22T14:34:08+00:00" + "time": "2026-05-19T18:30:48+00:00" }, { "name": "guzzlehttp/psr7", - "version": "2.9.0", + "version": "2.10.0", "source": { "type": "git", "url": "https://github.com/guzzle/psr7.git", - "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884" + "reference": "d5ddaf5743c42a61cb6100f83dc9d5a2bafe75ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/guzzle/psr7/zipball/7d0ed42f28e42d61352a7a79de682e5e67fec884", - "reference": "7d0ed42f28e42d61352a7a79de682e5e67fec884", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/d5ddaf5743c42a61cb6100f83dc9d5a2bafe75ca", + "reference": "d5ddaf5743c42a61cb6100f83dc9d5a2bafe75ca", "shasum": "" }, "require": { @@ -1251,9 +1321,9 @@ }, "require-dev": { "bamarni/composer-bin-plugin": "^1.8.2", - "http-interop/http-factory-tests": "0.9.0", + "http-interop/http-factory-tests": "1.1.0", "jshttp/mime-db": "1.54.0.1", - "phpunit/phpunit": "^8.5.44 || ^9.6.25" + "phpunit/phpunit": "^8.5.52 || ^9.6.34" }, "suggest": { "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" @@ -1324,7 +1394,7 @@ ], "support": { "issues": "https://github.com/guzzle/psr7/issues", - "source": "https://github.com/guzzle/psr7/tree/2.9.0" + "source": "https://github.com/guzzle/psr7/tree/2.10.0" }, "funding": [ { @@ -1340,7 +1410,7 @@ "type": "tidelift" } ], - "time": "2026-03-10T16:41:02+00:00" + "time": "2026-05-19T17:32:11+00:00" }, { "name": "guzzlehttp/uri-template", @@ -1428,6 +1498,243 @@ ], "time": "2025-08-22T14:27:06+00:00" }, + { + "name": "jfcherng/php-color-output", + "version": "3.0.0", + "source": { + "type": "git", + "url": "https://github.com/jfcherng/php-color-output.git", + "reference": "6c7bf16686cc6a291647fcb87491640a2d5edd20" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jfcherng/php-color-output/zipball/6c7bf16686cc6a291647fcb87491640a2d5edd20", + "reference": "6c7bf16686cc6a291647fcb87491640a2d5edd20", + "shasum": "" + }, + "require": { + "php": ">=7.1.3" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^2.19", + "liip/rmt": "^1.6", + "phan/phan": "^2 || ^3 || ^4", + "phpunit/phpunit": ">=7 <10", + "squizlabs/php_codesniffer": "^3.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jfcherng\\Utility\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jack Cherng", + "email": "jfcherng@gmail.com" + } + ], + "description": "Make your PHP command-line application colorful.", + "keywords": [ + "ansi-colors", + "color", + "command-line", + "str-color" + ], + "support": { + "issues": "https://github.com/jfcherng/php-color-output/issues", + "source": "https://github.com/jfcherng/php-color-output/tree/3.0.0" + }, + "funding": [ + { + "url": "https://www.paypal.me/jfcherng/5usd", + "type": "custom" + } + ], + "time": "2021-05-27T02:45:54+00:00" + }, + { + "name": "jfcherng/php-diff", + "version": "6.16.2", + "source": { + "type": "git", + "url": "https://github.com/jfcherng/php-diff.git", + "reference": "7f46bcfc582e81769237d0b3f6b8a548efe8799d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jfcherng/php-diff/zipball/7f46bcfc582e81769237d0b3f6b8a548efe8799d", + "reference": "7f46bcfc582e81769237d0b3f6b8a548efe8799d", + "shasum": "" + }, + "require": { + "jfcherng/php-color-output": "^3", + "jfcherng/php-mb-string": "^1.4.6 || ^2", + "jfcherng/php-sequence-matcher": "^3.2.10 || ^4", + "php": ">=7.4" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.51", + "liip/rmt": "^1.6", + "phan/phan": "^5", + "phpunit/phpunit": "^9", + "squizlabs/php_codesniffer": "^3.6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jfcherng\\Diff\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Jack Cherng", + "email": "jfcherng@gmail.com" + }, + { + "name": "Chris Boulton", + "email": "chris.boulton@interspire.com" + } + ], + "description": "A comprehensive library for generating differences between two strings in multiple formats (unified, side by side HTML etc).", + "keywords": [ + "diff", + "udiff", + "unidiff", + "unified diff" + ], + "support": { + "issues": "https://github.com/jfcherng/php-diff/issues", + "source": "https://github.com/jfcherng/php-diff/tree/6.16.2" + }, + "funding": [ + { + "url": "https://www.paypal.me/jfcherng/5usd", + "type": "custom" + } + ], + "time": "2024-03-10T17:40:29+00:00" + }, + { + "name": "jfcherng/php-mb-string", + "version": "2.0.1", + "source": { + "type": "git", + "url": "https://github.com/jfcherng/php-mb-string.git", + "reference": "8407bfefde47849c9e7c9594e6de2ac85a0f845d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jfcherng/php-mb-string/zipball/8407bfefde47849c9e7c9594e6de2ac85a0f845d", + "reference": "8407bfefde47849c9e7c9594e6de2ac85a0f845d", + "shasum": "" + }, + "require": { + "ext-iconv": "*", + "php": ">=8.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3", + "phan/phan": "^5", + "phpunit/phpunit": "^9 || ^10" + }, + "suggest": { + "ext-iconv": "Either \"ext-iconv\" or \"ext-mbstring\" is requried.", + "ext-mbstring": "Either \"ext-iconv\" or \"ext-mbstring\" is requried." + }, + "type": "library", + "autoload": { + "psr-4": { + "Jfcherng\\Utility\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jack Cherng", + "email": "jfcherng@gmail.com" + } + ], + "description": "A high performance multibytes sting implementation for frequently reading/writing operations.", + "support": { + "issues": "https://github.com/jfcherng/php-mb-string/issues", + "source": "https://github.com/jfcherng/php-mb-string/tree/2.0.1" + }, + "funding": [ + { + "url": "https://www.paypal.me/jfcherng/5usd", + "type": "custom" + } + ], + "time": "2023-04-17T14:23:16+00:00" + }, + { + "name": "jfcherng/php-sequence-matcher", + "version": "4.0.3", + "source": { + "type": "git", + "url": "https://github.com/jfcherng/php-sequence-matcher.git", + "reference": "d2038ac29627340a7458609072a8ba355e80ec5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/jfcherng/php-sequence-matcher/zipball/d2038ac29627340a7458609072a8ba355e80ec5b", + "reference": "d2038ac29627340a7458609072a8ba355e80ec5b", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3", + "phan/phan": "^5", + "phpunit/phpunit": "^9 || ^10", + "squizlabs/php_codesniffer": "^3.7" + }, + "type": "library", + "autoload": { + "psr-4": { + "Jfcherng\\Diff\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "authors": [ + { + "name": "Jack Cherng", + "email": "jfcherng@gmail.com" + }, + { + "name": "Chris Boulton", + "email": "chris.boulton@interspire.com" + } + ], + "description": "A longest sequence matcher. The logic is primarily based on the Python difflib package.", + "support": { + "issues": "https://github.com/jfcherng/php-sequence-matcher/issues", + "source": "https://github.com/jfcherng/php-sequence-matcher/tree/4.0.3" + }, + "funding": [ + { + "url": "https://www.paypal.me/jfcherng/5usd", + "type": "custom" + } + ], + "time": "2023-05-21T07:57:08+00:00" + }, { "name": "laravel/fortify", "version": "v1.37.0", @@ -1494,16 +1801,16 @@ }, { "name": "laravel/framework", - "version": "v13.8.0", + "version": "v13.11.1", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "e7db333a025a1e93ebca7744953069d7719f4bcf" + "reference": "6b70133ea3552afc37307ffb85b9efa48dc187d1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/e7db333a025a1e93ebca7744953069d7719f4bcf", - "reference": "e7db333a025a1e93ebca7744953069d7719f4bcf", + "url": "https://api.github.com/repos/laravel/framework/zipball/6b70133ea3552afc37307ffb85b9efa48dc187d1", + "reference": "6b70133ea3552afc37307ffb85b9efa48dc187d1", "shasum": "" }, "require": { @@ -1607,7 +1914,7 @@ "aws/aws-sdk-php": "^3.322.9", "ext-gmp": "*", "fakerphp/faker": "^1.24", - "guzzlehttp/psr7": "^2.4", + "guzzlehttp/psr7": "^2.9", "laravel/pint": "^1.18", "league/flysystem-aws-s3-v3": "^3.25.1", "league/flysystem-ftp": "^3.25.1", @@ -1714,7 +2021,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-05-05T21:01:14+00:00" + "time": "2026-05-19T20:24:39+00:00" }, { "name": "laravel/passkeys", @@ -1786,16 +2093,16 @@ }, { "name": "laravel/prompts", - "version": "v0.3.17", + "version": "v0.3.18", "source": { "type": "git", "url": "https://github.com/laravel/prompts.git", - "reference": "6a82ac19a28b916ae0885828795dbd4c59d9a818" + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/prompts/zipball/6a82ac19a28b916ae0885828795dbd4c59d9a818", - "reference": "6a82ac19a28b916ae0885828795dbd4c59d9a818", + "url": "https://api.github.com/repos/laravel/prompts/zipball/a19af51bb144bf87f08397921fa619f85c7d4e72", + "reference": "a19af51bb144bf87f08397921fa619f85c7d4e72", "shasum": "" }, "require": { @@ -1839,9 +2146,9 @@ "description": "Add beautiful and user-friendly forms to your command-line applications.", "support": { "issues": "https://github.com/laravel/prompts/issues", - "source": "https://github.com/laravel/prompts/tree/v0.3.17" + "source": "https://github.com/laravel/prompts/tree/v0.3.18" }, - "time": "2026-04-20T16:07:33+00:00" + "time": "2026-05-19T00:47:18+00:00" }, { "name": "laravel/serializable-closure", @@ -2164,16 +2471,16 @@ }, { "name": "league/flysystem", - "version": "3.33.0", + "version": "3.34.0", "source": { "type": "git", "url": "https://github.com/thephpleague/flysystem.git", - "reference": "570b8871e0ce693764434b29154c54b434905350" + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/570b8871e0ce693764434b29154c54b434905350", - "reference": "570b8871e0ce693764434b29154c54b434905350", + "url": "https://api.github.com/repos/thephpleague/flysystem/zipball/2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", + "reference": "2daaac3b0d4c83ea7ed5d8586e786f5d00f3540e", "shasum": "" }, "require": { @@ -2241,9 +2548,9 @@ ], "support": { "issues": "https://github.com/thephpleague/flysystem/issues", - "source": "https://github.com/thephpleague/flysystem/tree/3.33.0" + "source": "https://github.com/thephpleague/flysystem/tree/3.34.0" }, - "time": "2026-03-25T07:59:30+00:00" + "time": "2026-05-14T10:28:08+00:00" }, { "name": "league/flysystem-local", @@ -3011,16 +3318,16 @@ }, { "name": "nette/utils", - "version": "v4.1.3", + "version": "v4.1.4", "source": { "type": "git", "url": "https://github.com/nette/utils.git", - "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe" + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/nette/utils/zipball/bb3ea637e3d131d72acc033cfc2746ee893349fe", - "reference": "bb3ea637e3d131d72acc033cfc2746ee893349fe", + "url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7", + "reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7", "shasum": "" }, "require": { @@ -3096,9 +3403,9 @@ ], "support": { "issues": "https://github.com/nette/utils/issues", - "source": "https://github.com/nette/utils/tree/v4.1.3" + "source": "https://github.com/nette/utils/tree/v4.1.4" }, - "time": "2026-02-13T03:05:33+00:00" + "time": "2026-05-11T20:49:54+00:00" }, { "name": "nikic/php-parser", @@ -3314,78 +3621,6 @@ }, "time": "2025-09-24T15:06:41+00:00" }, - { - "name": "phpdocumentor/reflection", - "version": "6.6.0", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/Reflection.git", - "reference": "c8d36446027506a005103d57265ba5ea56beabfc" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/Reflection/zipball/c8d36446027506a005103d57265ba5ea56beabfc", - "reference": "c8d36446027506a005103d57265ba5ea56beabfc", - "shasum": "" - }, - "require": { - "composer-runtime-api": "^2", - "nikic/php-parser": "~4.18 || ^5.0", - "php": "8.1.*|8.2.*|8.3.*|8.4.*|8.5.*", - "phpdocumentor/reflection-common": "^2.1", - "phpdocumentor/reflection-docblock": "^5", - "phpdocumentor/type-resolver": "^1.4", - "symfony/polyfill-php80": "^1.28", - "webmozart/assert": "^1.7" - }, - "require-dev": { - "dealerdirect/phpcodesniffer-composer-installer": "^1.0", - "doctrine/coding-standard": "^13.0", - "eliashaeussler/phpunit-attributes": "^1.8", - "mikey179/vfsstream": "~1.2", - "mockery/mockery": "~1.6.0", - "phpspec/prophecy-phpunit": "^2.4", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-webmozart-assert": "^1.2", - "phpunit/phpunit": "^10.5.53", - "psalm/phar": "^6.0", - "rector/rector": "^1.0.0", - "squizlabs/php_codesniffer": "^3.8" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-5.x": "5.3.x-dev", - "dev-6.x": "6.0.x-dev" - } - }, - "autoload": { - "files": [ - "src/php-parser/Modifiers.php" - ], - "psr-4": { - "phpDocumentor\\": "src/phpDocumentor" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "Reflection library to do Static Analysis for PHP Projects", - "homepage": "http://www.phpdoc.org", - "keywords": [ - "phpDocumentor", - "phpdoc", - "reflection", - "static analysis" - ], - "support": { - "issues": "https://github.com/phpDocumentor/Reflection/issues", - "source": "https://github.com/phpDocumentor/Reflection/tree/6.6.0" - }, - "time": "2026-04-12T18:07:10+00:00" - }, { "name": "phpdocumentor/reflection-common", "version": "2.2.0", @@ -4425,23 +4660,246 @@ "time": "2025-12-14T04:43:48+00:00" }, { - "name": "spatie/laravel-data", - "version": "4.22.1", + "name": "robrichards/xmlseclibs", + "version": "3.1.5", "source": { "type": "git", - "url": "https://github.com/spatie/laravel-data.git", - "reference": "ec254c0ebc3f3b37515cd7449e2dbb10588e606b" + "url": "https://github.com/robrichards/xmlseclibs.git", + "reference": "03062be78178cbb5e8f605cd255dc32a14981f92" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/spatie/laravel-data/zipball/ec254c0ebc3f3b37515cd7449e2dbb10588e606b", - "reference": "ec254c0ebc3f3b37515cd7449e2dbb10588e606b", + "url": "https://api.github.com/repos/robrichards/xmlseclibs/zipball/03062be78178cbb5e8f605cd255dc32a14981f92", + "reference": "03062be78178cbb5e8f605cd255dc32a14981f92", + "shasum": "" + }, + "require": { + "ext-openssl": "*", + "php": ">= 5.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "RobRichards\\XMLSecLibs\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-3-Clause" + ], + "description": "A PHP library for XML Security", + "homepage": "https://github.com/robrichards/xmlseclibs", + "keywords": [ + "security", + "signature", + "xml", + "xmldsig" + ], + "support": { + "issues": "https://github.com/robrichards/xmlseclibs/issues", + "source": "https://github.com/robrichards/xmlseclibs/tree/3.1.5" + }, + "time": "2026-03-13T10:31:56+00:00" + }, + { + "name": "robsontenorio/mary", + "version": "2.8.2", + "source": { + "type": "git", + "url": "https://github.com/robsontenorio/mary.git", + "reference": "fee6e23a158c54c682b5dd5d7c9377ba4889761a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/robsontenorio/mary/zipball/fee6e23a158c54c682b5dd5d7c9377ba4889761a", + "reference": "fee6e23a158c54c682b5dd5d7c9377ba4889761a", + "shasum": "" + }, + "require": { + "blade-ui-kit/blade-heroicons": "^2.6", + "illuminate/support": "^10.0|^11.0|^12.0|^13.0", + "jfcherng/php-diff": "^6.15", + "laravel/prompts": "^0|^1" + }, + "require-dev": { + "orchestra/testbench": "^8|^9|^10|^11.0", + "phpunit/phpunit": "^10|^11|^12.5.12" + }, + "type": "library", + "extra": { + "laravel": { + "aliases": { + "Mary": "Mary\\Facades\\Mary" + }, + "providers": [ + "Mary\\MaryServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Mary\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Robson Tenório", + "email": "rrtenorio@gmail.com", + "homepage": "https://github.com/robsontenorio" + } + ], + "description": "Gorgeous UI components for Livewire powered by daisyUI and Tailwind", + "homepage": "https://mary-ui.com", + "keywords": [ + "DaisyUI", + "alpinejs", + "blade", + "blade ui components", + "components", + "laravel", + "livewire", + "livewire components", + "livewire packages", + "livewire ui", + "livewire ui components", + "livewire-components", + "livewire-packages", + "tailwind", + "tallstack", + "tallstack components", + "tallstack ui", + "tallstackui", + "ui" + ], + "support": { + "issues": "https://github.com/robsontenorio/mary/issues", + "source": "https://github.com/robsontenorio/mary/tree/2.8.2" + }, + "funding": [ + { + "url": "https://github.com/robsontenorio", + "type": "github" + } + ], + "time": "2026-04-17T13:40:53+00:00" + }, + { + "name": "spatie/laravel-activitylog", + "version": "5.0.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-activitylog.git", + "reference": "0e00fe74fd071cc572a045459f6d4c9de33130bd" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-activitylog/zipball/0e00fe74fd071cc572a045459f6d4c9de33130bd", + "reference": "0e00fe74fd071cc572a045459f6d4c9de33130bd", + "shasum": "" + }, + "require": { + "illuminate/config": "^12.0 || ^13.0", + "illuminate/database": "^12.0 || ^13.0", + "illuminate/support": "^12.0 || ^13.0", + "php": "^8.4", + "spatie/laravel-package-tools": "^1.6.3" + }, + "require-dev": { + "ext-json": "*", + "larastan/larastan": "^3.0", + "laravel/pint": "^1.29", + "orchestra/testbench": "^10.0 || ^11.0", + "pestphp/pest": "^4.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Activitylog\\ActivitylogServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\Activitylog\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Sebastian De Deyne", + "email": "sebastian@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + }, + { + "name": "Tom Witkowski", + "email": "dev.gummibeer@gmail.com", + "homepage": "https://gummibeer.de", + "role": "Developer" + } + ], + "description": "A very simple activity logger to monitor the users of your website or application", + "homepage": "https://github.com/spatie/activitylog", + "keywords": [ + "activity", + "laravel", + "log", + "spatie", + "user" + ], + "support": { + "issues": "https://github.com/spatie/laravel-activitylog/issues", + "source": "https://github.com/spatie/laravel-activitylog/tree/5.0.0" + }, + "funding": [ + { + "url": "https://spatie.be/open-source/support-us", + "type": "custom" + }, + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-03-25T10:04:54+00:00" + }, + { + "name": "spatie/laravel-data", + "version": "4.23.0", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-data.git", + "reference": "230543769c996e407fec2873930626aed7dd0d3b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-data/zipball/230543769c996e407fec2873930626aed7dd0d3b", + "reference": "230543769c996e407fec2873930626aed7dd0d3b", "shasum": "" }, "require": { "illuminate/contracts": "^10.0|^11.0|^12.0|^13.0", "php": "^8.1", - "phpdocumentor/reflection": "^6.0", + "phpdocumentor/reflection-common": "^2.2", + "phpdocumentor/reflection-docblock": "^5.3 || ^6.0", + "phpdocumentor/type-resolver": "^1.7 || ^2.0", "spatie/laravel-package-tools": "^1.9.0", "spatie/php-structure-discoverer": "^2.0" }, @@ -4496,7 +4954,7 @@ ], "support": { "issues": "https://github.com/spatie/laravel-data/issues", - "source": "https://github.com/spatie/laravel-data/tree/4.22.1" + "source": "https://github.com/spatie/laravel-data/tree/4.23.0" }, "funding": [ { @@ -4504,7 +4962,7 @@ "type": "github" } ], - "time": "2026-04-27T07:30:53+00:00" + "time": "2026-05-08T14:41:13+00:00" }, { "name": "spatie/laravel-package-tools", @@ -4567,6 +5025,93 @@ ], "time": "2026-02-21T12:49:54+00:00" }, + { + "name": "spatie/laravel-permission", + "version": "7.4.1", + "source": { + "type": "git", + "url": "https://github.com/spatie/laravel-permission.git", + "reference": "ef42ecb781e5534d368a3853fa161e420ad51397" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/laravel-permission/zipball/ef42ecb781e5534d368a3853fa161e420ad51397", + "reference": "ef42ecb781e5534d368a3853fa161e420ad51397", + "shasum": "" + }, + "require": { + "illuminate/auth": "^12.0|^13.0", + "illuminate/container": "^12.0|^13.0", + "illuminate/contracts": "^12.0|^13.0", + "illuminate/database": "^12.0|^13.0", + "php": "^8.3", + "spatie/laravel-package-tools": "^1.0" + }, + "require-dev": { + "larastan/larastan": "^3.9", + "laravel/passport": "^13.0", + "laravel/pint": "^1.0", + "orchestra/testbench": "^10.0|^11.0", + "pestphp/pest": "^3.0|^4.0", + "pestphp/pest-plugin-laravel": "^3.0|^4.1", + "phpstan/phpstan": "^2.1" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Spatie\\Permission\\PermissionServiceProvider" + ] + }, + "branch-alias": { + "dev-main": "7.x-dev", + "dev-master": "7.x-dev" + } + }, + "autoload": { + "files": [ + "src/helpers.php" + ], + "psr-4": { + "Spatie\\Permission\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van der Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "Permission handling for Laravel 12 and up", + "homepage": "https://github.com/spatie/laravel-permission", + "keywords": [ + "acl", + "laravel", + "permission", + "permissions", + "rbac", + "roles", + "security", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/laravel-permission/issues", + "source": "https://github.com/spatie/laravel-permission/tree/7.4.1" + }, + "funding": [ + { + "url": "https://github.com/spatie", + "type": "github" + } + ], + "time": "2026-04-29T07:59:45+00:00" + }, { "name": "spatie/php-structure-discoverer", "version": "2.4.2", @@ -4907,16 +5452,16 @@ }, { "name": "symfony/console", - "version": "v8.0.9", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "7113778e2e91f4709cb3194a75dfa9c0d028d94d" + "reference": "3156577f46a38aa1b9323aad223de7a9cd426782" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/7113778e2e91f4709cb3194a75dfa9c0d028d94d", - "reference": "7113778e2e91f4709cb3194a75dfa9c0d028d94d", + "url": "https://api.github.com/repos/symfony/console/zipball/3156577f46a38aa1b9323aad223de7a9cd426782", + "reference": "3156577f46a38aa1b9323aad223de7a9cd426782", "shasum": "" }, "require": { @@ -4973,7 +5518,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v8.0.9" + "source": "https://github.com/symfony/console/tree/v8.0.11" }, "funding": [ { @@ -4993,7 +5538,7 @@ "type": "tidelift" } ], - "time": "2026-04-29T15:02:55+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "symfony/css-selector", @@ -5531,16 +6076,16 @@ }, { "name": "symfony/http-kernel", - "version": "v8.0.10", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/http-kernel.git", - "reference": "fb3f65b3d4ca2dad31c80d323819a762ca31d6ac" + "reference": "20d3680373f4b791903c09e74b45402b4aeda71c" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/http-kernel/zipball/fb3f65b3d4ca2dad31c80d323819a762ca31d6ac", - "reference": "fb3f65b3d4ca2dad31c80d323819a762ca31d6ac", + "url": "https://api.github.com/repos/symfony/http-kernel/zipball/20d3680373f4b791903c09e74b45402b4aeda71c", + "reference": "20d3680373f4b791903c09e74b45402b4aeda71c", "shasum": "" }, "require": { @@ -5611,7 +6156,7 @@ "description": "Provides a structured process for converting a Request into a Response", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/http-kernel/tree/v8.0.10" + "source": "https://github.com/symfony/http-kernel/tree/v8.0.11" }, "funding": [ { @@ -5631,7 +6176,7 @@ "type": "tidelift" } ], - "time": "2026-05-06T12:27:31+00:00" + "time": "2026-05-13T18:07:14+00:00" }, { "name": "symfony/mailer", @@ -6710,16 +7255,16 @@ }, { "name": "symfony/process", - "version": "v8.0.8", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/process.git", - "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc" + "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/process/zipball/cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", - "reference": "cb8939aff03470d1a9d1d1b66d08c6fa71b3bbdc", + "url": "https://api.github.com/repos/symfony/process/zipball/26d89e459f037d2873300605d0a07e7a8ef84db0", + "reference": "26d89e459f037d2873300605d0a07e7a8ef84db0", "shasum": "" }, "require": { @@ -6751,7 +7296,7 @@ "description": "Executes commands in sub-processes", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/process/tree/v8.0.8" + "source": "https://github.com/symfony/process/tree/v8.0.11" }, "funding": [ { @@ -6771,7 +7316,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-11T16:56:32+00:00" }, { "name": "symfony/property-access", @@ -7026,16 +7571,16 @@ }, { "name": "symfony/serializer", - "version": "v7.4.8", + "version": "v7.4.10", "source": { "type": "git", "url": "https://github.com/symfony/serializer.git", - "reference": "006fd51717addf2df2bd1a64dafef6b7fab6b455" + "reference": "268c5aa6c4bd675eddd89348e7ecac292a843ddd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/serializer/zipball/006fd51717addf2df2bd1a64dafef6b7fab6b455", - "reference": "006fd51717addf2df2bd1a64dafef6b7fab6b455", + "url": "https://api.github.com/repos/symfony/serializer/zipball/268c5aa6c4bd675eddd89348e7ecac292a843ddd", + "reference": "268c5aa6c4bd675eddd89348e7ecac292a843ddd", "shasum": "" }, "require": { @@ -7048,7 +7593,7 @@ "phpdocumentor/reflection-docblock": "<5.2|>=7", "phpdocumentor/type-resolver": "<1.5.1", "symfony/dependency-injection": "<6.4", - "symfony/property-access": "<6.4", + "symfony/property-access": "<6.4.31|>=7.0,<7.4.2|>=8.0,<8.0.2", "symfony/property-info": "<6.4", "symfony/type-info": "<7.2.5", "symfony/uid": "<6.4", @@ -7070,7 +7615,7 @@ "symfony/http-kernel": "^6.4|^7.0|^8.0", "symfony/messenger": "^6.4|^7.0|^8.0", "symfony/mime": "^6.4|^7.0|^8.0", - "symfony/property-access": "^6.4|^7.0|^8.0", + "symfony/property-access": "^6.4.31|^7.4.2|^8.0.2", "symfony/property-info": "^6.4|^7.0|^8.0", "symfony/translation-contracts": "^2.5|^3", "symfony/type-info": "^7.2.5|^8.0", @@ -7106,7 +7651,7 @@ "description": "Handles serializing and deserializing data structures, including object graphs, into array structures or other formats like XML and JSON.", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/serializer/tree/v7.4.8" + "source": "https://github.com/symfony/serializer/tree/v7.4.10" }, "funding": [ { @@ -7126,7 +7671,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T21:34:42+00:00" + "time": "2026-05-03T13:03:28+00:00" }, { "name": "symfony/service-contracts", @@ -7217,16 +7762,16 @@ }, { "name": "symfony/string", - "version": "v8.0.8", + "version": "v8.0.11", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "ae9488f874d7603f9d2dfbf120203882b645d963" + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/ae9488f874d7603f9d2dfbf120203882b645d963", - "reference": "ae9488f874d7603f9d2dfbf120203882b645d963", + "url": "https://api.github.com/repos/symfony/string/zipball/39be2ad058a3c0bd558edca23e65f009865d75ff", + "reference": "39be2ad058a3c0bd558edca23e65f009865d75ff", "shasum": "" }, "require": { @@ -7283,7 +7828,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v8.0.8" + "source": "https://github.com/symfony/string/tree/v8.0.11" }, "funding": [ { @@ -7303,7 +7848,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "symfony/translation", @@ -8099,23 +8644,23 @@ }, { "name": "webmozart/assert", - "version": "1.12.1", + "version": "2.3.0", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68" + "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/9be6926d8b485f55b9229203f962b51ed377ba68", - "reference": "9be6926d8b485f55b9229203f962b51ed377ba68", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/eb0d790f735ba6cff25c683a85a1da0eadeff9e4", + "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4", "shasum": "" }, "require": { "ext-ctype": "*", "ext-date": "*", "ext-filter": "*", - "php": "^7.2 || ^8.0" + "php": "^8.2" }, "suggest": { "ext-intl": "", @@ -8125,7 +8670,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.10-dev" + "dev-feature/2-0": "2.0-dev" } }, "autoload": { @@ -8141,6 +8686,10 @@ { "name": "Bernhard Schussek", "email": "bschussek@gmail.com" + }, + { + "name": "Woody Gilk", + "email": "woody.gilk@gmail.com" } ], "description": "Assertions to validate method input/output with nice error messages.", @@ -8151,9 +8700,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/1.12.1" + "source": "https://github.com/webmozarts/assert/tree/2.3.0" }, - "time": "2025-10-29T15:56:20+00:00" + "time": "2026-04-11T10:33:05+00:00" } ], "packages-dev": [ @@ -8250,6 +8799,146 @@ ], "time": "2026-03-29T15:46:14+00:00" }, + { + "name": "captainhook/captainhook", + "version": "5.29.2", + "source": { + "type": "git", + "url": "https://github.com/captainhook-git/captainhook.git", + "reference": "805d44b0de8ed143f4acfb8c6bcb788cdbc42963" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/captainhook-git/captainhook/zipball/805d44b0de8ed143f4acfb8c6bcb788cdbc42963", + "reference": "805d44b0de8ed143f4acfb8c6bcb788cdbc42963", + "shasum": "" + }, + "require": { + "captainhook/secrets": "^0.9.4", + "ext-json": "*", + "ext-spl": "*", + "ext-xml": "*", + "php": ">=8.0", + "sebastianfeldmann/camino": "^0.9.2", + "sebastianfeldmann/cli": "^3.3", + "sebastianfeldmann/git": "^3.16.0", + "symfony/console": "^2.7 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0", + "symfony/filesystem": "^2.7 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0", + "symfony/process": "^2.7 || ^3.0 || ^4.0 || ^5.0 || ^6.0 || ^7.0 || ^8.0" + }, + "replace": { + "sebastianfeldmann/captainhook": "*" + }, + "require-dev": { + "composer/composer": "~1 || ^2.0", + "mikey179/vfsstream": "~1" + }, + "bin": [ + "bin/captainhook" + ], + "type": "library", + "extra": { + "captainhook": { + "config": "captainhook.json" + }, + "branch-alias": { + "dev-main": "6.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "CaptainHook\\App\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "description": "PHP git hook manager", + "homepage": "https://php.captainhook.info/", + "keywords": [ + "commit-msg", + "git", + "hooks", + "post-merge", + "pre-commit", + "pre-push", + "prepare-commit-msg" + ], + "support": { + "issues": "https://github.com/captainhook-git/captainhook/issues", + "source": "https://github.com/captainhook-git/captainhook/tree/5.29.2" + }, + "funding": [ + { + "url": "https://github.com/sponsors/sebastianfeldmann", + "type": "github" + } + ], + "time": "2026-03-25T19:32:25+00:00" + }, + { + "name": "captainhook/secrets", + "version": "0.9.7", + "source": { + "type": "git", + "url": "https://github.com/captainhook-git/secrets.git", + "reference": "d62c97f75f81ac98e22f1c282482bd35fa82f631" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/captainhook-git/secrets/zipball/d62c97f75f81ac98e22f1c282482bd35fa82f631", + "reference": "d62c97f75f81ac98e22f1c282482bd35fa82f631", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "CaptainHook\\Secrets\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "description": "Utility classes to detect secrets", + "keywords": [ + "commit-msg", + "keys", + "passwords", + "post-merge", + "prepare-commit-msg", + "secrets", + "tokens" + ], + "support": { + "issues": "https://github.com/captainhook-git/secrets/issues", + "source": "https://github.com/captainhook-git/secrets/tree/0.9.7" + }, + "funding": [ + { + "url": "https://github.com/sponsors/sebastianfeldmann", + "type": "github" + } + ], + "time": "2025-04-08T07:10:48+00:00" + }, { "name": "composer/pcre", "version": "3.3.2", @@ -8641,6 +9330,47 @@ }, "time": "2025-04-30T06:54:44+00:00" }, + { + "name": "iamcal/sql-parser", + "version": "v0.7", + "source": { + "type": "git", + "url": "https://github.com/iamcal/SQLParser.git", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/iamcal/SQLParser/zipball/610392f38de49a44dab08dc1659960a29874c4b8", + "reference": "610392f38de49a44dab08dc1659960a29874c4b8", + "shasum": "" + }, + "require-dev": { + "php-coveralls/php-coveralls": "^1.0", + "phpunit/phpunit": "^5|^6|^7|^8|^9" + }, + "type": "library", + "autoload": { + "psr-4": { + "iamcal\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Cal Henderson", + "email": "cal@iamcal.com" + } + ], + "description": "MySQL schema parser", + "support": { + "issues": "https://github.com/iamcal/SQLParser/issues", + "source": "https://github.com/iamcal/SQLParser/tree/v0.7" + }, + "time": "2026-01-28T22:20:33+00:00" + }, { "name": "jean85/pretty-package-versions", "version": "2.1.1", @@ -8701,6 +9431,237 @@ }, "time": "2025-03-19T14:43:43+00:00" }, + { + "name": "laradumps/laradumps", + "version": "v5.3.0", + "source": { + "type": "git", + "url": "https://github.com/laradumps/laradumps.git", + "reference": "22ac20bb88e960eb1f68535958adefbc963074ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laradumps/laradumps/zipball/22ac20bb88e960eb1f68535958adefbc963074ce", + "reference": "22ac20bb88e960eb1f68535958adefbc963074ce", + "shasum": "" + }, + "require": { + "illuminate/mail": "^11.0 || ^12.0 || ^13.0", + "illuminate/support": "^11.0 || ^12.0 || ^13.0", + "laradumps/laradumps-core": "^4.0.3", + "php": "^8.2" + }, + "require-dev": { + "larastan/larastan": "^3.8", + "laravel/framework": "^11.0 || ^12.0 || ^13.0", + "laravel/pint": "^1.26.0", + "mockery/mockery": "^1.6.12", + "orchestra/testbench-core": "^9.4 || ^10.0 || ^11.0", + "pestphp/pest": "^3.7.0 || ^4.0.0", + "symfony/var-dumper": "^7.1.3|^8.0" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "LaraDumps\\LaraDumps\\LaraDumpsServiceProvider" + ] + } + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "LaraDumps\\LaraDumps\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Luan Freitas", + "email": "luanfreitas10@protonmail.com", + "role": "Developer" + } + ], + "description": "LaraDumps is a friendly app designed to boost your Laravel PHP coding and debugging experience.", + "homepage": "https://github.com/laradumps/laradumps", + "support": { + "issues": "https://github.com/laradumps/laradumps/issues", + "source": "https://github.com/laradumps/laradumps/tree/v5.3.0" + }, + "funding": [ + { + "url": "https://github.com/luanfreitasdev", + "type": "github" + } + ], + "time": "2026-03-25T12:27:54+00:00" + }, + { + "name": "laradumps/laradumps-core", + "version": "v4.0.6", + "source": { + "type": "git", + "url": "https://github.com/laradumps/laradumps-core.git", + "reference": "41acc4a0dba81232287bed4d98de112b1c466244" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laradumps/laradumps-core/zipball/41acc4a0dba81232287bed4d98de112b1c466244", + "reference": "41acc4a0dba81232287bed4d98de112b1c466244", + "shasum": "" + }, + "require": { + "ext-curl": "*", + "php": "^8.2", + "ramsey/uuid": "^4.9.1", + "spatie/backtrace": "^1.5", + "symfony/console": "^6.4|^7.0|^8.0", + "symfony/finder": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", + "symfony/yaml": "^6.4|^7.0|^8.0" + }, + "require-dev": { + "illuminate/support": "^12", + "laravel/pint": "^1.26.0", + "pestphp/pest": "^3.0|^4.0", + "phpstan/phpstan": "^1.10.50" + }, + "suggest": { + "nunomaduro/termwind": "For a better terminal experience" + }, + "bin": [ + "bin/laradumps" + ], + "type": "library", + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "LaraDumps\\LaraDumpsCore\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Luan Freitas", + "email": "luanfreitas10@protonmail.com", + "role": "Developer" + } + ], + "description": "LaraDumps is a friendly app designed to boost your Laravel / PHP coding and debugging experience.", + "homepage": "https://github.com/laradumps/laradumps-core", + "support": { + "issues": "https://github.com/laradumps/laradumps-core/issues", + "source": "https://github.com/laradumps/laradumps-core/tree/v4.0.6" + }, + "funding": [ + { + "url": "https://github.com/luanfreitasdev", + "type": "github" + } + ], + "time": "2026-03-19T15:05:22+00:00" + }, + { + "name": "larastan/larastan", + "version": "v3.9.6", + "source": { + "type": "git", + "url": "https://github.com/larastan/larastan.git", + "reference": "9ad17e83e96b63536cb6ac39c3d40d29ff9cf636" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/larastan/larastan/zipball/9ad17e83e96b63536cb6ac39c3d40d29ff9cf636", + "reference": "9ad17e83e96b63536cb6ac39c3d40d29ff9cf636", + "shasum": "" + }, + "require": { + "ext-json": "*", + "iamcal/sql-parser": "^0.7.0", + "illuminate/console": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/container": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/contracts": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/database": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/http": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/pipeline": "^11.44.2 || ^12.4.1 || ^13", + "illuminate/support": "^11.44.2 || ^12.4.1 || ^13", + "php": "^8.2", + "phpstan/phpstan": "^2.1.44" + }, + "require-dev": { + "doctrine/coding-standard": "^13", + "laravel/framework": "^11.44.2 || ^12.7.2 || ^13", + "mockery/mockery": "^1.6.12", + "nikic/php-parser": "^5.4", + "orchestra/canvas": "^v9.2.2 || ^10.0.1 || ^11", + "orchestra/testbench-core": "^9.12.0 || ^10.1 || ^11", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpunit/phpunit": "^10.5.35 || ^11.5.15 || ^12.5.8" + }, + "suggest": { + "orchestra/testbench": "Using Larastan for analysing a package needs Testbench", + "phpmyadmin/sql-parser": "Install to enable Larastan's optional phpMyAdmin-based SQL parser automatically" + }, + "type": "phpstan-extension", + "extra": { + "phpstan": { + "includes": [ + "extension.neon" + ] + }, + "branch-alias": { + "dev-master": "3.0-dev" + } + }, + "autoload": { + "psr-4": { + "Larastan\\Larastan\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Can Vural", + "email": "can9119@gmail.com" + } + ], + "description": "Larastan - Discover bugs in your code without running it. A phpstan/phpstan extension for Laravel", + "keywords": [ + "PHPStan", + "code analyse", + "code analysis", + "larastan", + "laravel", + "package", + "php", + "static analysis" + ], + "support": { + "issues": "https://github.com/larastan/larastan/issues", + "source": "https://github.com/larastan/larastan/tree/v3.9.6" + }, + "funding": [ + { + "url": "https://github.com/canvural", + "type": "github" + } + ], + "time": "2026-04-16T10:02:43+00:00" + }, { "name": "laravel/agent-detector", "version": "v2.0.2", @@ -9197,16 +10158,16 @@ }, { "name": "laravel/sail", - "version": "v1.58.0", + "version": "v1.59.0", "source": { "type": "git", "url": "https://github.com/laravel/sail.git", - "reference": "2e5e968138ca52ed87d712449697a8364d73b466" + "reference": "a41abad557e487eaefde6c9873085ed086fdf47a" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/sail/zipball/2e5e968138ca52ed87d712449697a8364d73b466", - "reference": "2e5e968138ca52ed87d712449697a8364d73b466", + "url": "https://api.github.com/repos/laravel/sail/zipball/a41abad557e487eaefde6c9873085ed086fdf47a", + "reference": "a41abad557e487eaefde6c9873085ed086fdf47a", "shasum": "" }, "require": { @@ -9256,7 +10217,7 @@ "issues": "https://github.com/laravel/sail/issues", "source": "https://github.com/laravel/sail" }, - "time": "2026-04-27T13:38:34+00:00" + "time": "2026-05-13T14:02:20+00:00" }, { "name": "mockery/mockery", @@ -10080,6 +11041,59 @@ }, "time": "2022-02-21T01:04:05+00:00" }, + { + "name": "phpstan/phpstan", + "version": "2.1.54", + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpstan/phpstan/zipball/8be50c3992107dc837b17da4d140fbbdf9a5c5bd", + "reference": "8be50c3992107dc837b17da4d140fbbdf9a5c5bd", + "shasum": "" + }, + "require": { + "php": "^7.4|^8.0" + }, + "conflict": { + "phpstan/phpstan-shim": "*" + }, + "bin": [ + "phpstan", + "phpstan.phar" + ], + "type": "library", + "autoload": { + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PHPStan - PHP Static Analysis Tool", + "keywords": [ + "dev", + "static analysis" + ], + "support": { + "docs": "https://phpstan.org/user-guide/getting-started", + "forum": "https://github.com/phpstan/phpstan/discussions", + "issues": "https://github.com/phpstan/phpstan/issues", + "security": "https://github.com/phpstan/phpstan/security/policy", + "source": "https://github.com/phpstan/phpstan-src" + }, + "funding": [ + { + "url": "https://github.com/ondrejmirtes", + "type": "github" + }, + { + "url": "https://github.com/phpstan", + "type": "github" + } + ], + "time": "2026-04-29T13:31:09+00:00" + }, { "name": "phpunit/php-code-coverage", "version": "12.5.6", @@ -11412,6 +12426,246 @@ ], "time": "2025-02-07T05:00:38+00:00" }, + { + "name": "sebastianfeldmann/camino", + "version": "0.9.5", + "source": { + "type": "git", + "url": "https://github.com/sebastianfeldmann/camino.git", + "reference": "bf2e4c8b2a029e9eade43666132b61331e3e8184" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianfeldmann/camino/zipball/bf2e4c8b2a029e9eade43666132b61331e3e8184", + "reference": "bf2e4c8b2a029e9eade43666132b61331e3e8184", + "shasum": "" + }, + "require": { + "php": ">=7.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "SebastianFeldmann\\Camino\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "description": "Path management the OO way", + "homepage": "https://github.com/sebastianfeldmann/camino", + "keywords": [ + "file system", + "path" + ], + "support": { + "issues": "https://github.com/sebastianfeldmann/camino/issues", + "source": "https://github.com/sebastianfeldmann/camino/tree/0.9.5" + }, + "funding": [ + { + "url": "https://github.com/sebastianfeldmann", + "type": "github" + } + ], + "time": "2022-01-03T13:15:10+00:00" + }, + { + "name": "sebastianfeldmann/cli", + "version": "3.4.2", + "source": { + "type": "git", + "url": "https://github.com/sebastianfeldmann/cli.git", + "reference": "6fa122afd528dae7d7ec988a604aa6c600f5d9b5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianfeldmann/cli/zipball/6fa122afd528dae7d7ec988a604aa6c600f5d9b5", + "reference": "6fa122afd528dae7d7ec988a604aa6c600f5d9b5", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "require-dev": { + "symfony/process": "^4.3 | ^5.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "3.4.x-dev" + } + }, + "autoload": { + "psr-4": { + "SebastianFeldmann\\Cli\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "description": "PHP cli helper classes", + "homepage": "https://github.com/sebastianfeldmann/cli", + "keywords": [ + "cli" + ], + "support": { + "issues": "https://github.com/sebastianfeldmann/cli/issues", + "source": "https://github.com/sebastianfeldmann/cli/tree/3.4.2" + }, + "funding": [ + { + "url": "https://github.com/sebastianfeldmann", + "type": "github" + } + ], + "time": "2024-11-26T10:19:01+00:00" + }, + { + "name": "sebastianfeldmann/git", + "version": "3.16.0", + "source": { + "type": "git", + "url": "https://github.com/sebastianfeldmann/git.git", + "reference": "40a5cc043f0957228767f639e370ec92590e940f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/sebastianfeldmann/git/zipball/40a5cc043f0957228767f639e370ec92590e940f", + "reference": "40a5cc043f0957228767f639e370ec92590e940f", + "shasum": "" + }, + "require": { + "ext-json": "*", + "ext-libxml": "*", + "ext-simplexml": "*", + "php": ">=8.0", + "sebastianfeldmann/cli": "^3.0" + }, + "require-dev": { + "mikey179/vfsstream": "^1.6" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "4.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "SebastianFeldmann\\Git\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Sebastian Feldmann", + "email": "sf@sebastian-feldmann.info" + } + ], + "description": "PHP git wrapper", + "homepage": "https://github.com/sebastianfeldmann/git", + "keywords": [ + "git" + ], + "support": { + "issues": "https://github.com/sebastianfeldmann/git/issues", + "source": "https://github.com/sebastianfeldmann/git/tree/3.16.0" + }, + "funding": [ + { + "url": "https://github.com/sebastianfeldmann", + "type": "github" + } + ], + "time": "2026-01-26T20:59:18+00:00" + }, + { + "name": "spatie/backtrace", + "version": "1.8.2", + "source": { + "type": "git", + "url": "https://github.com/spatie/backtrace.git", + "reference": "8ffe78be5ed355b5009e3dd989d183433e9a5adc" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/spatie/backtrace/zipball/8ffe78be5ed355b5009e3dd989d183433e9a5adc", + "reference": "8ffe78be5ed355b5009e3dd989d183433e9a5adc", + "shasum": "" + }, + "require": { + "php": "^7.3 || ^8.0" + }, + "require-dev": { + "ext-json": "*", + "laravel/serializable-closure": "^1.3 || ^2.0", + "phpunit/phpunit": "^9.3 || ^11.4.3", + "spatie/phpunit-snapshot-assertions": "^4.2 || ^5.1.6", + "symfony/var-dumper": "^5.1|^6.0|^7.0|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Spatie\\Backtrace\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Freek Van de Herten", + "email": "freek@spatie.be", + "homepage": "https://spatie.be", + "role": "Developer" + } + ], + "description": "A better backtrace", + "homepage": "https://github.com/spatie/backtrace", + "keywords": [ + "Backtrace", + "spatie" + ], + "support": { + "issues": "https://github.com/spatie/backtrace/issues", + "source": "https://github.com/spatie/backtrace/tree/1.8.2" + }, + "funding": [ + { + "url": "https://github.com/sponsors/spatie", + "type": "github" + }, + { + "url": "https://spatie.be/open-source/support-us", + "type": "other" + } + ], + "time": "2026-03-11T13:48:28+00:00" + }, { "name": "staabm/side-effects-detector", "version": "1.0.5", @@ -11465,17 +12719,87 @@ "time": "2024-10-20T05:08:20+00:00" }, { - "name": "symfony/yaml", - "version": "v8.0.8", + "name": "symfony/filesystem", + "version": "v8.0.11", "source": { "type": "git", - "url": "https://github.com/symfony/yaml.git", - "reference": "54174ab48c0c0f9e21512b304be17f8150ccf8f1" + "url": "https://github.com/symfony/filesystem.git", + "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/yaml/zipball/54174ab48c0c0f9e21512b304be17f8150ccf8f1", - "reference": "54174ab48c0c0f9e21512b304be17f8150ccf8f1", + "url": "https://api.github.com/repos/symfony/filesystem/zipball/224db910898ce1317b892a9a1338f1f8f17eb7c7", + "reference": "224db910898ce1317b892a9a1338f1f8f17eb7c7", + "shasum": "" + }, + "require": { + "php": ">=8.4", + "symfony/polyfill-ctype": "~1.8", + "symfony/polyfill-mbstring": "~1.8" + }, + "require-dev": { + "symfony/process": "^7.4|^8.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Filesystem\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Provides basic utilities for the filesystem", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/filesystem/tree/v8.0.11" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://github.com/nicolas-grekas", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2026-05-11T16:39:47+00:00" + }, + { + "name": "symfony/yaml", + "version": "v8.0.11", + "source": { + "type": "git", + "url": "https://github.com/symfony/yaml.git", + "reference": "48046fbd5567bd1717f278eaa2cfc3131f489984" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/yaml/zipball/48046fbd5567bd1717f278eaa2cfc3131f489984", + "reference": "48046fbd5567bd1717f278eaa2cfc3131f489984", "shasum": "" }, "require": { @@ -11517,7 +12841,7 @@ "description": "Loads and dumps YAML files", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/yaml/tree/v8.0.8" + "source": "https://github.com/symfony/yaml/tree/v8.0.11" }, "funding": [ { @@ -11537,7 +12861,7 @@ "type": "tidelift" } ], - "time": "2026-03-30T15:14:47+00:00" + "time": "2026-05-13T12:07:53+00:00" }, { "name": "ta-tikoma/phpunit-architecture-test", @@ -11655,7 +12979,7 @@ "prefer-stable": true, "prefer-lowest": false, "platform": { - "php": "^8.3" + "php": "^8.5" }, "platform-dev": {}, "plugin-api-version": "2.9.0" diff --git a/config/activitylog.php b/config/activitylog.php new file mode 100644 index 0000000..86316e7 --- /dev/null +++ b/config/activitylog.php @@ -0,0 +1,74 @@ + env('ACTIVITYLOG_ENABLED', true), + + /* + * When the clean command is executed, all recording activities older than + * the number of days specified here will be deleted. + */ + 'clean_after_days' => 365, + + /* + * If no log name is passed to the activity() helper + * we use this default log name. + */ + 'default_log_name' => 'default', + + /* + * You can specify an auth driver here that gets user models. + * If this is null we'll use the current Laravel auth driver. + */ + 'default_auth_driver' => null, + + /* + * If set to true, the subject relationship on activities + * will include soft deleted models. + */ + 'include_soft_deleted_subjects' => false, + + /* + * This model will be used to log activity. + * It should implement the Spatie\Activitylog\Contracts\Activity interface + * and extend Illuminate\Database\Eloquent\Model. + */ + 'activity_model' => Activity::class, + + /* + * These attributes will be excluded from logging for all models. + * Model-specific exclusions via logExcept() are merged with these. + */ + 'default_except_attributes' => [], + + /* + * When enabled, activities are buffered in memory and inserted in a + * single bulk query after the response has been sent to the client. + * This can significantly reduce the number of database queries when + * many activities are logged during a single request. + * + * Only enable this if your application logs a high volume of activities + * per request. Buffered activities will not have an ID until the + * buffer is flushed. + */ + 'buffer' => [ + 'enabled' => env('ACTIVITYLOG_BUFFER_ENABLED', false), + ], + + /* + * These action classes can be overridden to customize how activities + * are logged and cleaned. Your custom classes must extend the originals. + */ + 'actions' => [ + 'log_activity' => LogActivityAction::class, + 'clean_log' => CleanActivityLogAction::class, + ], +]; diff --git a/config/cache.php b/config/cache.php index 62c6b58..fe80299 100644 --- a/config/cache.php +++ b/config/cache.php @@ -114,7 +114,7 @@ | */ - 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')) . '-cache-'), + 'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'), /* |-------------------------------------------------------------------------- diff --git a/config/data.php b/config/data.php index 829b598..bad2d17 100644 --- a/config/data.php +++ b/config/data.php @@ -3,22 +3,11 @@ declare(strict_types=1); use Illuminate\Contracts\Support\Arrayable; -use Spatie\LaravelData\Casts\DateTimeInterfaceCast; -use Spatie\LaravelData\Casts\EnumCast; -use Spatie\LaravelData\Normalizers\ArrayableNormalizer; -use Spatie\LaravelData\Normalizers\ArrayNormalizer; -use Spatie\LaravelData\Normalizers\JsonNormalizer; -use Spatie\LaravelData\Normalizers\ModelNormalizer; -use Spatie\LaravelData\Normalizers\ObjectNormalizer; -use Spatie\LaravelData\RuleInferrers\AttributesRuleInferrer; -use Spatie\LaravelData\RuleInferrers\BuiltInTypesRuleInferrer; -use Spatie\LaravelData\RuleInferrers\NullableRuleInferrer; -use Spatie\LaravelData\RuleInferrers\RequiredRuleInferrer; -use Spatie\LaravelData\RuleInferrers\SometimesRuleInferrer; +use Spatie\LaravelData\Casts\{DateTimeInterfaceCast, EnumCast}; +use Spatie\LaravelData\Normalizers\{ArrayNormalizer, ArrayableNormalizer, JsonNormalizer, ModelNormalizer, ObjectNormalizer}; +use Spatie\LaravelData\RuleInferrers\{AttributesRuleInferrer, BuiltInTypesRuleInferrer, NullableRuleInferrer, RequiredRuleInferrer, SometimesRuleInferrer}; use Spatie\LaravelData\Support\Creation\ValidationStrategy; -use Spatie\LaravelData\Transformers\ArrayableTransformer; -use Spatie\LaravelData\Transformers\DateTimeInterfaceTransformer; -use Spatie\LaravelData\Transformers\EnumTransformer; +use Spatie\LaravelData\Transformers\{ArrayableTransformer, DateTimeInterfaceTransformer, EnumTransformer}; return [ /* @@ -182,14 +171,12 @@ * the data classes. You can override these settings by passing options to the command. */ 'commands' => [ - /* * Provides default configuration for the `make:data` command. These settings can be overridden with options * passed directly to the `make:data` command for generating single Data classes, or if not set they will * automatically fall back to these defaults. See `php artisan make:data --help` for more information */ 'make' => [ - /* * The default namespace for generated Data classes. This exists under the application's root namespace, * so the default 'Data` will end up as '\App\Data', and generated Data classes will be placed in the @@ -212,6 +199,6 @@ * properties when used in a Livewire component. */ 'livewire' => [ - 'enable_synths' => false, + 'enable_synths' => true, ], ]; diff --git a/config/database.php b/config/database.php index df64d23..d588717 100644 --- a/config/database.php +++ b/config/database.php @@ -151,7 +151,7 @@ 'options' => [ 'cluster' => env('REDIS_CLUSTER', 'redis'), - 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')) . '-database-'), + 'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'), 'persistent' => env('REDIS_PERSISTENT', false), ], diff --git a/config/filesystems.php b/config/filesystems.php index 7d2bbbe..eeaedcd 100644 --- a/config/filesystems.php +++ b/config/filesystems.php @@ -43,7 +43,7 @@ 'public' => [ 'driver' => 'local', 'root' => storage_path('app/public'), - 'url' => mb_rtrim(env('APP_URL', 'http://localhost'), '/') . '/storage', + 'url' => mb_rtrim(env('APP_URL', 'http://localhost'), '/').'/storage', 'visibility' => 'public', 'throw' => false, 'report' => false, diff --git a/config/livewire.php b/config/livewire.php new file mode 100644 index 0000000..56fdef7 --- /dev/null +++ b/config/livewire.php @@ -0,0 +1,284 @@ + [ + resource_path('views/components'), + resource_path('views/livewire'), + ], + + /* + |--------------------------------------------------------------------------- + | Component Namespaces + |--------------------------------------------------------------------------- + | + | This value sets default namespaces that will be used to resolve view-based + | components like single-file and multi-file components. These folders'll + | also be referenced when creating new components via the make command. + | + */ + + 'component_namespaces' => [ + 'layouts' => resource_path('views/layouts'), + 'pages' => resource_path('views/pages'), + ], + + /* + |--------------------------------------------------------------------------- + | Page Layout + |--------------------------------------------------------------------------- + | The view that will be used as the layout when rendering a single component as + | an entire page via `Route::livewire('/post/create', 'pages::create-post')`. + | In this case, the content of pages::create-post will render into $slot. + | + */ + + 'component_layout' => 'layouts::app.sidebar', + + /* + |--------------------------------------------------------------------------- + | Lazy Loading Placeholder + |--------------------------------------------------------------------------- + | Livewire allows you to lazy load components that would otherwise slow down + | the initial page load. Every component can have a custom placeholder or + | you can define the default placeholder view for all components below. + | + */ + + 'component_placeholder' => null, // Example: 'placeholders::skeleton' + + /* + |--------------------------------------------------------------------------- + | Make Command + |--------------------------------------------------------------------------- + | This value determines the default configuration for the artisan make command + | You can configure the component type (sfc, mfc, class) and whether to use + | the high-voltage (⚡) emoji as a prefix in the sfc|mfc component names. + | + */ + + 'make_command' => [ + 'type' => 'sfc', // Options: 'sfc', 'mfc', 'class' + 'emoji' => true, // Options: true, false + 'with' => [ + 'js' => false, + 'css' => false, + 'test' => false, + ], + ], + + /* + |--------------------------------------------------------------------------- + | Class Namespace + |--------------------------------------------------------------------------- + | + | This value sets the root class namespace for Livewire component classes in + | your application. This value will change where component auto-discovery + | finds components. It's also referenced by the file creation commands. + | + */ + + 'class_namespace' => 'App\\Livewire', + + /* + |--------------------------------------------------------------------------- + | Class Path + |--------------------------------------------------------------------------- + | + | This value is used to specify the path where Livewire component class files + | are created when running creation commands like `artisan make:livewire`. + | This path is customizable to match your projects directory structure. + | + */ + + 'class_path' => app_path('Livewire'), + + /* + |--------------------------------------------------------------------------- + | View Path + |--------------------------------------------------------------------------- + | + | This value is used to specify where Livewire component Blade templates are + | stored when running file creation commands like `artisan make:livewire`. + | It is also used if you choose to omit a component's render() method. + | + */ + + 'view_path' => resource_path('views/livewire'), + + /* + |--------------------------------------------------------------------------- + | Temporary File Uploads + |--------------------------------------------------------------------------- + | + | Livewire handles file uploads by storing uploads in a temporary directory + | before the file is stored permanently. All file uploads are directed to + | a global endpoint for temporary storage. You may configure this below: + | + */ + + 'temporary_file_upload' => [ + 'disk' => env('LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK'), // Example: 'local', 's3' | Default: 'default' + 'rules' => null, // Example: ['file', 'mimes:png,jpg'] | Default: ['required', 'file', 'max:12288'] (12MB) + 'directory' => null, // Example: 'tmp' | Default: 'livewire-tmp' + 'middleware' => null, // Example: 'throttle:5,1' | Default: 'throttle:60,1' + 'preview_mimes' => [ // Supported file types for temporary pre-signed file URLs... + 'png', 'gif', 'bmp', 'svg', 'wav', 'mp4', + 'mov', 'avi', 'wmv', 'mp3', 'm4a', + 'jpg', 'jpeg', 'mpga', 'webp', 'wma', + ], + 'max_upload_time' => 5, // Max duration (in minutes) before an upload is invalidated... + 'cleanup' => true, // Should cleanup temporary uploads older than 24 hrs... + ], + + /* + |--------------------------------------------------------------------------- + | Render On Redirect + |--------------------------------------------------------------------------- + | + | This value determines if Livewire will run a component's `render()` method + | after a redirect has been triggered using something like `redirect(...)` + | Setting this to true will render the view once more before redirecting + | + */ + + 'render_on_redirect' => false, + + /* + |--------------------------------------------------------------------------- + | Eloquent Model Binding + |--------------------------------------------------------------------------- + | + | Previous versions of Livewire supported binding directly to eloquent model + | properties using wire:model by default. However, this behavior has been + | deemed too "magical" and has therefore been put under a feature flag. + | + */ + + 'legacy_model_binding' => false, + + /* + |--------------------------------------------------------------------------- + | Auto-inject Frontend Assets + |--------------------------------------------------------------------------- + | + | By default, Livewire automatically injects its JavaScript and CSS into the + | and of pages containing Livewire components. By disabling + | this behavior, you need to use @livewireStyles and @livewireScripts. + | + */ + + 'inject_assets' => true, + + /* + |--------------------------------------------------------------------------- + | Navigate (SPA mode) + |--------------------------------------------------------------------------- + | + | By adding `wire:navigate` to links in your Livewire application, Livewire + | will prevent the default link handling and instead request those pages + | via AJAX, creating an SPA-like effect. Configure this behavior here. + | + */ + + 'navigate' => [ + 'show_progress_bar' => true, + 'progress_bar_color' => '#2299dd', + ], + + /* + |--------------------------------------------------------------------------- + | HTML Morph Markers + |--------------------------------------------------------------------------- + | + | Livewire intelligently "morphs" existing HTML into the newly rendered HTML + | after each update. To make this process more reliable, Livewire injects + | "markers" into the rendered Blade surrounding @if, @class & @foreach. + | + */ + + 'inject_morph_markers' => true, + + /* + |--------------------------------------------------------------------------- + | Smart Wire Keys + |--------------------------------------------------------------------------- + | + | Livewire uses loops and keys used within loops to generate smart keys that + | are applied to nested components that don't have them. This makes using + | nested components more reliable by ensuring that they all have keys. + | + */ + + 'smart_wire_keys' => true, + + /* + |--------------------------------------------------------------------------- + | Pagination Theme + |--------------------------------------------------------------------------- + | + | When enabling Livewire's pagination feature by using the `WithPagination` + | trait, Livewire will use Tailwind templates to render pagination views + | on the page. If you want Bootstrap CSS, you can specify: "bootstrap" + | + */ + + 'pagination_theme' => 'tailwind', + + /* + |--------------------------------------------------------------------------- + | Release Token + |--------------------------------------------------------------------------- + | + | This token is stored client-side and sent along with each request to check + | a users session to see if a new release has invalidated it. If there is + | a mismatch it will throw an error and prompt for a browser refresh. + | + */ + + 'release_token' => 'a', + + /* + |--------------------------------------------------------------------------- + | CSP Safe + |--------------------------------------------------------------------------- + | + | This config is used to determine if Livewire will use the CSP-safe version + | of Alpine in its bundle. This is useful for applications that are using + | strict Content Security Policy (CSP) to protect against XSS attacks. + | + */ + + 'csp_safe' => false, + + /* + |--------------------------------------------------------------------------- + | Payload Guards + |--------------------------------------------------------------------------- + | + | These settings protect against malicious or oversized payloads that could + | cause denial of service. The default values should feel reasonable for + | most web applications. Each can be set to null to disable the limit. + | + */ + + 'payload' => [ + 'max_size' => 1024 * 1024, // 1MB - maximum request payload size in bytes + 'max_nesting_depth' => 10, // Maximum depth of dot-notation property paths + 'max_calls' => 50, // Maximum method calls per request + 'max_components' => 200, // Maximum components per batch request + ], +]; diff --git a/config/logging.php b/config/logging.php index 7243435..a001964 100644 --- a/config/logging.php +++ b/config/logging.php @@ -2,9 +2,7 @@ declare(strict_types=1); -use Monolog\Handler\NullHandler; -use Monolog\Handler\StreamHandler; -use Monolog\Handler\SyslogUdpHandler; +use Monolog\Handler\{NullHandler, StreamHandler, SyslogUdpHandler}; use Monolog\Processor\PsrLogMessageProcessor; return [ @@ -91,7 +89,7 @@ 'handler_with' => [ 'host' => env('PAPERTRAIL_URL'), 'port' => env('PAPERTRAIL_PORT'), - 'connectionString' => 'tls://' . env('PAPERTRAIL_URL') . ':' . env('PAPERTRAIL_PORT'), + 'connectionString' => 'tls://'.env('PAPERTRAIL_URL').':'.env('PAPERTRAIL_PORT'), ], 'processors' => [PsrLogMessageProcessor::class], ], diff --git a/config/mary.php b/config/mary.php new file mode 100644 index 0000000..0324934 --- /dev/null +++ b/config/mary.php @@ -0,0 +1,46 @@ + '' + * + * + * + * prefix => 'mary-' + * + * + */ + 'prefix' => 'mary-', + + /** + * Default route prefix. + * + * Some maryUI components make network request to its internal routes. + * + * route_prefix => '' + * - Spotlight: '/mary/spotlight' + * - Editor: '/mary/upload' + * - ... + * + * route_prefix => 'my-components' + * - Spotlight: '/my-components/mary/spotlight' + * - Editor: '/my-components/mary/upload' + * - ... + */ + 'route_prefix' => '', + + /** + * Components settings + */ + 'components' => [ + 'spotlight' => [ + 'class' => 'App\Support\Spotlight', + ], + ], +]; diff --git a/config/permission.php b/config/permission.php new file mode 100644 index 0000000..4f2808e --- /dev/null +++ b/config/permission.php @@ -0,0 +1,221 @@ + [ + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * Eloquent model should be used to retrieve your permissions. Of course, it + * is often just the "Permission" model but you may use whatever you like. + * + * The model you want to use as a Permission model needs to implement the + * `Spatie\Permission\Contracts\Permission` contract. + */ + + 'permission' => Permission::class, + + /* + * When using the "HasRoles" trait from this package, we need to know which + * Eloquent model should be used to retrieve your roles. Of course, it + * is often just the "Role" model but you may use whatever you like. + * + * The model you want to use as a Role model needs to implement the + * `Spatie\Permission\Contracts\Role` contract. + */ + + 'role' => Role::class, + + /* + * When using the "Teams" feature from this package, we need to know which + * Eloquent model should be used to retrieve your teams. Of course, it + * is often just the "Team" model but you may use whatever you like. + */ + 'team' => null, + + /* + * When using the "HasModels" trait and passing raw IDs to syncModels, + * attachModels, or detachModels, this model class will be used to + * resolve those IDs. If null, defaults to the guard's model. + */ + 'default_model' => null, + ], + + 'table_names' => [ + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'roles' => 'roles', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your permissions. We have chosen a basic + * default value but you may easily change it to any table you like. + */ + + 'permissions' => 'permissions', + + /* + * When using the "HasPermissions" trait from this package, we need to know which + * table should be used to retrieve your models permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'model_has_permissions' => 'model_has_permissions', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your models roles. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'model_has_roles' => 'model_has_roles', + + /* + * When using the "HasRoles" trait from this package, we need to know which + * table should be used to retrieve your roles permissions. We have chosen a + * basic default value but you may easily change it to any table you like. + */ + + 'role_has_permissions' => 'role_has_permissions', + ], + + 'column_names' => [ + /* + * Change this if you want to name the related pivots other than defaults + */ + 'role_pivot_key' => null, // default 'role_id', + 'permission_pivot_key' => null, // default 'permission_id', + + /* + * Change this if you want to name the related model primary key other than + * `model_id`. + * + * For example, this would be nice if your primary keys are all UUIDs. In + * that case, name this `model_uuid`. + */ + + 'model_morph_key' => 'model_id', + + /* + * Change this if you want to use the teams feature and your related model's + * foreign key is other than `team_id`. + */ + + 'team_foreign_key' => 'team_id', + ], + + /* + * When set to true, the method for checking permissions will be registered on the gate. + * Set this to false if you want to implement custom logic for checking permissions. + */ + + 'register_permission_check_method' => true, + + /* + * When set to true, Laravel\Octane\Events\OperationTerminated event listener will be registered + * this will refresh permissions on every TickTerminated, TaskTerminated and RequestTerminated + * NOTE: This should not be needed in most cases, but an Octane/Vapor combination benefited from it. + */ + 'register_octane_reset_listener' => false, + + /* + * Events will fire when a role or permission is assigned/unassigned: + * \Spatie\Permission\Events\RoleAttachedEvent + * \Spatie\Permission\Events\RoleDetachedEvent + * \Spatie\Permission\Events\PermissionAttachedEvent + * \Spatie\Permission\Events\PermissionDetachedEvent + * + * To enable, set to true, and then create listeners to watch these events. + */ + 'events_enabled' => false, + + /* + * Teams Feature. + * When set to true the package implements teams using the 'team_foreign_key'. + * If you want the migrations to register the 'team_foreign_key', you must + * set this to true before doing the migration. + * If you already did the migration then you must make a new migration to also + * add 'team_foreign_key' to 'roles', 'model_has_roles', and 'model_has_permissions' + * (view the latest version of this package's migration file) + */ + + 'teams' => false, + + /* + * The class to use to resolve the permissions team id + */ + 'team_resolver' => DefaultTeamResolver::class, + + /* + * Passport Client Credentials Grant + * When set to true the package will use Passports Client to check permissions + */ + + 'use_passport_client_credentials' => false, + + /* + * When set to true, the required permission names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. + */ + + 'display_permission_in_exception' => false, + + /* + * When set to true, the required role names are added to exception messages. + * This could be considered an information leak in some contexts, so the default + * setting is false here for optimum safety. + */ + + 'display_role_in_exception' => false, + + /* + * By default wildcard permission lookups are disabled. + * See documentation to understand supported syntax. + */ + + 'enable_wildcard_permission' => false, + + /* + * The class to use for interpreting wildcard permissions. + * If you need to modify delimiters, override the class and specify its name here. + */ + // 'wildcard_permission' => Spatie\Permission\WildcardPermission::class, + + /* Cache-specific settings */ + + 'cache' => [ + + /* + * By default all permissions are cached for 24 hours to speed up performance. + * When permissions or roles are updated the cache is flushed automatically. + */ + + 'expiration_time' => DateInterval::createFromDateString('24 hours'), + + /* + * The cache key used to store all permissions. + */ + + 'key' => 'spatie.permission.cache', + + /* + * You may optionally indicate a specific cache driver to use for permission and + * role caching using any of the `store` drivers listed in the cache.php config + * file. Using 'default' here means to use the `default` set in cache.php. + */ + + 'store' => 'default', + ], +]; diff --git a/config/services.php b/config/services.php index 9adca09..ba69a61 100644 --- a/config/services.php +++ b/config/services.php @@ -37,4 +37,13 @@ ], ], + 'microsoft_graph' => [ + 'tenant_id' => env('MICROSOFT_GRAPH_TENANT_ID'), + 'client_id' => env('MICROSOFT_GRAPH_CLIENT_ID'), + 'client_secret' => env('MICROSOFT_GRAPH_CLIENT_SECRET'), + 'domain_name' => env('MICROSOFT_GRAPH_DOMAIN_NAME'), + 'display_name' => env('MICROSOFT_GRAPH_DISPLAY_NAME', 'Company SSO'), + 'mfa_behavior' => env('MICROSOFT_GRAPH_MFA_BEHAVIOR', 'acceptIfMfaDoneByFederatedIdp'), + ], + ]; diff --git a/config/session.php b/config/session.php index e1ec101..27a721e 100644 --- a/config/session.php +++ b/config/session.php @@ -131,7 +131,7 @@ 'cookie' => env( 'SESSION_COOKIE', - Str::slug((string) env('APP_NAME', 'laravel')) . '-session', + Str::slug((string) env('APP_NAME', 'laravel')).'-session', ), /* diff --git a/database/factories/ConnectedAppFactory.php b/database/factories/ConnectedAppFactory.php new file mode 100644 index 0000000..9caf046 --- /dev/null +++ b/database/factories/ConnectedAppFactory.php @@ -0,0 +1,35 @@ + + */ +class ConnectedAppFactory extends Factory +{ + protected $model = ConnectedApp::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + $name = $this->faker->unique()->company().' '.$this->faker->randomElement(['SSO', 'Portal', 'OAuth', 'API', 'App']); + + return [ + 'name' => $name, + 'slug' => Str::slug($name), + 'connection_protocol_id' => ConnectionProtocol::inRandomOrder()->first()?->id ?? 1, + 'connection_provider_id' => ConnectionProvider::inRandomOrder()->first()?->id ?? 1, + 'connection_status_id' => ConnectionStatus::inRandomOrder()->first()?->id ?? 1, + ]; + } +} diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index 32d2c94..5d30b42 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -30,7 +30,7 @@ public function definition(): array 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), 'email_verified_at' => now(), - 'password' => static::$password ??= Hash::make('password'), + 'password' => self::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), 'two_factor_secret' => null, 'two_factor_recovery_codes' => null, @@ -43,7 +43,7 @@ public function definition(): array */ public function unverified(): static { - return $this->state(fn(array $attributes) => [ + return $this->state(fn (array $attributes) => [ 'email_verified_at' => null, ]); } @@ -53,7 +53,7 @@ public function unverified(): static */ public function withTwoFactor(): static { - return $this->state(fn(array $attributes) => [ + return $this->state(fn (array $attributes) => [ 'two_factor_secret' => encrypt('secret'), 'two_factor_recovery_codes' => encrypt(json_encode(['recovery-code-1'])), 'two_factor_confirmed_at' => now(), diff --git a/database/migrations/0001_01_01_000000_create_users_table.php b/database/migrations/0001_01_01_000000_create_users_table.php index cfc327e..ffca8c6 100644 --- a/database/migrations/0001_01_01_000000_create_users_table.php +++ b/database/migrations/0001_01_01_000000_create_users_table.php @@ -6,7 +6,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class () extends Migration { +return new class() extends Migration +{ /** * Run the migrations. */ @@ -20,6 +21,7 @@ public function up(): void $table->string('password'); $table->rememberToken(); $table->timestamps(); + $table->softDeletes(); }); Schema::create('password_reset_tokens', function (Blueprint $table): void { diff --git a/database/migrations/0001_01_01_000001_create_cache_table.php b/database/migrations/0001_01_01_000001_create_cache_table.php index 6c4fbae..e7ffbc3 100644 --- a/database/migrations/0001_01_01_000001_create_cache_table.php +++ b/database/migrations/0001_01_01_000001_create_cache_table.php @@ -6,7 +6,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class () extends Migration { +return new class() extends Migration +{ /** * Run the migrations. */ diff --git a/database/migrations/0001_01_01_000002_create_jobs_table.php b/database/migrations/0001_01_01_000002_create_jobs_table.php index cf71197..8074e1d 100644 --- a/database/migrations/0001_01_01_000002_create_jobs_table.php +++ b/database/migrations/0001_01_01_000002_create_jobs_table.php @@ -6,7 +6,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class () extends Migration { +return new class() extends Migration +{ /** * Run the migrations. */ diff --git a/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php b/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php index d2f589c..9a8078b 100644 --- a/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php +++ b/database/migrations/2025_08_14_170933_add_two_factor_columns_to_users_table.php @@ -6,7 +6,8 @@ use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -return new class () extends Migration { +return new class() extends Migration +{ /** * Run the migrations. */ diff --git a/database/migrations/2026_05_11_112605_create_permission_tables.php b/database/migrations/2026_05_11_112605_create_permission_tables.php new file mode 100644 index 0000000..8cf489f --- /dev/null +++ b/database/migrations/2026_05_11_112605_create_permission_tables.php @@ -0,0 +1,147 @@ +id(); // permission id + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + + $table->unique(['name', 'guard_name']); + }); + + /** + * See `docs/prerequisites.md` for suggested lengths on 'name' and 'guard_name' if "1071 Specified key was too long" errors are encountered. + */ + Schema::create($tableNames['roles'], static function (Blueprint $table) use ($teams, $columnNames): void { + $table->id(); // role id + if ($teams || config('permission.testing')) { // permission.testing is a fix for sqlite testing + $table->unsignedBigInteger($columnNames['team_foreign_key'])->nullable(); + $table->index($columnNames['team_foreign_key'], 'roles_team_foreign_key_index'); + } + $table->string('name'); + $table->string('guard_name'); + $table->timestamps(); + if ($teams || config('permission.testing')) { + $table->unique([$columnNames['team_foreign_key'], 'name', 'guard_name']); + } else { + $table->unique(['name', 'guard_name']); + } + }); + + Schema::create($tableNames['model_has_permissions'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotPermission, $teams): void { + $table->unsignedBigInteger($pivotPermission); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_permissions_model_id_model_type_index'); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_permissions_team_foreign_key_index'); + + $table->primary( + [$columnNames['team_foreign_key'], $pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary' + ); + } else { + $table->primary( + [$pivotPermission, $columnNames['model_morph_key'], 'model_type'], + 'model_has_permissions_permission_model_type_primary' + ); + } + }); + + Schema::create($tableNames['model_has_roles'], static function (Blueprint $table) use ($tableNames, $columnNames, $pivotRole, $teams): void { + $table->unsignedBigInteger($pivotRole); + + $table->string('model_type'); + $table->unsignedBigInteger($columnNames['model_morph_key']); + $table->index([$columnNames['model_morph_key'], 'model_type'], 'model_has_roles_model_id_model_type_index'); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->cascadeOnDelete(); + if ($teams) { + $table->unsignedBigInteger($columnNames['team_foreign_key']); + $table->index($columnNames['team_foreign_key'], 'model_has_roles_team_foreign_key_index'); + + $table->primary( + [$columnNames['team_foreign_key'], $pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary' + ); + } else { + $table->primary( + [$pivotRole, $columnNames['model_morph_key'], 'model_type'], + 'model_has_roles_role_model_type_primary' + ); + } + }); + + Schema::create($tableNames['role_has_permissions'], static function (Blueprint $table) use ($tableNames, $pivotRole, $pivotPermission): void { + $table->unsignedBigInteger($pivotPermission); + $table->unsignedBigInteger($pivotRole); + + $table->foreign($pivotPermission) + ->references('id') // permission id + ->on($tableNames['permissions']) + ->cascadeOnDelete(); + + $table->foreign($pivotRole) + ->references('id') // role id + ->on($tableNames['roles']) + ->cascadeOnDelete(); + + $table->primary([$pivotPermission, $pivotRole], 'role_has_permissions_permission_id_role_id_primary'); + }); + + app('cache') + ->store('default' !== config('permission.cache.store') ? config('permission.cache.store') : null) + ->forget(config('permission.cache.key')); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $tableNames = config('permission.table_names'); + + throw_if(empty($tableNames), 'Error: config/permission.php not found and defaults could not be merged. Please publish the package configuration before proceeding, or drop the tables manually.'); + + Schema::dropIfExists($tableNames['role_has_permissions']); + Schema::dropIfExists($tableNames['model_has_roles']); + Schema::dropIfExists($tableNames['model_has_permissions']); + Schema::dropIfExists($tableNames['roles']); + Schema::dropIfExists($tableNames['permissions']); + } +}; diff --git a/database/migrations/2026_05_11_120016_create_connection_providers_table.php b/database/migrations/2026_05_11_120016_create_connection_providers_table.php new file mode 100644 index 0000000..b6a8ceb --- /dev/null +++ b/database/migrations/2026_05_11_120016_create_connection_providers_table.php @@ -0,0 +1,32 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('connection_providers'); + } +}; diff --git a/database/migrations/2026_05_11_123840_create_connection_statuses_table.php b/database/migrations/2026_05_11_123840_create_connection_statuses_table.php new file mode 100644 index 0000000..5fc3345 --- /dev/null +++ b/database/migrations/2026_05_11_123840_create_connection_statuses_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('name')->unique(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('connection_statuses'); + } +}; diff --git a/database/migrations/2026_05_11_123849_create_connection_protocols_table.php b/database/migrations/2026_05_11_123849_create_connection_protocols_table.php new file mode 100644 index 0000000..cffe7d3 --- /dev/null +++ b/database/migrations/2026_05_11_123849_create_connection_protocols_table.php @@ -0,0 +1,31 @@ +id(); + $table->string('name')->unique(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('connection_protocols'); + } +}; diff --git a/database/migrations/2026_05_11_123900_create_connected_apps_table.php b/database/migrations/2026_05_11_123900_create_connected_apps_table.php new file mode 100644 index 0000000..9bc856a --- /dev/null +++ b/database/migrations/2026_05_11_123900_create_connected_apps_table.php @@ -0,0 +1,38 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->foreignIdFor(ConnectionProtocol::class); + $table->foreignIdFor(ConnectionProvider::class); + $table->foreignIdFor(ConnectionStatus::class); + $table->timestamps(); + $table->index(['name', 'slug']); + $table->index(['connection_protocol_id', 'connection_provider_id'], 'ca_protocol_provider_index'); + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('connected_apps'); + } +}; diff --git a/database/migrations/2026_05_15_115455_create_activity_log_table.php b/database/migrations/2026_05_15_115455_create_activity_log_table.php new file mode 100644 index 0000000..a5b77f5 --- /dev/null +++ b/database/migrations/2026_05_15_115455_create_activity_log_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('log_name')->nullable()->index(); + $table->text('description'); + $table->nullableMorphs('subject', 'subject'); + $table->string('event')->nullable(); + $table->nullableMorphs('causer', 'causer'); + $table->json('attribute_changes')->nullable(); + $table->json('properties')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('activity_log'); + } +}; diff --git a/database/migrations/2026_05_18_113635_app_roles.php b/database/migrations/2026_05_18_113635_app_roles.php new file mode 100644 index 0000000..e694e2a --- /dev/null +++ b/database/migrations/2026_05_18_113635_app_roles.php @@ -0,0 +1,33 @@ +id(); + $table->foreignIdFor(ConnectedApp::class, 'app_id'); + $table->foreignId('role_id')->comment('role_id from spatie roles table'); + $table->dateTime('duration'); + $table->unique(['app_id', 'role_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('app_roles'); + } +}; diff --git a/database/migrations/2026_05_20_093004_create_restricted_user_permissions_table.php b/database/migrations/2026_05_20_093004_create_restricted_user_permissions_table.php new file mode 100644 index 0000000..0eba0ac --- /dev/null +++ b/database/migrations/2026_05_20_093004_create_restricted_user_permissions_table.php @@ -0,0 +1,35 @@ +id(); + $table->foreignIdFor(Role::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(User::class)->constrained()->cascadeOnDelete(); + $table->foreignIdFor(Permission::class)->constrained()->cascadeOnDelete(); + $table->timestamps(); + $table->unique(['role_id', 'user_id', 'permission_id'], 'restricted_role_user_permission_unique'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('restricted_user_permissions'); + } +}; diff --git a/database/migrations/2026_05_25_050110_add_priority_to_roles_table.php b/database/migrations/2026_05_25_050110_add_priority_to_roles_table.php new file mode 100644 index 0000000..9b0d5a3 --- /dev/null +++ b/database/migrations/2026_05_25_050110_add_priority_to_roles_table.php @@ -0,0 +1,24 @@ +unsignedInteger('priority')->default(100)->after('guard_name'); + }); + } + + public function down(): void + { + Schema::table('roles', function (Blueprint $table): void { + $table->dropColumn('priority'); + }); + } +}; diff --git a/database/migrations/2026_05_25_105900_create_notifications_table.php b/database/migrations/2026_05_25_105900_create_notifications_table.php new file mode 100644 index 0000000..de8710d --- /dev/null +++ b/database/migrations/2026_05_25_105900_create_notifications_table.php @@ -0,0 +1,33 @@ +uuid('id')->primary(); + $table->string('type'); + $table->morphs('notifiable'); + $table->text('data'); + $table->timestamp('read_at')->nullable(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('notifications'); + } +}; diff --git a/database/migrations/2026_05_25_160000_create_ticket_statuses_table.php b/database/migrations/2026_05_25_160000_create_ticket_statuses_table.php new file mode 100644 index 0000000..fd59cc0 --- /dev/null +++ b/database/migrations/2026_05_25_160000_create_ticket_statuses_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('name')->unique(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ticket_statuses'); + } +}; diff --git a/database/migrations/2026_05_25_160001_create_tickets_table.php b/database/migrations/2026_05_25_160001_create_tickets_table.php new file mode 100644 index 0000000..8d0f1d8 --- /dev/null +++ b/database/migrations/2026_05_25_160001_create_tickets_table.php @@ -0,0 +1,63 @@ +id(); + $table->string('ticket_id')->unique()->index(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->foreignId('connected_app_id')->nullable()->constrained('connected_apps')->cascadeOnDelete(); + $table->string('issue_category'); + $table->text('description')->nullable(); + $table->foreignId('status_id')->constrained('ticket_statuses')->restrictOnDelete(); + $table->softDeletes(); + $table->timestamps(); + }); + + Schema::create('ticket_notified_roles', function (Blueprint $table): void { + $table->id(); + $table->foreignId('ticket_id')->constrained('tickets')->cascadeOnDelete(); + $table->foreignId('role_id')->comment('role_id from Spatie roles table'); + $table->unique(['ticket_id', 'role_id']); + }); + + Schema::create('ticket_notified_users', function (Blueprint $table): void { + $table->id(); + $table->foreignId('ticket_id')->constrained('tickets')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->unique(['ticket_id', 'user_id']); + }); + + Schema::create('ticket_attachments', function (Blueprint $table): void { + $table->id(); + $table->foreignId('ticket_id')->constrained('tickets')->cascadeOnDelete(); + $table->string('file_path'); + $table->string('file_name'); + $table->unsignedBigInteger('file_size'); + $table->string('mime_type'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ticket_attachments'); + Schema::dropIfExists('ticket_notified_users'); + Schema::dropIfExists('ticket_notified_roles'); + Schema::dropIfExists('tickets'); + } +}; diff --git a/database/migrations/2026_05_25_173000_add_assigned_user_and_replies_to_tickets.php b/database/migrations/2026_05_25_173000_add_assigned_user_and_replies_to_tickets.php new file mode 100644 index 0000000..e5d4353 --- /dev/null +++ b/database/migrations/2026_05_25_173000_add_assigned_user_and_replies_to_tickets.php @@ -0,0 +1,45 @@ +foreignId('assigned_user_id') + ->nullable() + ->after('status_id') + ->constrained('users') + ->nullOnDelete(); + }); + + Schema::create('ticket_replies', function (Blueprint $table): void { + $table->id(); + $table->foreignId('ticket_id')->constrained('tickets')->cascadeOnDelete(); + $table->foreignId('user_id')->constrained('users')->cascadeOnDelete(); + $table->text('message'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('ticket_replies'); + + Schema::table('tickets', function (Blueprint $table): void { + $table->dropForeign(['assigned_user_id']); + $table->dropColumn('assigned_user_id'); + }); + } +}; diff --git a/database/migrations/2026_05_26_180000_add_settings_to_connected_apps_table.php b/database/migrations/2026_05_26_180000_add_settings_to_connected_apps_table.php new file mode 100644 index 0000000..7fbb1d6 --- /dev/null +++ b/database/migrations/2026_05_26_180000_add_settings_to_connected_apps_table.php @@ -0,0 +1,30 @@ +json('settings')->nullable()->after('connection_status_id'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('connected_apps', function (Blueprint $table): void { + $table->dropColumn('settings'); + }); + } +}; diff --git a/database/migrations/2026_05_26_180001_add_immutable_id_to_users_table.php b/database/migrations/2026_05_26_180001_add_immutable_id_to_users_table.php new file mode 100644 index 0000000..c1be534 --- /dev/null +++ b/database/migrations/2026_05_26_180001_add_immutable_id_to_users_table.php @@ -0,0 +1,30 @@ +string('immutable_id')->nullable()->unique()->after('email'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn('immutable_id'); + }); + } +}; diff --git a/database/seeders/ConnectionProtocolSeeder.php b/database/seeders/ConnectionProtocolSeeder.php new file mode 100644 index 0000000..019b071 --- /dev/null +++ b/database/seeders/ConnectionProtocolSeeder.php @@ -0,0 +1,25 @@ + [ + 'name' => $value, + 'created_at' => now(), + ], ConnectionProtocolEnum::values()); + ConnectionProtocol::query() + ->upsert($data, 'name', ['updated_at']); + } +} diff --git a/database/seeders/ConnectionProviderSeeder.php b/database/seeders/ConnectionProviderSeeder.php new file mode 100644 index 0000000..d826215 --- /dev/null +++ b/database/seeders/ConnectionProviderSeeder.php @@ -0,0 +1,32 @@ + 'Azure FD', + 'slug' => 'azure-fd', + ], [ + 'name' => 'Keka HR', + 'slug' => 'keka-hr', + ], + [ + 'name' => 'Oneclick', + 'slug' => 'oneclick', + ], + ]; + ConnectionProvider::query()->upsert($data, ['slug'], ['name']); + } +} diff --git a/database/seeders/ConnectionStatusSeeder.php b/database/seeders/ConnectionStatusSeeder.php new file mode 100644 index 0000000..cbbaf4a --- /dev/null +++ b/database/seeders/ConnectionStatusSeeder.php @@ -0,0 +1,25 @@ + [ + 'name' => $value, + 'created_at' => now(), + ], ConnectionStatusEnum::values()); + ConnectionStatus::query() + ->upsert($data, 'name', ['updated_at']); + } +} diff --git a/database/seeders/DatabaseSeeder.php b/database/seeders/DatabaseSeeder.php index b275081..751ad1c 100644 --- a/database/seeders/DatabaseSeeder.php +++ b/database/seeders/DatabaseSeeder.php @@ -4,10 +4,10 @@ namespace Database\Seeders; -use App\Models\User; -// use Illuminate\Database\Console\Seeds\WithoutModelEvents; use Illuminate\Database\Seeder; +// use Illuminate\Database\Console\Seeds\WithoutModelEvents; + final class DatabaseSeeder extends Seeder { /** @@ -15,11 +15,13 @@ final class DatabaseSeeder extends Seeder */ public function run(): void { - // User::factory(10)->create(); - - User::factory()->create([ - 'name' => 'Test User', - 'email' => 'test@example.com', - ]); + $this->call(ConnectionStatusSeeder::class); + $this->call(ConnectionProtocolSeeder::class); + $this->call(ConnectionProviderSeeder::class); + $this->call(PermissionSeeder::class); + $this->call(DefaultRoleWithPermissionSeeder::class); + $this->call(ExampleUserWithRoleSeeder::class); + $this->call(TicketStatusSeeder::class); + $this->call(MockDataSeeder::class); } } diff --git a/database/seeders/DefaultRoleWithPermissionSeeder.php b/database/seeders/DefaultRoleWithPermissionSeeder.php new file mode 100644 index 0000000..2d74bf8 --- /dev/null +++ b/database/seeders/DefaultRoleWithPermissionSeeder.php @@ -0,0 +1,34 @@ +value); + $role->update(['priority' => 0]); + $role->givePermissionTo(Permission::all()); + $role = Role::findOrCreate(DefaultUserRole::User->value); + $role->update(['priority' => 100]); + $this->syncUserPermissions($role); + } + + private function syncUserPermissions(Role $role): void + { + $role->givePermissionTo( + ProfilePermissionEnum::Access->value, + SecurityPermissionEnum::Access->value, + TicketPermissionEnum::Access->value, + TicketPermissionEnum::Create->value + ); + } +} diff --git a/database/seeders/ExampleUserWithRoleSeeder.php b/database/seeders/ExampleUserWithRoleSeeder.php new file mode 100644 index 0000000..7b588ec --- /dev/null +++ b/database/seeders/ExampleUserWithRoleSeeder.php @@ -0,0 +1,49 @@ +syncAdminRole(); + $this->syncUserRole(); + } + + private function syncAdminRole(): void + { + $role = Role::findOrCreate(DefaultUserRole::Admin->value); + // Check if user exists by email, otherwise create via factory + $user = User::firstWhere('email', 'admin@example.com') + ?? User::factory()->create([ + 'name' => 'Admin', + 'email' => 'admin@example.com', + 'password' => 'password', + ]); + + $user->assignRole($role); + } + + private function syncUserRole(): void + { + $role = Role::findOrCreate(DefaultUserRole::User->value); + + $user = User::firstWhere('email', 'test@example.com') + ?? User::factory()->create([ + 'name' => 'Test User', + 'email' => 'test@example.com', + 'password' => 'password', + ]); + $user->assignRole($role); + + } +} diff --git a/database/seeders/MockDataSeeder.php b/database/seeders/MockDataSeeder.php new file mode 100644 index 0000000..8d5b3a2 --- /dev/null +++ b/database/seeders/MockDataSeeder.php @@ -0,0 +1,79 @@ + $name) { + $role = Role::findOrCreate($name, 'web'); + $role->update(['priority' => $priorities[$index]]); + $roles[] = $role; + } + + // 2. Create 5 users and assign them role combinations + $users = User::factory()->count(5)->create(); + + // Assign combinations + $users[0]->assignRole(['Manager', 'Editor']); + $users[1]->assignRole(['Developer', 'Support']); + $users[2]->assignRole(['Auditor']); + $users[3]->assignRole(['Manager', 'Developer']); + $users[4]->assignRole(['Support']); + + // 3. Create 10 apps using ConnectedAppService to automatically register permissions + $apps = []; + $factory = ConnectedApp::factory(); + + for ($i = 0; $i < 10; $i++) { + $data = $factory->definition(); + $request = new ConnectAppRequest( + name: $data['name'], + connectionProviderId: $data['connection_provider_id'], + connectionProtocolId: $data['connection_protocol_id'], + slug: $data['slug'], + connectionStatusId: $data['connection_status_id'], + ); + + $this->connectedAppService->create($request); + $apps[] = ConnectedApp::where('slug', $data['slug'])->firstOrFail(); + } + + // 4. Connect apps to roles using AppRoleService to automatically assign permissions + $roleAppsMapping = [ + 'Manager' => [$apps[0]->id, $apps[1]->id, $apps[2]->id], + 'Editor' => [$apps[2]->id, $apps[3]->id, $apps[4]->id], + 'Auditor' => [$apps[5]->id], + 'Developer' => [$apps[6]->id, $apps[7]->id, $apps[8]->id], + 'Support' => [$apps[8]->id, $apps[9]->id], + ]; + + foreach ($roleAppsMapping as $roleName => $appIds) { + $role = Role::findByName($roleName, 'web'); + $this->appRoleService->assign(new AssignAppsToRoleData( + roleId: $role->id, + validUpto: now()->addYears(5)->toDateTimeString(), + appIds: $appIds + )); + } + } +} diff --git a/database/seeders/PermissionSeeder.php b/database/seeders/PermissionSeeder.php new file mode 100644 index 0000000..55d5ff5 --- /dev/null +++ b/database/seeders/PermissionSeeder.php @@ -0,0 +1,23 @@ +extract(); + foreach ($allEnums as $enumCases) { + foreach ($enumCases as $case) { + Permission::findOrCreate($case->value); + } + } + } +} diff --git a/database/seeders/TicketStatusSeeder.php b/database/seeders/TicketStatusSeeder.php new file mode 100644 index 0000000..36b397c --- /dev/null +++ b/database/seeders/TicketStatusSeeder.php @@ -0,0 +1,23 @@ + ['name' => $value], TicketStatusEnum::values()); + + TicketStatus::query() + ->upsert($data, ['name'], ['name', 'updated_at']); + } +} diff --git a/package-lock.json b/package-lock.json index 72fd76f..7c7a8bd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,13 +5,16 @@ "packages": { "": { "dependencies": { - "@tailwindcss/vite": "^4.1.11", "autoprefixer": "^10.4.20", "concurrently": "^9.0.1", "laravel-vite-plugin": "^3.1", - "tailwindcss": "^4.0.7", "vite": "^8.0.0" }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.0", + "daisyui": "^5.5.19", + "tailwindcss": "^4.3.0" + }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.9.5", "@tailwindcss/oxide-linux-x64-gnu": "^4.0.1", @@ -53,6 +56,7 @@ "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -63,6 +67,7 @@ "version": "2.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -73,6 +78,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -82,12 +88,14 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.31", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -208,9 +216,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -227,9 +232,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -246,9 +248,6 @@ "cpu": [ "ppc64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -265,9 +264,6 @@ "cpu": [ "s390x" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -284,9 +280,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -303,9 +296,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -394,9 +384,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -404,50 +391,53 @@ ] }, "node_modules/@tailwindcss/node": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", - "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/remapping": "^2.3.5", - "enhanced-resolve": "^5.19.0", + "enhanced-resolve": "^5.21.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.4" + "tailwindcss": "4.3.0" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", - "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-arm64": "4.2.4", - "@tailwindcss/oxide-darwin-x64": "4.2.4", - "@tailwindcss/oxide-freebsd-x64": "4.2.4", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", - "@tailwindcss/oxide-linux-x64-musl": "4.2.4", - "@tailwindcss/oxide-wasm32-wasi": "4.2.4", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", - "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -458,12 +448,13 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", - "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -474,12 +465,13 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", - "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -490,12 +482,13 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", - "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -506,12 +499,13 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", - "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -522,12 +516,13 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", - "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", "cpu": [ "arm64" ], + "dev": true, "libc": [ "glibc" ], @@ -541,12 +536,13 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", - "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", "cpu": [ "arm64" ], + "dev": true, "libc": [ "musl" ], @@ -560,9 +556,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", - "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", "cpu": [ "x64" ], @@ -579,12 +575,13 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", - "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", "cpu": [ "x64" ], + "dev": true, "libc": [ "musl" ], @@ -598,9 +595,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", - "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -612,13 +609,14 @@ "cpu": [ "wasm32" ], + "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.8.1", - "@emnapi/runtime": "^1.8.1", - "@emnapi/wasi-threads": "^1.1.0", - "@napi-rs/wasm-runtime": "^1.1.1", + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, @@ -627,12 +625,13 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", - "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -643,12 +642,13 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", - "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -659,14 +659,15 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.2.4.tgz", - "integrity": "sha512-pCvohwOCspk3ZFn6eJzrrX3g4n2JY73H6MmYC87XfGPyTty4YsCjYTMArRZm/zOI8dIt3+EcrLHAFPe5A4bgtw==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.0.tgz", + "integrity": "sha512-t6J3OrB5Fc0ExuhohouH0fWUGMYL6PTLhW+E7zIk/pdbnJARZDCwjBznFnkh5ynRnIRSI4YjtTH0t6USjJISrw==", + "dev": true, "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.2.4", - "@tailwindcss/oxide": "4.2.4", - "tailwindcss": "4.2.4" + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "tailwindcss": "4.3.0" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" @@ -891,6 +892,16 @@ "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/daisyui": { + "version": "5.5.19", + "resolved": "https://registry.npmjs.org/daisyui/-/daisyui-5.5.19.tgz", + "integrity": "sha512-pbFAkl1VCEh/MPCeclKL61I/MqRIFFhNU7yiXoDDRapXN4/qNCoMxeCCswyxEEhqL5eiTTfwHvucFtOE71C9sA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/saadeghi/daisyui?sponsor=1" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -913,9 +924,10 @@ "license": "MIT" }, "node_modules/enhanced-resolve": { - "version": "5.21.0", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", - "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", + "version": "5.21.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz", + "integrity": "sha512-QyL119InA+XXEkNLNTPCXPugSvOfhwv0JOlGNzvxs0hZaiHLNvXSpudUWsOlsXGWJh8G6ckCScEkVHfX3kw/2Q==", + "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -991,6 +1003,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, "license": "ISC" }, "node_modules/has-flag": { @@ -1015,6 +1028,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -1182,9 +1196,6 @@ "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1205,9 +1216,6 @@ "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1228,9 +1236,6 @@ "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1251,9 +1256,6 @@ "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -1311,6 +1313,7 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -1506,15 +1509,17 @@ } }, "node_modules/tailwindcss": { - "version": "4.2.4", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", - "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, "license": "MIT" }, "node_modules/tapable": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, "license": "MIT", "engines": { "node": ">=6" diff --git a/package.json b/package.json index ce5f378..7abf6dd 100644 --- a/package.json +++ b/package.json @@ -7,16 +7,19 @@ "dev": "vite" }, "dependencies": { - "@tailwindcss/vite": "^4.1.11", "autoprefixer": "^10.4.20", "concurrently": "^9.0.1", "laravel-vite-plugin": "^3.1", - "tailwindcss": "^4.0.7", "vite": "^8.0.0" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.9.5", "@tailwindcss/oxide-linux-x64-gnu": "^4.0.1", "lightningcss-linux-x64-gnu": "^1.29.1" + }, + "devDependencies": { + "@tailwindcss/vite": "^4.3.0", + "daisyui": "^5.5.19", + "tailwindcss": "^4.3.0" } } diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..73581d5 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,22 @@ +includes: + - vendor/larastan/larastan/extension.neon + - vendor/nesbot/carbon/extension.neon + +parameters: + paths: + - app/ + - resources/views/ + + # Level 10 is the highest level + level: 5 + + ignoreErrors: + - + message: '#Expression "new .*" on a separate line does not do anything#' + path: resources/views/*.blade.php + +# ignoreErrors: +# - '#PHPDoc tag @var#' +# + excludePaths: + - resources/views/flux/* diff --git a/pint.json b/pint.json index 69b94c9..b74ef63 100644 --- a/pint.json +++ b/pint.json @@ -1,21 +1,10 @@ { - "preset": "per", + "preset": "laravel", "rules": { - "align_multiline_comment": true, - "array_indentation": true, - "array_syntax": true, - "blank_line_after_namespace": true, - "blank_line_after_opening_tag": true, - "combine_consecutive_issets": true, - "combine_consecutive_unsets": true, - "concat_space": { - "spacing": "one" - }, - "declare_parentheses": true, "declare_strict_types": true, - "explicit_string_variable": true, - "final_class": true, "fully_qualified_strict_types": true, + "group_import": true, + "single_import_per_statement": false, "global_namespace_import": { "import_classes": true, "import_constants": true, @@ -23,14 +12,8 @@ }, "is_null": true, "lambda_not_used_import": true, - "logical_operators": true, - "mb_str_functions": true, - "method_chaining_indentation": true, - "modernize_strpos": true, "new_with_braces": true, "no_empty_comment": true, - "not_operator_with_space": true, - "ordered_traits": true, "protected_to_private": true, "simplified_if_return": true, "strict_comparison": true, @@ -38,7 +21,11 @@ "trim_array_spaces": true, "use_arrow_functions": true, "void_return": true, - "yoda_style": true, + "yoda_style": { + "equal": true, + "identical": true, + "less_and_greater": null + }, "array_push": true, "assign_null_coalescing_to_coalesce_equal": true, "explicit_indirect_variable": true, @@ -49,33 +36,17 @@ "no_superfluous_elseif": true, "no_useless_else": true, "nullable_type_declaration_for_default_null_value": true, - "ordered_imports": { - "sort_algorithm": "alpha" + "strict_param": true, + "date_time_immutable": true, + "mb_str_functions": true, + "logical_operators": true, + "ordered_interfaces": true, + "ordered_traits": true, + "php_unit_strict": true, + "phpdoc_separation": true, + "return_type_declaration": { + "space_before": "none" }, - "ordered_class_elements": { - "order": [ - "use_trait", - "case", - "constant", - "constant_public", - "constant_protected", - "constant_private", - "property_public", - "property_protected", - "property_private", - "construct", - "destruct", - "magic", - "phpunit", - "method_abstract", - "method_public_static", - "method_public", - "method_protected_static", - "method_protected", - "method_private_static", - "method_private" - ], - "sort_algorithm": "none" - } + "single_line_throw": false } } diff --git a/public/favicon.ico b/public/favicon.ico index 236fadb..b9fea25 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/favicon.svg b/public/favicon.svg deleted file mode 100644 index e4e710e..0000000 --- a/public/favicon.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/public/images/4238287.jpg b/public/images/4238287.jpg new file mode 100644 index 0000000..3fe69fd Binary files /dev/null and b/public/images/4238287.jpg differ diff --git a/public/images/logo.png b/public/images/logo.png new file mode 100644 index 0000000..d05e7d3 Binary files /dev/null and b/public/images/logo.png differ diff --git a/public/index.php b/public/index.php index 7d9e6bf..6aa752c 100644 --- a/public/index.php +++ b/public/index.php @@ -8,15 +8,15 @@ define('LARAVEL_START', microtime(true)); // Determine if the application is in maintenance mode... -if (file_exists($maintenance = __DIR__ . '/../storage/framework/maintenance.php')) { +if (file_exists($maintenance = __DIR__.'/../storage/framework/maintenance.php')) { require $maintenance; } // Register the Composer autoloader... -require __DIR__ . '/../vendor/autoload.php'; +require __DIR__.'/../vendor/autoload.php'; // Bootstrap Laravel and handle the request... /** @var Application $app */ -$app = require_once __DIR__ . '/../bootstrap/app.php'; +$app = require_once __DIR__.'/../bootstrap/app.php'; $app->handleRequest(Request::capture()); diff --git a/resources/css/app.css b/resources/css/app.css index ad6eeed..5628d53 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -52,7 +52,7 @@ [data-flux-field]:not(ui-radio, ui-checkbox) { } [data-flux-label] { - @apply !mb-0 !leading-tight; + @apply !mb-0 !leading-tight; } input:focus[data-flux-control], @@ -64,3 +64,80 @@ select:focus[data-flux-control] { /* \[:where(&)\]:size-4 { @apply size-4; } */ + + +/** + The lines above are intact. + The lines below were added by maryUI installer. +*/ + +/** daisyUI */ +@plugin "daisyui" { + themes: light --default +} + +@plugin "daisyui/theme" { + name: "light"; + default: true; + prefersdark: false; + color-scheme: "light"; + --color-base-100: oklch(100% 0 0); + --color-base-200: oklch(98% 0 0); + --color-base-300: oklch(95% 0 0); + --color-base-content: oklch(21% 0.006 285.885); + --color-primary: oklch(54% 0.245 262.881); + --color-primary-content: oklch(97% 0.014 254.604); + --color-secondary: oklch(65% 0.241 354.308); + --color-secondary-content: oklch(94% 0.028 342.258); + --color-accent: oklch(77% 0.152 181.912); + --color-accent-content: oklch(38% 0.063 188.416); + --color-neutral: oklch(14% 0.005 285.823); + --color-neutral-content: oklch(92% 0.004 286.32); + --color-info: oklch(74% 0.16 232.661); + --color-info-content: oklch(29% 0.066 243.157); + --color-success: oklch(76% 0.177 163.223); + --color-success-content: oklch(37% 0.077 168.94); + --color-warning: oklch(82% 0.189 84.429); + --color-warning-content: oklch(41% 0.112 45.904); + --color-error: oklch(71% 0.194 13.428); + --color-error-content: oklch(27% 0.105 12.094); + --radius-selector: 0.5rem; + --radius-field: 0.5rem; + --radius-box: 1rem; + --size-selector: 0.25rem; + --size-field: 0.25rem; + --border: 1px; + --depth: 0; + --noise: 0; +} + + +/* maryUI */ +@source "../../vendor/robsontenorio/mary/src/View/Components/**/*.php"; + +/* Theme toggle */ +@custom-variant dark (&:where(.dark, .dark *)); + +/*pagination style*/ +.mary-table-pagination { + button, span { + @apply text-xs border-none + } + + button { + @apply cursor-pointer + } + + span[aria-current="page"] > span { + @apply bg-gray-200! + } + + button, span[aria-current="page"] > span, span[aria-disabled="true"] span { + @apply py-1 px-2 bg-gray-100 + } +} + +/*set background color for table header*/ +thead { + @apply bg-gray-50 +} diff --git a/resources/views/components/auth-header.blade.php b/resources/views/components/auth-header.blade.php index e596a3f..818b5f7 100644 --- a/resources/views/components/auth-header.blade.php +++ b/resources/views/components/auth-header.blade.php @@ -3,7 +3,7 @@ 'description', ]) -
- {{ $title }} - {{ $description }} +
+

{{ $title }}

+

{{ $description }}

diff --git a/resources/views/components/dashboard-sidebar.blade.php b/resources/views/components/dashboard-sidebar.blade.php index 9dff3e1..4bbdd26 100644 --- a/resources/views/components/dashboard-sidebar.blade.php +++ b/resources/views/components/dashboard-sidebar.blade.php @@ -1,18 +1,23 @@ active = $active; $this->collapsed = session('sidebar_collapsed', false); } @@ -22,25 +27,137 @@ public function toggle(): void session(['sidebar_collapsed' => $this->collapsed]); } - public function navItems(): array + /** + * @return DataCollection + */ + public function navItems(): DataCollection { - return [ - [ - 'key' => 'dashboard', - 'label' => 'Dashboard', - 'icon' => 'lucide-house', - 'route' => 'home', - ], - [ - 'key' => 'users', - 'label' => 'Users', - 'icon' => 'lucide-users', - 'route' => 'home', - ], - ]; + $items = NavItemData::collect([ + new NavItemData( + label: 'Dashboard', + icon: 'lucide.house', + active_pattern: 'dashboard', + route: 'dashboard', + ), + + new NavItemData( + label: 'Apps', + icon: 'lucide.grid-2x2-plus', + active_pattern: 'apps.*', + sub_menu: NavSubItemData::collect([ + new NavSubItemData( + label: 'All Apps', + route: 'apps.index', + active_pattern: 'apps.index', + permission: AppPermissionEnum::Access + ), + new NavSubItemData( + label: 'Providers', + route: 'apps.connectionProviders.index', + active_pattern: 'apps.connectionProviders.*', + permission: ConnectionProviderPermissionEnum::Access + ), + new NavSubItemData( + label: 'Microsoft Graph', + route: 'apps.microsoft-federation', + active_pattern: 'apps.microsoft-federation', + permission: ConnectionProviderPermissionEnum::Access + ) + ], DataCollection::class) + ), + + new NavItemData( + label: 'Access Manager', + icon: 'lucide.shield', + active_pattern: 'access-manager.*', + sub_menu: NavSubItemData::collect([ + new NavSubItemData( + label: 'Roles and Apps', + route: 'access-manager.roles-and-apps.index', + active_pattern: 'access-manager.roles-and-apps.*', + permission: RoleAndAppsPermissionEnum::Access + ), + new NavSubItemData( + label: 'Permission Manager', + route: 'access-manager.permissions.index', + active_pattern: 'access-manager.permissions.*', + permission: PermissionPermissionEnum::Access + ), + new NavSubItemData( + label: 'Users', + route: 'access-manager.users.index', + active_pattern: 'access-manager.users.*', + permission: UserPermissionEnum::Access + ), + ], DataCollection::class) + ), + + new NavItemData( + label: 'Support Tickets', + icon: 'lucide.life-buoy', + active_pattern: 'tickets*', + route: 'tickets.index', + ), + + new NavItemData( + label: 'Settings', + icon: 'lucide.settings', + active_pattern: 'settings.*', // Kept as fallback, but handled dynamically in view + sub_menu: NavSubItemData::collect([ + new NavSubItemData( + label: 'Profile', + route: 'profile.edit', + active_pattern: 'profile.edit', + permission: ProfilePermissionEnum::Access + ), + new NavSubItemData( + label: 'Security', + route: 'security.edit', + active_pattern: 'security.edit', + permission: SecurityPermissionEnum::Access + ), + ], DataCollection::class) + ), + ], DataCollection::class); + + // Filter out items the user shouldn't see + return $this->filterNavMenu($items); + } + + /** + * Filter out menus that the current user does not have permission. + * @param DataCollection $items + * @returns DataCollection + */ + private function filterNavMenu(DataCollection $items): DataCollection + { + $filteredItems = $items->toCollection()->filter(function (NavItemData $item) { + if ($item->sub_menu) { + $allowedSubItems = $item->sub_menu->toCollection()->filter(function (NavSubItemData $subItem) { + $permission = $subItem->permission instanceof UnitEnum + ? $subItem->permission->value + : $subItem->permission; + + return auth()->user()->can($permission); + }); + + // If the user has NO access to any submenus, discard the parent menu item + if ($allowedSubItems->isEmpty()) { + return false; + } + + // Update the DataCollection with authorized items + $item->sub_menu = NavSubItemData::collect($allowedSubItems, DataCollection::class); + } + + return true; + }); + + return NavItemData::collect($filteredItems->values()->all(), DataCollection::class); } }; ?> + diff --git a/resources/views/components/dashboard-stats.blade.php b/resources/views/components/dashboard-stats.blade.php index 88d5e1d..5925d3c 100644 --- a/resources/views/components/dashboard-stats.blade.php +++ b/resources/views/components/dashboard-stats.blade.php @@ -1,17 +1,17 @@ - +

Total Users

284

- +

Active Roles

4

- +

Services

8

- +

Revoked Today

7

diff --git a/resources/views/components/dashboard/connected-apps/⚡all.blade.php b/resources/views/components/dashboard/connected-apps/⚡all.blade.php new file mode 100644 index 0000000..0a7d236 --- /dev/null +++ b/resources/views/components/dashboard/connected-apps/⚡all.blade.php @@ -0,0 +1,53 @@ + + */ + #[Computed] + public function getConnectedApps(): PaginatedDataCollection + { + $service = app(ConnectedAppService::class); + return $service->getAll(); + } +}; +?> + +
+ @foreach ($this->getConnectedApps as $app) + +
+
+ + Outline + + +
+
+

{{$app->name}}

+

{{$app->protocol->name}} - {{$app->provider}}

+
+
+
+ + Revoke +
+
+ @endforeach +
diff --git a/resources/views/components/dashboard/connected-apps/⚡connected-apps.blade.php b/resources/views/components/dashboard/connected-apps/⚡connected-apps.blade.php new file mode 100644 index 0000000..f2b2ac7 --- /dev/null +++ b/resources/views/components/dashboard/connected-apps/⚡connected-apps.blade.php @@ -0,0 +1,33 @@ + + +
+ + + +
+ + {{ __('Connect App') }} +
+
+
+ + + + + +
Tricks
+
+ +
Musics
+
+
+
+
diff --git a/resources/views/components/dashboard/connected-services/⚡all.blade.php b/resources/views/components/dashboard/connected-services/⚡all.blade.php deleted file mode 100644 index f7c4d6c..0000000 --- a/resources/views/components/dashboard/connected-services/⚡all.blade.php +++ /dev/null @@ -1,86 +0,0 @@ -connectedServices = new ConnectedServicesData( - services: new ConnectedServiceCollectionData([ - new ConnectedServiceData( - name: 'Microsoft Outlook', - icon: 'home', - type: ConnectionTechTypes::OIDC, - connectionService: 'Azure AD', - status: ConnectionStatus::Connected - ), - new ConnectedServiceData( - name: 'Microsoft Teams', - icon: 'home', - type: ConnectionTechTypes::OIDC, - connectionService: 'Azure AD', - status: ConnectionStatus::Pending - ), - new ConnectedServiceData( - name: 'Keka HR', - icon: 'home', - type: ConnectionTechTypes::SAML, - connectionService: 'KeKA', - status: ConnectionStatus::Disconnected - ), - new ConnectedServiceData( - name: 'Trutimer', - icon: 'home', - type: ConnectionTechTypes::SAML, - connectionService: 'Redmine', - status: ConnectionStatus::Error - ), - ]) - ); - } -}; -?> - -
- @foreach ($connectedServices->services as $service) - -
-
- - Outline - - -
-
-

{{$service->name}}

-

{{$service->type->name}} - {{$service->connectionService}}

-
-
-
-
-
$service->status === ConnectionStatus::Connected, - 'bg-gray-400' => $service->status === ConnectionStatus::Disconnected, - 'bg-yellow-400' => $service->status === ConnectionStatus::Pending, - 'bg-red-400' => $service->status === ConnectionStatus::Error, - ]) - >
-

{{ucfirst($service->status->name)}}

-
- Revoke -
-
- @endforeach -
diff --git a/resources/views/components/dashboard/connected-services/⚡connected-services.blade.php b/resources/views/components/dashboard/connected-services/⚡connected-services.blade.php deleted file mode 100644 index f0bbe13..0000000 --- a/resources/views/components/dashboard/connected-services/⚡connected-services.blade.php +++ /dev/null @@ -1,32 +0,0 @@ - - -
- - - -
- - {{ __('Add service') }} -
-
-
- - - - - -
Tricks
-
- -
Musics
-
-
-
-
diff --git a/resources/views/components/dashboard/roles/⚡hr-permission.blade.php b/resources/views/components/dashboard/roles/⚡hr-permission.blade.php new file mode 100644 index 0000000..8fe8c2a --- /dev/null +++ b/resources/views/components/dashboard/roles/⚡hr-permission.blade.php @@ -0,0 +1,60 @@ +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); + } +}; +?> + +
+ + + + {{ __('Revoke All') }} + + + {{ __('Save') }} + + + + + @scope('cell_view', $row) + + @endscope + @scope('cell_create', $row) + + @endscope + @scope('cell_update', $row) + + @endscope + @scope('cell_delete', $row) + + @endscope + + +
diff --git a/resources/views/components/dashboard/roles/⚡manager.blade.php b/resources/views/components/dashboard/roles/⚡manager.blade.php index d16602c..a5a91c8 100644 --- a/resources/views/components/dashboard/roles/⚡manager.blade.php +++ b/resources/views/components/dashboard/roles/⚡manager.blade.php @@ -1,97 +1,71 @@ - */ - public array $header; - public RolesData $rolesData; + #[DataCollectionOf(TableHeader::class)] + public DataCollection $header; + + #[DataCollectionOf(class: RoleData::class)] + public DataCollection $roles; public function mount(): void { - $this->header = [ + $this->header = TableHeader::collect([ [ - 'key' => 'name', - 'label' => 'Role Name', + "key" => "name", + "label" => "Role Name", ], [ - 'key' => 'users', - 'label' => 'User Assigned', + "key" => "users", + "label" => "User Assigned", ], [ - 'key' => 'services', - 'label' => 'Services', + "key" => "services", + "label" => "Services", ], [ - 'key' => 'status', - 'label' => 'Status', - ] - ]; + "key" => "status", + "label" => "Status", + ], + ], DataCollection::class); + $this->roles = RoleData::collect( + [ + new RoleData( + name: "Super Admin", - $this->rolesData = new RolesData( - roles: new RolesCollection([ - new RoleData( - name: 'Super Admin', - users: new UsersData( - users: new UserCollection([ - new UserData( - name: 'Kushal Saha', - email: 'kushal@example.com' - ), - new UserData( - name: 'Test Example', - email: 'test@example.com' - ) - ] - ) - ), - services: new ServiceCollection( + users: UserData::collect( [ - new ServiceData( - name: 'Teams' - ) - ] - ), - status: \App\Enums\RolesStatus::Active - ), - new RoleData( - name: 'Super Admin', - users: new UsersData( - users: new UserCollection([ - new UserData( - name: 'Kushal Saha', - email: 'kushal@example.com' - ) - ] - ) - ), - services: new ServiceCollection( - [ - new ServiceData( - name: 'Teams' + new UserData( + name: "Kushal Saha", + email: "kushal@example.com", ), - new ServiceData( - name: 'Outlook' - ) - ] + new UserData( + name: "Test Example", + email: "test@example.com", + ), + ], + DataCollection::class, ), - status: \App\Enums\RolesStatus::Active + + services: ServiceData::collect( + [ + new ServiceData(name: "Teams"), + new ServiceData(name: "Outlook"), + ], + DataCollection::class, + ), + + status: RolesStatusEnum::Active, ), - ]) + ], + DataCollection::class, ); } }; @@ -101,34 +75,38 @@ public function mount(): void
- -
- - {{ __('Add role') }} -
+ + {{ __('Add role') }}
- - @scope('cell_users', $row) - @foreach($row->users->users as $user) - - {{implode('', array_map(fn($w) => strtoupper($w[0]), explode(' ', $user->name)))}} - - @endforeach + + + @scope('cell_users', $role) + @php /** @var RoleData $role */ @endphp +
+ @foreach($role->users as $user) + + @endforeach +
@endscope - @scope('cell_status', $row) + @scope('cell_status', $role) + @php /** @var RoleData $role */ @endphp - {{ ucfirst($row->status->name) }} + {{ ucfirst($role->status->name) }} @endscope - @scope('cell_services', $row) - @foreach($row->services as $service) + @scope('cell_services', $role) + @php /** @var RoleData $role */ @endphp + @foreach($role->services as $service) {{$service->name}} @endforeach @endscope -
+ + @scope('actions', $role) + + @endscope +
diff --git a/resources/views/components/dashboard/users/⚡assignments.blade.php b/resources/views/components/dashboard/users/⚡assignments.blade.php new file mode 100644 index 0000000..52fd50d --- /dev/null +++ b/resources/views/components/dashboard/users/⚡assignments.blade.php @@ -0,0 +1,138 @@ + */ + #[DataCollectionOf(TableHeader::class)] + public DataCollection $header; + + /** @var DataCollection|null */ + #[DataCollectionOf(UserData::class)] + public ?DataCollection $users = null; + + public function mount(): void + { + $this->header = TableHeader::collect([ + new TableHeader('name', 'User'), + new TableHeader('role.name', 'Role'), + new TableHeader('role.service', 'Service Access'), + new TableHeader('status', 'Status'), + ], DataCollection::class); + + $this->users = UserData::collect([ + new UserData( + name: 'Priya Rao', + email: 'priya@example.com', + active: true, + roles: RoleData::collect([ + new RoleData( + name: 'Super Admin', + users: UserData::collect([], DataCollection::class), + services: ServiceData::collect([ + new ServiceData(name: 'AWS Core'), + new ServiceData(name: 'GitHub Enterprise'), + ], DataCollection::class), + status: RolesStatusEnum::Active, + ), + new RoleData( + name: 'HR Manager', + users: UserData::collect([], DataCollection::class), + services: ServiceData::collect([ + new ServiceData(name: 'Keka'), + new ServiceData(name: 'Outlook'), + ], DataCollection::class), + status: RolesStatusEnum::Active, + ) + ], DataCollection::class) + ), + + new UserData( + name: 'Kushal Saha', + email: 'kushal@example.com', + active: true, + roles: RoleData::collect([ + new RoleData( + name: 'Backend Developer', + users: UserData::collect([], DataCollection::class), + services: ServiceData::collect([ + new ServiceData(name: 'Forge'), + ], DataCollection::class), + status: RolesStatusEnum::Active, + ) + ], DataCollection::class) + ), + + new UserData( + name: 'Alex Johnson', + email: 'alex@example.com', + active: false, + roles: RoleData::collect([ + new RoleData( + name: 'Guest Viewer', + users: UserData::collect([], DataCollection::class), + services: ServiceData::collect([ + new ServiceData(name: 'Analytics Dashboard'), + ], DataCollection::class), + status: RolesStatusEnum::Active, + ) + ], DataCollection::class) + ) + ], DataCollection::class); + } +}; +?> + +
+ + + + {{ __('Assign user') }} + + + + + @scope('cell_name', $row) + + @endscope + @scope('cell_role.name', $row) +
+ @foreach ($row->roles as $role) + + {{$role->name}} + + @endforeach +
+ @endscope + + @scope('cell_role.service', $row) +
+ @foreach ($row->roles as $role) + @foreach($role->services as $service) + {{$service->name}}, + @endforeach + @endforeach +
+ @endscope + + @scope('cell_status', $row) + @if($row->active) + Active + @else + Not Active + @endif + @endscope + + @scope('actions', $row) + + @endscope +
+
+
diff --git a/resources/views/components/dashbord-topbar.blade.php b/resources/views/components/dashbord-topbar.blade.php index 10c522a..496f4c4 100644 --- a/resources/views/components/dashbord-topbar.blade.php +++ b/resources/views/components/dashbord-topbar.blade.php @@ -1,12 +1,84 @@ +@php use App\Enums\DefaultUserRole; + $user = auth()->user(); +@endphp
-

Admin Panel

+ @if($user?->hasRole(DefaultUserRole::Admin->value)) +

Admin Panel

+ @endif
-
- - - -
-
+
+ @php + $unreadNotifications = $user?->unreadNotifications()->take(5)->get() ?? collect(); + $unreadCount = $user?->unreadNotifications()->count() ?? 0; + @endphp + + +
+ + @if($unreadCount > 0) + + {{ $unreadCount }} + + @endif +
+
+ + @if($unreadNotifications->isEmpty()) + + @else +
+ Notifications +
+ @csrf + +
+
+ @foreach($unreadNotifications as $notification) + +
+ + {{ data_get($notification->data, 'issue_category', 'Notification') }} + + + {{ data_get($notification->data, 'message', '') }} + + + {{ $notification->created_at->diffForHumans() }} + +
+
+ @endforeach + @endif +
+ + + +
+ {{ $user->initials() }} +
+
+ +
+

{{$user->name}}

+

{{$user->email}}

+
+
+
+
+ @csrf + + +
+ diff --git a/resources/views/components/settings/layout.blade.php b/resources/views/components/settings/layout.blade.php index 3a65247..dd7b348 100644 --- a/resources/views/components/settings/layout.blade.php +++ b/resources/views/components/settings/layout.blade.php @@ -1,17 +1,7 @@
-
- - {{ __('Profile') }} - {{ __('Security') }} - {{ __('Appearance') }} - -
- - - -
- {{ $heading ?? '' }} - {{ $subheading ?? '' }} +
+

{{ $heading ?? '' }}

+

{{ $subheading ?? '' }}

{{ $slot }} diff --git a/resources/views/components/shared/avatar.blade.php b/resources/views/components/shared/avatar.blade.php new file mode 100644 index 0000000..0d3caa4 --- /dev/null +++ b/resources/views/components/shared/avatar.blade.php @@ -0,0 +1,18 @@ +@props(['placeholder', 'title', 'subtitle']) +
+twMerge(['class' => 'bg-blue-300 text-blue-800 w-8 h-8 border border-white rounded-full flex items-center justify-center'])}}> + @isset($placeholder) + @initials($placeholder) + @endisset + +
+ @isset($title) +

+ {{$title}} +

+ @endisset + @isset($subtitle) + {{$subtitle}} + @endisset +
+
diff --git a/resources/views/components/shared/badge.blade.php b/resources/views/components/shared/badge.blade.php index 6b322e0..1ca2b8d 100644 --- a/resources/views/components/shared/badge.blade.php +++ b/resources/views/components/shared/badge.blade.php @@ -1,3 +1,3 @@ twMerge(['class'=> 'px-4 py-1 rounded-full bg-gray-200'])}} + {{$attributes->twMerge(['class'=> 'px-4 py-1 rounded-full bg-gray-200 truncate'])}} >{{ $slot }} diff --git a/resources/views/components/shared/button.blade.php b/resources/views/components/shared/button.blade.php index 51f30af..54a6752 100644 --- a/resources/views/components/shared/button.blade.php +++ b/resources/views/components/shared/button.blade.php @@ -1,3 +1,25 @@ - +@props(['icon' => null, 'link' => null]) +@if($link) + twMerge(['class' => 'rounded-lg border border-gray-300 px-4 py-2 text-gray-700 active:scale-75 hover:bg-gray-100 transition duration-150 ease-in-out flex items-center justify-center min-w-20'])}} + /> +
+ @if($icon) + + @svg($icon, 'w-4 h-4') + + @endif + {{$slot}} +
+
+ +
+ @if($link) +
+ @else + +@endif diff --git a/resources/views/components/shared/card.blade.php b/resources/views/components/shared/card.blade.php index 1e625bc..bb76e5f 100644 --- a/resources/views/components/shared/card.blade.php +++ b/resources/views/components/shared/card.blade.php @@ -1,19 +1,19 @@ @props(['title', 'icon', 'actions']) -
twMerge('rounded-xl border border-gray-200 p-4 shadow-xs bg-white')}}> +
twMerge('rounded-xl border border-gray-200 p-0 shadow-xs bg-white')}}> @isset($title) -
-

- @isset($icon) - - @svg($icon) - - @endisset - {{ __($title) }} -

- @isset($actions) - {{$actions}} - @endisset -
+
+

+ @isset($icon) + @svg($icon) + @endisset + {{ __($title) }} +

+
+ @isset($actions) + {{$actions}} + @endisset +
+
@endisset {{$slot}}
diff --git a/resources/views/components/shared/choices.blade.php b/resources/views/components/shared/choices.blade.php new file mode 100644 index 0000000..345d0f7 --- /dev/null +++ b/resources/views/components/shared/choices.blade.php @@ -0,0 +1,35 @@ +@props([ + 'label' => 'Select Items', + 'options', // The data collection passed from the parent + 'searchFunction', // The parent method to call for searching + 'selectAllAction', // The parent method to call for selecting all + 'clearAction', // The parent method to call for clearing + 'placeholder' => 'Search...', + 'noResultText' => 'No items found.' +]) + +
+ + + +
+ + +
+
+ + whereStartsWith('wire:model') }} + :options="$options" + search-function="{{ $searchFunction }}" + option-label="name" + option-value="id" + :no-result-text="$noResultText" + :placeholder="$placeholder" + searchable + /> +
diff --git a/resources/views/components/shared/connection-status.blade.php b/resources/views/components/shared/connection-status.blade.php new file mode 100644 index 0000000..8f2b1f0 --- /dev/null +++ b/resources/views/components/shared/connection-status.blade.php @@ -0,0 +1,20 @@ +@php use App\Enums\ConnectionStatusEnum; @endphp +@props(['status']) +@php + if (!$status instanceof ConnectionStatusEnum){ + $status = ConnectionStatusEnum::tryFrom($status); + } +@endphp + +
+
$status === ConnectionStatusEnum::Connected, + 'bg-gray-400' => $status === ConnectionStatusEnum::Disconnected, + 'bg-yellow-400' => $status === ConnectionStatusEnum::Pending, + 'bg-red-400' => $status === ConnectionStatusEnum::Error, + ]) + >
+ {{ $status->name }} +
diff --git a/resources/views/components/shared/error.blade.php b/resources/views/components/shared/error.blade.php new file mode 100644 index 0000000..fe66314 --- /dev/null +++ b/resources/views/components/shared/error.blade.php @@ -0,0 +1,12 @@ +@props(['errorFieldName', 'firstErrorOnly' => false]) +
+ @if($errors->has($errorFieldName)) + @foreach($errors->get($errorFieldName) as $message) + @foreach(Arr::wrap($message) as $line) +
{{ $line }}
+ @break($firstErrorOnly) + @endforeach + @break($firstErrorOnly) + @endforeach + @endif +
diff --git a/resources/views/components/shared/toggle.blade.php b/resources/views/components/shared/toggle.blade.php new file mode 100644 index 0000000..41d466e --- /dev/null +++ b/resources/views/components/shared/toggle.blade.php @@ -0,0 +1,16 @@ +@props(['disabled' => false, 'label' => null, 'checked' => false]) + + diff --git a/resources/views/components/⚡confirmation-modal.blade.php b/resources/views/components/⚡confirmation-modal.blade.php new file mode 100644 index 0000000..53f9989 --- /dev/null +++ b/resources/views/components/⚡confirmation-modal.blade.php @@ -0,0 +1,97 @@ +action = $action; + $this->componentId = $componentId; + $this->title = $title; + $this->subtitle = $subtitle; + $this->description = $description; + $this->params = $params; + + $this->descriptionClass = $descriptionClass; + $this->acceptBtnClass = $acceptBtnClass; + $this->icon = $icon; + + $this->open = true; + } + + public function confirm(): void + { + $this->open = false; + + $this->dispatch('confirmation-accepted', + action: $this->action, + params: $this->params, + componentId: $this->componentId + ); + } + + public function getMergedAlertClass(): string + { + return TailwindMerge::factory()->make()->merge( + 'alert-warning alert-soft text-red-600', + $this->descriptionClass + ); + } + + public function getMergedButtonClass(): string + { + return TailwindMerge::factory()->make()->merge( + 'btn-warning', + $this->acceptBtnClass + ); + } +} +?> +
+ + @isset($description) + + {{ $description }} + + @endisset + + + + + + +
diff --git a/resources/views/layouts/app/sidebar.blade.php b/resources/views/layouts/app/sidebar.blade.php index 750990f..2940e54 100644 --- a/resources/views/layouts/app/sidebar.blade.php +++ b/resources/views/layouts/app/sidebar.blade.php @@ -14,6 +14,8 @@ {{ $slot }}
+ +
@livewireScripts diff --git a/resources/views/layouts/auth.blade.php b/resources/views/layouts/auth.blade.php index 7150091..99ffc53 100644 --- a/resources/views/layouts/auth.blade.php +++ b/resources/views/layouts/auth.blade.php @@ -1,3 +1,3 @@ - + {{ $slot }} - + diff --git a/resources/views/layouts/auth/simple.blade.php b/resources/views/layouts/auth/simple.blade.php index 0a6f894..d360f95 100644 --- a/resources/views/layouts/auth/simple.blade.php +++ b/resources/views/layouts/auth/simple.blade.php @@ -1,29 +1,23 @@ - - @include('partials.head') - - -
-
- + + @include('partials.head') + + +
+ + {{ config('app.name', 'Laravel') }} + +
+ {{ $slot }}
- - @persist('toast') - - - - @endpersist - - @fluxScripts - +
+ +
+@livewireScripts + diff --git a/resources/views/layouts/auth/split-floating.blade.php b/resources/views/layouts/auth/split-floating.blade.php new file mode 100644 index 0000000..64b433f --- /dev/null +++ b/resources/views/layouts/auth/split-floating.blade.php @@ -0,0 +1,17 @@ + + + + @include('partials.head') + + +
+ {{ $slot }} + @persist('mary-toaster') + + @endpersist +
+@livewireScripts + + diff --git a/resources/views/livewire/auth/confirm-password.blade.php b/resources/views/livewire/auth/confirm-password.blade.php index 13dd110..f4a4557 100644 --- a/resources/views/livewire/auth/confirm-password.blade.php +++ b/resources/views/livewire/auth/confirm-password.blade.php @@ -5,12 +5,12 @@ :description="__('This is a secure area of the application. Please confirm your password before continuing.')" /> - +
@csrf - - + {{ __('Confirm') }} - +
diff --git a/resources/views/livewire/auth/forgot-password.blade.php b/resources/views/livewire/auth/forgot-password.blade.php index 5f1cdad..391e493 100644 --- a/resources/views/livewire/auth/forgot-password.blade.php +++ b/resources/views/livewire/auth/forgot-password.blade.php @@ -1,15 +1,16 @@
- + - +
@csrf - - + {{ __('Email password reset link') }} - +
{{ __('Or, return to') }} - {{ __('log in') }} + {{ __('log in') }}
diff --git a/resources/views/livewire/auth/login.blade.php b/resources/views/livewire/auth/login.blade.php index e9167da..c1f28be 100644 --- a/resources/views/livewire/auth/login.blade.php +++ b/resources/views/livewire/auth/login.blade.php @@ -1,59 +1,79 @@ -
- +
+
+
+

Welcome Back !

+
+

Login to account and continue to

+ {{config('app.name')}} +
+
+ + +
+ +
+ - - + +
+ +
+ @csrf - - @csrf - - - - - -
- + - @if (Route::has('password.request')) - - {{ __('Forgot your password?') }} - - @endif -
+ +
+ - - + @if (Route::has('password.request')) + + {{ __('Forgot your password?') }} + + @endif +
-
- - {{ __('Log in') }} - -
- + + - @if (Route::has('register')) -
- {{ __('Don\'t have an account?') }} - {{ __('Sign up') }} -
- @endif +
+ + {{ __('Log in') }} + +
+ + + @if (Route::has('register')) +
+ {{ __('Don\'t have an account?') }} + {{ __('Sign up') }} +
+ @endif +
diff --git a/resources/views/livewire/auth/register.blade.php b/resources/views/livewire/auth/register.blade.php index 6c12579..ce44de6 100644 --- a/resources/views/livewire/auth/register.blade.php +++ b/resources/views/livewire/auth/register.blade.php @@ -1,67 +1,82 @@ -
- - - - - -
- @csrf - - - - - - - - - - - - -
- - {{ __('Create account') }} - +
+
+
+

Get started !

+
+

Join us to explore features of

+ {{config('app.name')}} +
- - -
- {{ __('Already have an account?') }} - {{ __('Log in') }} + +
+ + + +
+ @csrf + + + + + + + + + + + + +
+ + {{ __('Create account') }} + +
+ + +
+ {{ __('Already have an account ?') }} + {{ __('Log in') }} +
+
diff --git a/resources/views/livewire/auth/reset-password.blade.php b/resources/views/livewire/auth/reset-password.blade.php index e649471..88d792d 100644 --- a/resources/views/livewire/auth/reset-password.blade.php +++ b/resources/views/livewire/auth/reset-password.blade.php @@ -1,9 +1,9 @@
- + - +
@csrf @@ -11,7 +11,7 @@ - - -
- + {{ __('Reset password') }} - +
diff --git a/resources/views/livewire/settings/delete-user-form.blade.php b/resources/views/livewire/settings/delete-user-form.blade.php index a641407..fed0671 100644 --- a/resources/views/livewire/settings/delete-user-form.blade.php +++ b/resources/views/livewire/settings/delete-user-form.blade.php @@ -1,34 +1,48 @@
- {{ __('Delete account') }} - {{ __('Delete your account and all of its resources') }} +
- - - {{ __('Delete account') }} - - + + {{ __('Delete account') }} + - +
+
- {{ __('Are you sure you want to delete your account?') }} - - +

+ {{ __('Are you sure you want to delete your account?') }} +

+

{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }} - +

- + -
- - {{ __('Cancel') }} - + +
+ + {{ __('Cancel') }} + - {{ __('Delete account') }} -
+ + {{ __('Delete account') }} + +
+ -
+
diff --git a/resources/views/livewire/settings/profile.blade.php b/resources/views/livewire/settings/profile.blade.php index 6e9da81..e75d24d 100644 --- a/resources/views/livewire/settings/profile.blade.php +++ b/resources/views/livewire/settings/profile.blade.php @@ -1,36 +1,34 @@
- @include('partials.settings-heading') - - {{ __('Profile settings') }} -
- +
- + @if ($this->hasUnverifiedEmail)
- +
{{ __('Your email address is unverified.') }} - +
{{ __('Click here to re-send the verification email.') }} - - +
+
@endif
- {{ __('Save') }} + {{ __('Save') }}
@if ($this->showDeleteUser) - + @endif
diff --git a/resources/views/livewire/settings/security.blade.php b/resources/views/livewire/settings/security.blade.php index 648afeb..af8c111 100644 --- a/resources/views/livewire/settings/security.blade.php +++ b/resources/views/livewire/settings/security.blade.php @@ -1,11 +1,7 @@
- @include('partials.settings-heading') - - {{ __('Security settings') }} - - -
- + + - - -
- {{ __('Save') }} -
- + + {{ __('Save') }} + +
+ - @if ($canManageTwoFactor) -
- {{ __('Two-factor authentication') }} - {{ __('Manage your two-factor authentication settings') }} + @if ($canManageTwoFactor) +
+

{{ __('Manage your two-factor authentication settings') }}

+

{{ __('Manage your two-factor authentication settings') }}

-
- @if ($twoFactorEnabled) -
- - {{ __('You will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.') }} - -
- - {{ __('Disable 2FA') }} - -
+
+ @if ($twoFactorEnabled) +
+

+ {{ __('You will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.') }} +

- -
- @else -
- - {{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }} - - - + - {{ __('Enable 2FA') }} - + {{ __('Disable 2FA') }} +
- @endif -
-
- -
-
-
-
-
- @for ($i = 1; $i <= 5; $i++) -
- @endfor -
+ +
+ @else +
+

+ {{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }} +

-
- @for ($i = 1; $i <= 5; $i++) -
- @endfor -
+ + {{ __('Enable 2FA') }} + +
+ @endif +
+
- + +
+
+
+
+
+ @for ($i = 1; $i <= 5; $i++) +
+ @endfor +
+
+ @for ($i = 1; $i <= 5; $i++) +
+ @endfor
-
-
- {{ $this->modalConfig['title'] }} - {{ $this->modalConfig['description'] }} +
- @if ($showVerificationStep) -
-
- -
+
+
{{ $this->modalConfig['title'] }}
+
{{ $this->modalConfig['description'] }}
+
+
-
- - {{ __('Back') }} - - - - {{ __('Confirm') }} - -
+ @if ($showVerificationStep) +
+
+
- @else - @error('setupData') - - @enderror -
-
- @empty($qrCodeSvg) -
- -
- @else +
+ + {{ __('Back') }} + + + + {{ __('Confirm') }} + +
+
+ @else + @error('setupData') + + {{ $message }} + + @enderror + +
+
+ @empty($qrCodeSvg) +
+ +
+ @else
- {!! $qrCodeSvg !!} -
+ {!! $qrCodeSvg !!}
+
+ @endempty +
+
+ +
+ + {{ $this->modalConfig['buttonText'] }} + +
+ +
+
+
+ + {{ __('or, enter the code manually') }} + +
+ +
+
+ @empty($manualSetupKey) +
+ +
+ @else + + + @endempty
- -
- - {{ $this->modalConfig['buttonText'] }} - -
- -
-
-
- - {{ __('or, enter the code manually') }} - -
- -
-
- @empty($manualSetupKey) -
- -
- @else - - - - @endempty -
-
-
- @endif -
- - @endif - +
+ @endif +
+ + @endif
diff --git a/resources/views/livewire/settings/two-factor/recovery-codes.blade.php b/resources/views/livewire/settings/two-factor/recovery-codes.blade.php index 9ed2674..e445ff2 100644 --- a/resources/views/livewire/settings/two-factor/recovery-codes.blade.php +++ b/resources/views/livewire/settings/two-factor/recovery-codes.blade.php @@ -1,89 +1,87 @@ -
-
-
- - {{ __('2FA recovery codes') }} +
+ +
+

+ {{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }} +

- - {{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }} - -
-
-
- - - - {{ __('Hide recovery codes') }} - - - @if (filled($recoveryCodes)) - +
+
+ {{ __('View recovery codes') }} + -
-
- @error('recoveryCodes') - - @enderror + + {{ __('Hide recovery codes') }} + @if (filled($recoveryCodes)) -
- @foreach($recoveryCodes as $code) -
- {{ $code }} -
- @endforeach -
- - {{ __('Each recovery code can be used once to access your account and will be removed after use. If you need more, click Regenerate codes above.') }} - + {{ __('Regenerate codes') }} + @endif
+ +
+
+ @error('recoveryCodes') + + @enderror + + @if (filled($recoveryCodes)) +
+ @foreach($recoveryCodes as $code) +
+ {{ $code }} +
+ @endforeach +
+

+ {{ __('Each recovery code can be used once to access your account and will be removed after use. If you need more, click Regenerate codes above.') }} +

+ @endif +
+
-
+ diff --git a/resources/views/pages/access-manager/permissions/⚡index.blade.php b/resources/views/pages/access-manager/permissions/⚡index.blade.php new file mode 100644 index 0000000..a3417dd --- /dev/null +++ b/resources/views/pages/access-manager/permissions/⚡index.blade.php @@ -0,0 +1,214 @@ +permissionManagerService = $permissionManagerService; + } + + public function mount(): void + { + $this->headers = TableHeader::collect([ + new TableHeader(key: 'name', label: 'Role'), + new TableHeader(key: 'priority', label: 'Priority'), + new TableHeader(key: 'permissions', label: 'Permissions'), + new TableHeader(key: 'users', label: 'Added Users'), + ], DataCollection::class); + } + + /** + * @return PaginatedDataCollection + */ + #[Computed] + public function getRoles(): PaginatedDataCollection + { + return $this->permissionManagerService->getRolesWithPermissionsAndUsers(); + } + + public function openEditModal(int $roleId): void + { + $this->authorize(PermissionPermissionEnum::Update->value); + + $this->attempt( + action: function () use ($roleId): void { + $role = $this->permissionManagerService->getRoleForEditing($roleId); + + $this->roleId = $role->id; + $this->roleName = $role->name; + + $this->nonAppPermissions = $this->permissionManagerService->getNonAppPermissions()->toCollection() + ->groupBy('resource') + ->map(fn($group) => $group->toArray()) + ->toArray(); + + $this->selectedPermissionIds = $role->permissions() + ->pluck('id') + ->map(fn($id) => (int) $id) + ->toArray(); + + $this->showEditModal = true; + }, + showSuccess: false, + ); + } + + public function savePermissions(): void + { + $this->authorize(PermissionPermissionEnum::Update->value); + + $this->attempt( + action: function (): void { + $role = $this->permissionManagerService->getRoleForEditing($this->roleId); + + $existingAppPermissions = $role->permissions() + ->get() + ->filter(fn($p) => AppPermission::type($p->name) !== null) + ->pluck('name') + ->toArray(); + + $selectedNames = Permission::query() + ->whereIn('id', $this->selectedPermissionIds) + ->pluck('name') + ->toArray(); + + $finalNames = array_values(array_unique(array_merge($existingAppPermissions, $selectedNames))); + $role->syncPermissions($finalNames); + + $this->showEditModal = false; + unset($this->getRoles); + }, + successMessage: 'Permissions updated successfully!', + ); + } +}; +?> + +
+ + + @scope('cell_name', $row) + {{ $row->name }} + @endscope + + @scope('cell_priority', $row) + {{ $row->priority }} + @endscope + + @scope('cell_permissions', $row) +
+ @if($row->permissions && $row->permissions->count()) + @foreach($row->permissions as $permission) + @if(AppPermission::type($permission->name) === null) + + @else + + @endif + @endforeach + @else + No permissions + @endif +
+ @endscope + + @scope('cell_users', $row) +
+ @if($row->users && $row->users->count()) + @foreach($row->users as $user) + + @endforeach + @else + No users + @endif +
+ @endscope + + @scope('actions', $row) + @php + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + $isManageable = $row->priority > $userPriority; + @endphp + @if($isManageable) +
+ @can(PermissionPermissionEnum::Update->value) + + @endcan +
+ @endif + @endscope +
+
+ + + +
+ @foreach ($nonAppPermissions as $resource => $permissions) +
+

+ {{ ucwords(str_replace('-', ' ', $resource)) }} +

+
+ @foreach ($permissions as $permission) + + @endforeach +
+
+ @endforeach +
+ + + + Cancel + + + Save + + +
+
+
diff --git a/resources/views/pages/access-manager/roles-and-apps/⚡index.blade.php b/resources/views/pages/access-manager/roles-and-apps/⚡index.blade.php new file mode 100644 index 0000000..38e612e --- /dev/null +++ b/resources/views/pages/access-manager/roles-and-apps/⚡index.blade.php @@ -0,0 +1,243 @@ +roleService = $roleService; + $this->appRoleService = $appRoleService; + } + + public function mount(): void + { + $this->headers = TableHeader::collect([ + new TableHeader(key: 'name', label: 'Name'), + new TableHeader(key: 'priority', label: 'Priority'), + new TableHeader(key: 'apps', label: 'Apps'), + new TableHeader(key: 'users', label: 'Users'), + ], DataCollection::class); + } + + #[Computed] + public function getRoles(): PaginatedDataCollection + { + return $this->roleService->getAll(); + } + + + public function searchApps(string $value = ''): void + { + $this->appsSearchable = $this + ->appRoleService + ->searchAppsForSelect($value) + ->toArray(); + } + + public function selectAllApps(): void + { + $this->form->appIds = $this->appRoleService->getAllAppIds(); + $this->appsSearchable = $this->appRoleService->searchAppsForSelect('')->toArray(); + } + + public function clearApps(): void + { + $this->form->appIds = []; + } + + public function test() + { + dd('hi'); + } + + public function openCreateModal(): void + { + $this->form->reset(); + $this->searchApps(); + $this->showCreateModal = true; + } + + public function save(): void + { + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + + $this->form->validate(); + + if ((int) $this->form->priority <= $userPriority) { + $this->addError('form.priority', 'The priority must be greater than your own highest role priority (' . $userPriority . ').'); + return; + } + + $this->attempt( + action: function (): void { + $role = $this->roleService->create(new CreateRoleData( + name: $this->form->name, + priority: (int) $this->form->priority, + )); + $this->appRoleService->assign(new AssignAppsToRoleData( + roleId: $role->id, + validUpto: $this->form->isUnlimited ? now()->addYears(15) : $this->form->validUpto, + appIds: $this->form->appIds, + )); + $this->showCreateModal = false; + $this->form->reset(); + unset($this->getRoles); + }, + successMessage: 'Role created successfully!', + ); + } + + public function delete(int $roleId): void + { + $this->attempt( + action: function () use ($roleId): void { + $this->roleService->delete($roleId); + unset($this->getRoles); + }, + successMessage: 'Role deleted successfully!', + ); + } +}; +?> + +
+ + + + Add new role + + + + + @scope('cell_name', $row) + {{ $row->name }} + @endscope + + @scope('cell_priority', $row) + {{ $row->priority }} + @endscope + + @scope('cell_apps', $row) + @foreach($row->apps as $app) + @php /** @var RoleAppData $app */ @endphp + + @endforeach + @endscope + + @scope('cell_users', $row) +
+ @foreach($row->users as $user) + @php /** @var RoleUserData $user */ @endphp + + @endforeach +
+ @endscope + + @scope('actions', $row) +
+ + +
+ @endscope +
+
+ + + + + + + + +
+ +
+ +
+ + +
+ + + Cancel + + + Save + + +
+
+
diff --git a/resources/views/pages/access-manager/roles-and-apps/⚡show.blade.php b/resources/views/pages/access-manager/roles-and-apps/⚡show.blade.php new file mode 100644 index 0000000..292cb00 --- /dev/null +++ b/resources/views/pages/access-manager/roles-and-apps/⚡show.blade.php @@ -0,0 +1,528 @@ +appRoleService = $appRoleService; + $this->roleService = $roleService; + } + + public function mount(int $id): void + { + $role = Role::query()->findOrFail($id); + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + if ($role->priority <= $userPriority) { + abort(403, 'Unauthorized access to this role.'); + } + + $this->attempt( + action: function () use ($role): void { + $this->roleId = $role->id; + $this->roleName = $role->name; + $this->rolePriority = $role->priority; + }, + onError: function (): void { + $this->redirectRoute('access-manager.roles-and-apps.index', navigate: true); + }, + showSuccess: false, + ); + + $this->appHeaders = TableHeader::collect([ + new TableHeader(key: 'app_name', label: 'App'), + new TableHeader(key: 'duration', label: 'Validity'), + ], DataCollection::class); + + $this->userHeaders = TableHeader::collect([ + new TableHeader(key: 'name', label: 'Name'), + ], DataCollection::class); + } + + #[Computed] + public function apps(): DataCollection + { + return $this->appRoleService->getAppsForRole($this->roleId); + } + + /** + * Get users assigned to this role via spatie/laravel-permission. + */ + #[Computed] + public function getRoleUsers(): DataCollection + { + return $this->appRoleService->getUsersForRole($this->roleId); + } + + + public function openAddAppModal(): void + { + $this->isEditingApp = false; + $this->editingAssignmentId = null; + $this->editingAppName = ''; + + $this->assignForm->reset(); + $this->resetErrorBag(); + $this->searchApps(''); + + $this->showAddAppModal = true; + } + + public function openEditAppModal(int $assignmentId): void + { + $this->isEditingApp = true; + $this->editingAssignmentId = $assignmentId; + $this->resetErrorBag(); + + $assignment = collect($this->apps())->firstWhere('id', $assignmentId); + + if ($assignment) { + $this->assignForm->appIds = [$assignment['appId'] ?? $assignment['id']]; + $this->assignForm->validUpto = $assignment['duration']; + + $this->editingAppName = $assignment['appName']; + } + + $this->showAddAppModal = true; + } + + public function saveApp(): void + { + $this->assignForm->validate(); + + if ($this->isEditingApp) { + $this->updateApp(); + } else { + $this->assignApp(); + } + } + + public function searchApps(string $value = ''): void + { + $this->appsSearchable = $this + ->appRoleService + ->searchAppsForSelect($value) + ->toArray(); + } + + public function selectAllApps(): void + { + $this->assignForm->appIds = $this->appRoleService->getAllAppIds(); + $this->appsSearchable = $this->appRoleService->searchAppsForSelect('')->toArray(); + } + + public function clearApps(): void + { + $this->assignForm->appIds = []; + } + + public function assignApp(): void + { + $this->authorize(RoleAndAppsPermissionEnum::ConnectApps->value); + + $this->attempt( + action: function (): void { + $this->assignForm->validate(); + $this->appRoleService->assign(new AssignAppsToRoleData( + roleId: $this->roleId, + validUpto: $this->assignForm->isUnlimited ? now()->addYears(15) : $this->assignForm->validUpto, + appIds: $this->assignForm->appIds, + )); + $this->assignForm->reset(); + $this->showAddAppModal = false; + unset($this->apps); + }, + successMessage: 'Apps assigned successfully!', + ); + } + + public function updateApp(): void + { + $this->authorize(RoleAndAppsPermissionEnum::ConnectApps->value); + + $this->attempt( + action: function (): void { + $this->assignForm->validate(); + $this->appRoleService->assign(new AssignAppsToRoleData( + roleId: $this->roleId, + validUpto: $this->assignForm->isUnlimited ? now()->addYears(15) : $this->assignForm->validUpto, + appIds: $this->assignForm->appIds, + )); + + $this->assignForm->reset(); + $this->showAddAppModal = false; + unset($this->apps); + }, + successMessage: 'Apps updated successfully!', + ); + } + + public function detachApp(int $id): void + { + $this->authorize(RoleAndAppsPermissionEnum::ConnectApps->value); + + $this->attempt( + action: function () use ($id): void { + $this->appRoleService->detach($id); + unset($this->apps); + }, + successMessage: 'App removed from role.', + ); + } + + + public function openAddUserModal(): void + { + $this->reset('selectedUserIds'); + $this->resetErrorBag(); + $this->searchUsers(''); + $this->showAddUserModal = true; + } + + public function searchUsers(string $value = ''): void + { + $this->usersSearchable = $this + ->appRoleService + ->searchUsersForSelect($value) + ->toArray(); + } + + public function assignUser(): void + { + $this->validate([ + 'selectedUserIds' => 'required|array|min:1', + 'selectedUserIds.*' => 'integer|exists:users,id', + ]); + + $this->attempt( + action: function (): void { + $this->appRoleService->assignUsersToRole(new AssignUsersToRoleData( + roleId: $this->roleId, + userIds: $this->selectedUserIds, + )); + + $this->reset('selectedUserIds'); + $this->showAddUserModal = false; + unset($this->getRoleUsers); + }, + successMessage: 'Users assigned successfully!', + ); + } + + public function detachUser(int $userId): void + { + $this->attempt( + action: function () use ($userId): void { + $this->appRoleService->detachUserFromRole($this->roleId, $userId); + unset($this->getRoleUsers); + }, + successMessage: 'User removed from role.', + ); + } + + public function openEditPriorityModal(): void + { + $this->newPriority = $this->rolePriority; + $this->resetErrorBag(); + $this->showEditPriorityModal = true; + } + + public function savePriority(): void + { + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + + $this->validate([ + 'newPriority' => 'required|integer|min:' . ($userPriority + 1), + ], [ + 'newPriority.min' => 'The priority must be greater than your own highest role priority (' . $userPriority . ').', + ]); + + $this->attempt( + action: function (): void { + $this->roleService->updatePriority(new UpdateRolePriorityData( + roleId: $this->roleId, + priority: (int) $this->newPriority, + )); + $this->rolePriority = (int) $this->newPriority; + $this->showEditPriorityModal = false; + }, + successMessage: 'Role priority updated successfully!', + ); + } +}; +?> + +
+
+
+
+ +

{{ $roleName }}

+
+ + +
+
+
+
+ +
+ + + + @can(RoleAndAppsPermissionEnum::ConnectApps->value) + + Add App + + @endcan + + + @if ($this->apps->count()) + + @scope('cell_app_name', $row) + {{ $row['appName'] }} + @endscope + + @scope('cell_duration', $row) + {{ Carbon::parse($row['duration'])->diffForHumans() }} + @endscope + + @scope('actions', $row) +
+ @can(RoleAndAppsPermissionEnum::ConnectApps->value) + + + @endcan +
+ @endscope +
+ @else +

+ No apps assigned to this role yet. +

+ @endif +
+ + + + + Add User + + + + @if ($this->getRoleUsers->count()) + + @scope('cell_name', $row) + {{ $row['name'] }} + @endscope + + @scope('actions', $row) + + @endscope + + @else +

+ No users assigned to this role yet. +

+ @endif +
+
+ + + +
+ + @if(!$isEditingApp) +
+ +
+ @else + + @endif +
+ +
+ +
+ + +
+
+ + + + Cancel + + + {{ $isEditingApp ? 'Update Duration' : 'Assign Apps' }} + + +
+
+ + + + + + + + Cancel + + + Assign User + + + + + + + + + + + + Cancel + + + Save + + + + +
diff --git a/resources/views/pages/access-manager/users/⚡index.blade.php b/resources/views/pages/access-manager/users/⚡index.blade.php new file mode 100644 index 0000000..9af8dec --- /dev/null +++ b/resources/views/pages/access-manager/users/⚡index.blade.php @@ -0,0 +1,408 @@ +userService = $userService; + $this->roleService = $roleService; + } + + public function mount(): void + { + $this->headers = TableHeader::collect([ + new TableHeader(key: 'name', label: 'User'), + new TableHeader(key: 'roles', label: 'Roles'), + new TableHeader(key: 'permissions', label: 'Permissions'), + new TableHeader(key: 'apps', label: 'Connected Apps'), + ], DataCollection::class); + } + + #[Computed] + public function getUsers(): PaginatedDataCollection + { + return $this->userService->getAll(); + } + + #[Computed] + public function showSamlSettings(): bool + { + if (empty($this->form->roleIds)) { + return false; + } + + return \App\Models\ConnectedApp::query() + ->whereHas('roles', function ($query) { + $query->whereIn('roles.id', $this->form->roleIds); + }) + ->whereHas('protocol', function ($query) { + $query->where('name', 'saml'); + }) + ->exists(); + } + + public function delete(int $userId): void + { + $this->attempt( + action: function () use ($userId): void { + $this->userService->delete($userId); + unset($this->getUsers); + }, + successMessage: 'User deleted successfully!', + ); + } + + public function openAssignRolesModal(int $userId): void + { + $this->attempt( + action: function () use ($userId) { + $user = User::findOrFail($userId); + + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + $targetUserMinPriority = $user->roles()->min('priority') ?? 999999; + if ($targetUserMinPriority <= $userPriority) { + abort(403, 'Unauthorized to manage this user.'); + } + + $this->currentUserId = $userId; + $this->form->reset(); + $this->searchRoles(); + $this->form->roleIds = $user->roles()->pluck('id')->toArray(); + $this->form->immutableId = $user->immutable_id ?? ''; + $this->hydratePermissionInForm(); + $this->showAssignRolesModal = true; + }, + showSuccess: false + ); + } + + private function hydratePermissionInForm(): void + { + $this->roleWithPermission = $this->roleService->getPermissionsGroupedByRole($this->form->roleIds); + $allPermissionIds = []; + if ($this->roleWithPermission) { + foreach ($this->roleWithPermission->items() as $role) { + foreach ($role->permissions->items() as $permission) { + $allPermissionIds[] = $permission->id; + } + } + } + + $restrictedIds = $this->userService->getRestrictedPermissionIds($this->currentUserId); + + $this->form->permissions = array_values(array_diff($allPermissionIds, $restrictedIds)); + } + + /** + * Refresh the permission list whenever the roles selection changes. + * This method is automatically hooked by livewire cause of naming convention. + */ + public function updatedFormRoleIds(): void + { + $this->hydratePermissionInForm(); + } + + public function searchRoles(string $value = ''): void + { + $this->rolesSearchable = $this->roleService->searchRolesForSelect($value)->toArray(); + } + + public function selectAllRoles(): void + { + $this->form->roleIds = $this->roleService->getAllRoleIds(); + $this->searchRoles(); + $this->hydratePermissionInForm(); + } + + public function clearRoles(): void + { + $this->form->roleIds = []; + $this->hydratePermissionInForm(); + } + + public function selectAllPermissions(): void + { + $allPermissionIds = []; + if ($this->roleWithPermission) { + foreach ($this->roleWithPermission->items() as $role) { + foreach ($role->permissions->items() as $permission) { + $allPermissionIds[] = $permission->id; + } + } + } + $this->form->permissions = array_values(array_unique($allPermissionIds)); + } + + public function revokeAllPermissions(): void + { + $this->form->permissions = []; + } + + /** + * Get the deactivated permissions represented as DTOs containing the role ID and permission ID. + * + * @return DataCollection + */ + public function getDeactivatedPermissions(): DataCollection + { + $deactivated = []; + + $activePermissions = array_map('intval', $this->form->permissions); + + if ($this->roleWithPermission) { + foreach ($this->roleWithPermission->items() as $role) { + foreach ($role->permissions->items() as $permission) { + + if (!in_array($permission->id, $activePermissions, true)) { + $deactivated[] = new DeactivatedPermissionData( + roleId: $role->id, + permissionId: $permission->id + ); + } + + } + } + } + + return DeactivatedPermissionData::collect($deactivated, DataCollection::class); + } + + public function saveRoles(): void + { + + $this->attempt( + action: function () { + $this->form->validate(); + $deactivatedPermissions = $this->getDeactivatedPermissions(); + $this->userService->assignRoles($this->currentUserId, $this->form->roleIds, $this->form->immutableId); + $this->userService->syncRestrictedPermissions($this->currentUserId, $deactivatedPermissions); + $this->showAssignRolesModal = false; + $this->roleWithPermission = null; + $this->form->reset(); + unset($this->getUsers); + }, + onError: function () { + $this->showAssignRolesModal = false; + $this->roleWithPermission = null; + $this->form->reset(); + }, + successMessage: 'Roles updated successfully!' + ); + } +}; +?> + +
+ + + @scope('cell_name', $row) +
+ +
+
{{ $row->name }}
+
{{ $row->email }}
+
+
+ @endscope + + @scope('cell_roles', $row) +
+ @php + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + @endphp + @foreach($row->roles as $role) + @if($role->priority > $userPriority) + + @endif + @endforeach +
+ @endscope + + @scope('cell_permissions', $row) +
+ @php + $displayedPermissions = []; + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + @endphp + @foreach($row->roles as $role) + @if($role->priority > $userPriority && $role->permissions) + @foreach($role->permissions as $permission) + @if(!in_array($permission->id, $displayedPermissions)) + @php + $displayedPermissions[] = $permission->id; + $isRestricted = in_array($permission->id, $row->restrictedPermissionIds ?? []); + @endphp + @if($isRestricted) + + @else + + @endif + @endif + @endforeach + @endif + @endforeach +
+ @endscope + + @scope('cell_apps', $row) +
+ @php + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + @endphp + @foreach($row->roles as $role) + @if($role->priority > $userPriority && $role->apps) + @foreach ($role->apps as $app) + + @endforeach + @endif + @endforeach +
+ @endscope + + @scope('actions', $row) + @php + $userPriority = auth()->user()?->roles()->min('priority') ?? 999999; + $rowMinPriority = collect($row->roles)->min('priority') ?? 999999; + $isManageable = $rowMinPriority > $userPriority; + @endphp + @if($isManageable) +
+ + +
+ @endif + @endscope +
+
+ + + + + + @if($this->showSamlSettings) +
+

SAML SSO Settings

+ +
+ @endif + + @if ($roleWithPermission) +
+ +

Permissions

+
+ + + + +
+
+ + @foreach ($roleWithPermission?->items() as $role) +
+
+ + {{ $role->name }} + + {{$role->permissions->count()}} {{ $role->permissions->count() >= 2 ? 'Permissions' : 'Permission' }} +
+ +
+ @foreach ($role->permissions as $permission) + + @endforeach +
+
+ @endforeach +
+ @endif + + + + Cancel + + + Save + + +
+
+
diff --git a/resources/views/pages/connected-apps/⚡create.blade.php b/resources/views/pages/connected-apps/⚡create.blade.php new file mode 100644 index 0000000..c3cdcac --- /dev/null +++ b/resources/views/pages/connected-apps/⚡create.blade.php @@ -0,0 +1,168 @@ +appService = $appService; + } + + #[Computed] + public function providers() + { + return ConnectionProvider::query()->get(); + } + + #[Computed] + public function protocols(): Collection + { + $protocolService = app(ConnectionProtocolService::class); + + return $protocolService + ->getAll() + ->map(function (ConnectionProtocol $protocol) { + $protocol->name = strtoupper($protocol->name); + return $protocol; + }); + } + + /** + * @returns DataCollection + */ + #[Computed] + public function connectionStatuses(): DataCollection + { + $statusService = app(ConnectionStatusService::class); + + return $statusService + ->getAll() + ->map(function (ConnectionStatusData $status) { + if ($status->name === ConnectionStatusEnum::Disconnected) { + $this->form->statusId = $status->id; + } + return $status; + }); + } + + #[Computed] + public function isSaml(): bool + { + $protocol = $this->protocols()->firstWhere( + "id", + (int) $this->form->protocolId, + ); + return $protocol && strtolower($protocol->name) === "saml"; + } + + public function save(bool $goBack = true): void + { + $this->form->validate(); + + $settings = null; + if ($this->isSaml()) { + $settings = [ + "saml" => [ + "entity_id" => $this->form->samlEntityId, + "acs_url" => $this->form->samlAcsUrl, + ], + ]; + } + + $data = new ConnectAppRequest( + name: $this->form->name, + connectionProviderId: $this->form->providerId, + connectionProtocolId: $this->form->protocolId, + slug: str($this->form->name)->slug()->toString(), + connectionStatusId: $this->form->statusId, + settings: $settings, + ); + $this->attempt( + action: function () use ($data, $goBack) { + $this->appService->create($data); + $this->form->reset(); + if ($goBack) { + $this->goBack(); + } + }, + ); + } + + public function create() {} + + public function goBack(): void + { + $this->redirectRoute("apps.index", navigate: true); + } +}; +?> + +
+ + + + + + + + @if($this->isSaml) +
+

SAML Configuration

+ + +
+ @endif + +
+ Cancel + Save & Add Another + + Save + +
+
+
+
diff --git a/resources/views/pages/connected-apps/⚡edit.blade.php b/resources/views/pages/connected-apps/⚡edit.blade.php new file mode 100644 index 0000000..d915422 --- /dev/null +++ b/resources/views/pages/connected-apps/⚡edit.blade.php @@ -0,0 +1,173 @@ +service = $service; + } + + public function mount(int $id): void + { + $this->attempt( + action: function () use ($id) { + $record = $this->service->getConnectedApp($id); + $this->form->set($record); + $this->recordId = $id; + }, + onError: function () { + $this->redirectRoute("apps.index", navigate: true); + }, + showSuccess: false, + ); + } + + #[Computed] + public function providers() + { + return ConnectionProvider::query()->get(); + } + + #[Computed] + public function protocols(): Collection + { + $protocolService = app(ConnectionProtocolService::class); + + return $protocolService + ->getAll() + ->map(function (ConnectionProtocol $protocol) { + $protocol->name = strtoupper($protocol->name); + return $protocol; + }); + } + + #[Computed] + public function isSaml(): bool + { + $protocol = $this->protocols()->firstWhere( + "id", + (int) $this->form->protocolId, + ); + return $protocol && strtolower($protocol->name) === "saml"; + } + + /** + * @returns DataCollection + */ + #[Computed] + public function connectionStatuses(): DataCollection + { + $statusService = app(ConnectionStatusService::class); + + return $statusService->getAll(); + } + + public function save(): void + { + $this->attempt( + action: function () { + $this->form->validate(); + + $settings = null; + if ($this->isSaml()) { + $settings = [ + "saml" => [ + "entity_id" => $this->form->samlEntityId, + "acs_url" => $this->form->samlAcsUrl, + ], + ]; + } + + $serviceData = new ConnectAppRequest( + name: $this->form->name, + connectionProviderId: $this->form->providerId, + connectionProtocolId: $this->form->protocolId, + slug: str($this->form->name)->slug()->toString(), + connectionStatusId: $this->form->statusId, + settings: $settings, + ); + $this->service->update($this->recordId, $serviceData); + $this->redirectRoute("apps.index", navigate: true); + }, + ); + } +}; +?> + +
+ + +
+ + + + + + + @if($this->isSaml) +
+

SAML Configuration

+ + +
+ @endif +
+ + + {{ __('Cancel') }} + + + {{ __('Save') }} + + +
+
+
diff --git a/resources/views/pages/connected-apps/⚡index.blade.php b/resources/views/pages/connected-apps/⚡index.blade.php new file mode 100644 index 0000000..286aea1 --- /dev/null +++ b/resources/views/pages/connected-apps/⚡index.blade.php @@ -0,0 +1,100 @@ +service = $service; + } + + public function mount(): void + { + $this->initTableHeaders(); + } + + private function initTableHeaders(): void + { + $this->headers = TableHeader::collect([ + new TableHeader(key: 'name', label: 'Name',), + new TableHeader(key: 'provider', label: 'Provider'), + new TableHeader(key: 'protocol', label: 'Protocol'), + new TableHeader(key: 'status', label: 'Status'), + ], DataCollection::class); + } + + /** + * @return PaginatedDataCollection + */ + #[Computed] + public function getConnectedApps(): PaginatedDataCollection + { + return $this->service->getAll(); + } + + public function edit(int $appId): void + { + $this->redirectRoute('apps.edit', ['id' => $appId], navigate: true); + } + + public function delete(int $appId): void + { + $this->attempt( + action: fn() => $this->service->delete($appId), + successMessage: 'The app has been deleted !' + ); + } +}; +?> + +
+ + + Add App + + + + @scope('cell_protocol', $row) + {{$row->protocol->name}} + @endscope + + @scope('cell_status', $row) + + @endscope + @scope('actions', $row) +
+ + +
+ @endscope +
+
+
diff --git a/resources/views/pages/connecton-providers/⚡create.blade.php b/resources/views/pages/connecton-providers/⚡create.blade.php new file mode 100644 index 0000000..94a6a9a --- /dev/null +++ b/resources/views/pages/connecton-providers/⚡create.blade.php @@ -0,0 +1,50 @@ +service = $service; + } + + public function save(): void + { + $this->attempt( + action: function () { + $this->form->validate(); + $data = new StoreConnectionProviderRequestData( + name: $this->form->name, + ); + $this->service->create($data); + $this->form->reset(); + $this->redirectRoute('apps.connectionProviders.index', navigate: true); + }, + ); + } +}; +?> + +
+ + + + + + + + +
diff --git a/resources/views/pages/connecton-providers/⚡edit.blade.php b/resources/views/pages/connecton-providers/⚡edit.blade.php new file mode 100644 index 0000000..3f0e286 --- /dev/null +++ b/resources/views/pages/connecton-providers/⚡edit.blade.php @@ -0,0 +1,60 @@ +service = $service; + } + + public function mount(string $slug): void + { + $this->currentProviderSlug = $slug; + $data = $this->service->getProvider($slug); + $this->form->set($data); + } + + public function save(): void + { + $this->attempt( + action: function () { + $this->form->validate(); + $data = new StoreConnectionProviderRequestData( + name: $this->form->name, + ); + $this->service->update($this->currentProviderSlug, $data); + $this->redirectRoute('apps.connectionProviders.index', navigate: true); + }, + ); + } +}; +?> + +
+ + + + + + + + +
diff --git a/resources/views/pages/connecton-providers/⚡index.blade.php b/resources/views/pages/connecton-providers/⚡index.blade.php new file mode 100644 index 0000000..b200d2b --- /dev/null +++ b/resources/views/pages/connecton-providers/⚡index.blade.php @@ -0,0 +1,89 @@ +service = $service; + } + + public function mount(): void + { + $this->initTableHeaders(); + } + + private function initTableHeaders(): void + { + $this->headers = TableHeader::collect([ + new TableHeader(key: 'name', label: 'Name',), + ], DataCollection::class); + } + + #[Computed] + public function getAllProviders() + { + return $this->service->getAll(); + } + + public function edit(string $providerSlug): void + { + $this->redirectRoute('apps.connectionProviders.edit', ['slug' => $providerSlug]); + } + + public function delete(): void + { + $this->attempt( + action: function () { + $this->service->delete($this->selectedProvider); + $this->selectedProvider = ''; + } + ); + } + + public function showModal(string $providerSlug): void + { + $this->selectedProvider = $providerSlug; + $this->requireConfirmation('delete', description: 'This will delete all the connected apps of this provider.'); + } +}; +?> + +
+ + + Add + Provider + + + + @scope('actions', $row) +
+ + +
+ @endscope +
+
+
diff --git a/resources/views/pages/connecton-providers/⚡microsoft-federation.blade.php b/resources/views/pages/connecton-providers/⚡microsoft-federation.blade.php new file mode 100644 index 0000000..08c993c --- /dev/null +++ b/resources/views/pages/connecton-providers/⚡microsoft-federation.blade.php @@ -0,0 +1,587 @@ +service = $service; + } + + #[Computed] + public function mfaBehaviorOptions(): array + { + return array_map(fn($case) => [ + 'id' => $case->value, + 'name' => match ($case) { + FederatedIdpMfaBehaviorEnum::AcceptIfMfaDoneByFederatedIdp => 'Accept if MFA Done by Federated IdP', + FederatedIdpMfaBehaviorEnum::EnforceMfaByFederatedIdp => 'Enforce MFA by Federated IdP', + FederatedIdpMfaBehaviorEnum::RejectMfaByFederatedIdp => 'Always perform MFA (Reject Federated IdP MFA)', + }, + ], FederatedIdpMfaBehaviorEnum::cases()); + } + + public function mount(): void + { + $config = config('services.microsoft_graph'); + + $this->tenantId = (string) ($config['tenant_id'] ?? ''); + $this->clientId = (string) ($config['client_id'] ?? ''); + $this->clientSecret = (string) ($config['client_secret'] ?? ''); + $this->domainName = (string) ($config['domain_name'] ?? ''); + $this->displayName = (string) ($config['display_name'] ?? 'Company SSO'); + $this->mfaBehavior = FederatedIdpMfaBehaviorEnum::tryFrom( + (string) ($config['mfa_behavior'] ?? '') + ) ?? FederatedIdpMfaBehaviorEnum::AcceptIfMfaDoneByFederatedIdp; + + if ( + !empty($this->tenantId) && + !empty($this->clientId) && + !empty($this->clientSecret) && + !empty($this->domainName) + ) { + $this->credentialsLoaded = true; + } + + if (file_exists(storage_path("app/saml/idp.crt"))) { + $this->certificateLoaded = true; + } + + if ($this->credentialsLoaded) { + $this->checkStatus(true); + } + } + + public function checkStatus(bool $silent = false): void + { + $this->attempt( + action: function () use ($silent) { + $credentials = new MicrosoftGraphCredentialsData( + tenantId: $this->tenantId, + clientId: $this->clientId, + clientSecret: $this->clientSecret, + domainName: $this->domainName, + displayName: $this->displayName, + mfaBehavior: $this->mfaBehavior, + ); + + $result = $this->service->getFederationConfig($credentials); + + foreach ($result["transactions"] as $tx) { + $this->debugResponses[] = array_merge($tx, [ + "timestamp" => now()->format("Y-m-d H:i:s"), + ]); + } + + if ($result["status"] === "success") { + if ($result["config"] !== null) { + $this->connectionStatus = + ConnectionStatusEnum::Connected; + $this->federationId = $result["config"]["id"]; + $this->activeFederationConfig = $result["config"]; + if (!$silent) { + $this->success( + "Federation is active on Microsoft Entra ID!", + ); + } + } else { + $this->connectionStatus = + ConnectionStatusEnum::Disconnected; + $this->federationId = null; + $this->activeFederationConfig = null; + if (!$silent) { + $this->success( + "Domain is managed. No federation configuration found.", + ); + } + } + } else { + $this->connectionStatus = ConnectionStatusEnum::Error; + if (!$silent) { + $this->error( + $result["message"] ?? "Failed to check status.", + ); + } + } + }, + showSuccess: false, + ); + } + + public function connect(): void + { + if (!$this->certificateLoaded) { + $this->error( + "Missing public certificate! Run php artisan saml:generate-keys first.", + ); + return; + } + + $this->attempt( + action: function () { + $credentials = new MicrosoftGraphCredentialsData( + tenantId: $this->tenantId, + clientId: $this->clientId, + clientSecret: $this->clientSecret, + domainName: $this->domainName, + displayName: $this->displayName, + mfaBehavior: $this->mfaBehavior, + ); + + $certPem = file_get_contents(storage_path("app/saml/idp.crt")); + $issuerUri = route("saml.metadata"); + $passiveSignInUri = route("saml.sso"); + $activeSignInUri = route("saml.sso"); + $signOutUri = route("home"); + + $result = $this->service->connectFederation( + credentials: $credentials, + certPem: $certPem, + issuerUri: $issuerUri, + passiveSignInUri: $passiveSignInUri, + activeSignInUri: $activeSignInUri, + signOutUri: $signOutUri, + ); + + foreach ($result["transactions"] as $tx) { + $this->debugResponses[] = array_merge($tx, [ + "timestamp" => now()->format("Y-m-d H:i:s"), + ]); + } + + if ($result["status"] === "success") { + $this->connectionStatus = ConnectionStatusEnum::Connected; + $this->federationId = $result["config"]["id"] ?? null; + $this->activeFederationConfig = $result["config"]; + $this->success( + "SAML IDP successfully federated with Microsoft Entra ID!", + ); + } else { + $this->connectionStatus = ConnectionStatusEnum::Error; + $this->error( + $result["message"] ?? "Failed to connect federation.", + ); + } + }, + showSuccess: false, + ); + } + + public function disconnect(): void + { + if (empty($this->federationId)) { + $this->error("No active federation ID found. Check status first."); + return; + } + + $this->attempt( + action: function () { + $credentials = new MicrosoftGraphCredentialsData( + tenantId: $this->tenantId, + clientId: $this->clientId, + clientSecret: $this->clientSecret, + domainName: $this->domainName, + displayName: $this->displayName, + mfaBehavior: $this->mfaBehavior, + ); + + $result = $this->service->disconnectFederation( + $credentials, + $this->federationId, + ); + + foreach ($result["transactions"] as $tx) { + $this->debugResponses[] = array_merge($tx, [ + "timestamp" => now()->format("Y-m-d H:i:s"), + ]); + } + + if ($result["status"] === "success") { + $this->connectionStatus = + ConnectionStatusEnum::Disconnected; + $this->federationId = null; + $this->activeFederationConfig = null; + $this->success( + "SAML IDP disconnected from Microsoft Entra ID. Domain returned to Managed.", + ); + } else { + $this->connectionStatus = ConnectionStatusEnum::Error; + $this->error( + $result["message"] ?? + "Failed to disconnect federation.", + ); + } + }, + showSuccess: false, + ); + } + + public function clearDebug(): void + { + $this->debugResponses = []; + $this->success("Debug outputs cleared."); + } +}; +?> + +
+
+ + @if(!$credentialsLoaded) +
+ +
+

Environment Configuration Required

+

+ To enable the Microsoft Graph API integration, please configure the environment, to include + needed values. +

+
+
+ @endif + + @if($credentialsLoaded && !$certificateLoaded) +
+ +
+

SAML Signing Certificate Missing

+

+ A verified public certificate is required to sign SAML requests sent to Microsoft Entra ID. No + certificate was found. +

+
+
+ @endif + + +
+ +
+ + +
+ Status: + @if($connectionStatus === ConnectionStatusEnum::Connected) + + + Federated + + @elseif($connectionStatus === ConnectionStatusEnum::Disconnected) + + + Managed + + @elseif($connectionStatus === ConnectionStatusEnum::Pending) + + + Connecting... + + @else + + + Error + + @endif +
+
+ +
+
+
+ + MICROSOFT_GRAPH_TENANT_ID +
+ +
+ + MICROSOFT_GRAPH_CLIENT_ID +
+ +
+ + MICROSOFT_GRAPH_CLIENT_SECRET +
+ +
+ + MICROSOFT_GRAPH_DOMAIN_NAME +
+ +
+ + MICROSOFT_GRAPH_DISPLAY_NAME +
+ +
+ + MICROSOFT_GRAPH_MFA_BEHAVIOR +
+
+ + +
+

+ + local Identity Provider Endpoints +

+
+
+ Issuer URI (Entity ID) + {{ route('saml.metadata') }} +
+
+ Passive Sign-In Endpoint (SSO) + {{ route('saml.sso') }} +
+
+ Active Sign-In Endpoint (WS-Trust) + {{ route('saml.sso') }} +
+
+ Sign-Out URI + {{ route('home') }} +
+
+
+ + +
+ + Check Status + + +
+ @if($connectionStatus === ConnectionStatusEnum::Connected) + + Disconnect Federation + + @else + + Connect & Federate + + @endif +
+
+
+
+
+ + +
+ +
+ @if($activeFederationConfig) +
+
+ Federation ID + {{ Str::limit($activeFederationConfig['id'], 18) }} +
+
+ Preferred Protocol + {{ $activeFederationConfig['preferredAuthenticationProtocol'] ?? 'Unknown' }} +
+
+ Entra MFA Rule + {{ $activeFederationConfig['federatedIdpMfaBehavior'] ?? 'Unknown' }} +
+
+ Active Signing Certificate Thumbprint +
+ {{ hash('sha1', base64_decode($activeFederationConfig['signingCertificate'])) }} +
+
+
+ @else +
+
+ +
+

No Active Federation

+

Connect this IDP + to Microsoft Entra to sync active configurations.

+
+ @endif +
+
+
+
+ + + + + @if(count($debugResponses) > 0) + + Clear Logs + + @endif + + +
+ @if(count($debugResponses) > 0) +
+ @foreach(array_reverse($debugResponses) as $index => $log) +
+ + + + +
+ + @if(!empty($log['request_headers']) || !empty($log['request_body'])) +
+ @if(!empty($log['request_headers'])) +
+ Request Headers +
{{ json_encode($log['request_headers'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}
+
+ @endif + @if(!empty($log['request_body'])) +
+ Request Payload +
{{ json_encode($log['request_body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}
+
+ @endif +
+ @endif + + +
+ Response Body Payload +
{{ json_encode($log['response_body'], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) }}
+
+
+
+ @endforeach +
+ @else +
+
+ +
+

Terminal Output Empty

+

Federation + transactions will appear here as HTTP raw records when you click Check, Connect, or + Disconnect.

+
+ @endif +
+
+
+
+ + diff --git a/resources/views/pages/⚡dashboard.blade.php b/resources/views/pages/dashboards/⚡admin.blade.php similarity index 55% rename from resources/views/pages/⚡dashboard.blade.php rename to resources/views/pages/dashboards/⚡admin.blade.php index 5b9ef98..5864e6f 100644 --- a/resources/views/pages/⚡dashboard.blade.php +++ b/resources/views/pages/dashboards/⚡admin.blade.php @@ -6,16 +6,17 @@ use Livewire\Component; new -#[Layout('layouts.app')] +#[Layout('layouts.app.sidebar')] #[Title('Dashboard')] -#[Middleware('auth,verified')] class extends Component { }; ?>
- - - + + + + +
diff --git a/resources/views/pages/dashboards/⚡user.blade.php b/resources/views/pages/dashboards/⚡user.blade.php new file mode 100644 index 0000000..38dd3c7 --- /dev/null +++ b/resources/views/pages/dashboards/⚡user.blade.php @@ -0,0 +1,152 @@ +service = $service; + } + + public function mount(): void + { + $user = auth()->user(); + + // Using spatie/laravel-activitylog v5 to log dashboard access + if ($user) { + activity() + ->causedBy($user) + ->event('dashboard_access') + ->log('User accessed their assigned services dashboard.'); + } + } + + #[Computed] + public function services(): Collection + { + $user = auth()->user(); + + if (!$user) { + return collect(); + } + + return $this->service->getActiveServices($user); + } +}; +?> + +
+ + + +

+ Manage and access your authenticated single sign-on services. +

+
+
+ +
+ + Active Account + + + + {{ now()->format('F j, Y') }} + +
+
+ + @if($this->services->isEmpty()) + +
+ +
+

No Active Services Assigned

+

+ Your account does not currently have any active or visible services assigned by your role, or your + access duration has expired. +

+ + To request access to specific applications, please contact your system administrator or IT helpdesk with + your account details + +
+ @else +
+ @foreach($this->services as $service) + + + @if($service->is_warning) + + + Expiring Soon + + @else + + Active + + @endif + + +
+
+

+ {{ $service->slug }} +

+
+ +
+
+ + + {{ $service->days_remaining }} {{ Str::plural('day', $service->days_remaining) }} left + +
+ +
+ @if($service->is_warning) + + @endif + + +
+
+
+
+ @endforeach +
+ @endif +
diff --git a/resources/views/pages/tickets/⚡index.blade.php b/resources/views/pages/tickets/⚡index.blade.php new file mode 100644 index 0000000..b2abfb4 --- /dev/null +++ b/resources/views/pages/tickets/⚡index.blade.php @@ -0,0 +1,1107 @@ +resetPage(); + } + + public function updatedStatusFilter(): void + { + $this->resetPage(); + } + + public function boot(TicketService $ticketService, UserAppAccessService $appAccessService): void + { + $this->ticketService = $ticketService; + $this->appAccessService = $appAccessService; + } + + public function mount(): void + { + // Pre-populate dynamic choice lists + $this->searchApps(); + $this->searchRoles(); + $this->searchUsers(); + $this->searchAssignableUsers(); + + // Select first role by default in notified roles array if empty + $defaultRole = Role::where('name', 'Support')->first(); + if ($defaultRole && empty($this->form->notifiedRoleIds)) { + $this->form->notifiedRoleIds = [$defaultRole->id]; + } + + // Auto-open drawer if requested from URL + if ($this->raise) { + $this->drawerOpen = true; + if ($this->appId) { + $this->form->connectedAppId = $this->appId; + } + } + + // Auto-open ticket if requested from URL + if ($this->ticketParam && (int) $this->ticketParam > 0) { + $this->showTicket((int) $this->ticketParam); + } + } + + /** + * Re-hydrate searchable arrays before every render to prevent losing selections. + */ + public function rendering(): void + { + if (empty($this->appsSearchable)) { + $this->searchApps(); + } + if (empty($this->rolesSearchable)) { + $this->searchRoles(); + } + if (empty($this->usersSearchable)) { + $this->searchUsers(); + } + if (empty($this->assignableUsersSearchable)) { + $this->searchAssignableUsers($this->assignableUsersSearchTerm); + } + } + + /** + * Compute visible tickets based on permissions. + */ + #[Computed] + public function tickets(): PaginatedDataCollection + { + $user = auth()->user(); + if (!$user) { + return TicketListData::collect(collect(), PaginatedDataCollection::class); + } + + $search = trim($this->search); + $search = empty($search) ? null : $search; + + if ($user->hasRole('Admin') || $user->hasRole('admin') || $user->can(TicketPermissionEnum::Read->value)) { + return $this->ticketService->getList($search, $this->statusFilter, 10); + } + + return $this->ticketService->getListForUser($user->id, $search, $this->statusFilter, 10); + } + + /** + * Compute expiring apps for the current user. + */ + #[Computed] + public function eligibleApps(): Collection + { + $user = auth()->user(); + if (!$user) { + return collect(); + } + + return $this->appAccessService->getActiveServices($user) + ->filter(fn($app) => $app->is_warning); + } + + /** + * Compute available ticket statuses for filtering. + */ + #[Computed] + public function statuses(): Collection + { + return TicketStatus::query()->get(); + } + + /** + * Search connected apps for mary-choices select dropdown. + */ + public function searchApps(string $value = ''): void + { + $selectedId = $this->form->connectedAppId; + $apps = $this->eligibleApps(); + + if ($selectedId && !$apps->contains('id', $selectedId)) { + $selectedApp = ConnectedApp::find($selectedId); + if ($selectedApp) { + $apps->push($selectedApp); + } + } + + $this->appsSearchable = $apps + ->when('' !== $value, + fn($col) => $col->filter(fn($app) => str_contains(strtolower($app->name), strtolower($value)))) + ->map(fn($app) => [ + 'id' => $app->id, + 'name' => $app->name.(isset($app->days_remaining) ? ' ('.$app->days_remaining.' days left)' : ''), + ]) + ->toArray(); + } + + /** + * Search roles for mary-choices select dropdown. + */ + public function searchRoles(string $value = ''): void + { + $selectedIds = $this->form->notifiedRoleIds; + + $this->rolesSearchable = Role::query() + ->where(function ($query) use ($value, $selectedIds): void { + if ('' !== $value) { + $query->where('name', 'like', "%{$value}%"); + } + if (!empty($selectedIds)) { + $query->orWhereIn('id', $selectedIds); + } + }) + ->select('id', 'name') + ->get() + ->toArray(); + } + + /** + * Search users for mary-choices select dropdown. + */ + public function searchUsers(string $value = ''): void + { + $selectedIds = $this->form->notifiedUserIds; + + $this->usersSearchable = User::query() + ->where('id', '!=', auth()->id()) + ->where(function ($query) use ($value, $selectedIds): void { + if ('' !== $value) { + $query->where('name', 'like', "%{$value}%") + ->orWhere('email', 'like', "%{$value}%"); + } + if (!empty($selectedIds)) { + $query->orWhereIn('id', $selectedIds); + } + }) + ->select('id', 'name') + ->get() + ->toArray(); + } + + /** + * Search assignable users dynamically with incremental pagination capability. + */ + public function searchAssignableUsers(string $value = ''): void + { + // Reset page limits if search keyword shifts + if ($this->assignableUsersSearchTerm !== $value) { + $this->assignableUsersSearchTerm = $value; + $this->assignableUsersPerPage = 10; + } + + $query = User::query(); + + $currentAssigneeId = $this->assignedUserId; + if (is_array($currentAssigneeId)) { + $currentAssigneeId = !empty($currentAssigneeId) ? $currentAssigneeId[0] : null; + } + + if ('' !== $value) { + $query->where(function ($q) use ($value) { + $q->where('name', 'like', "%{$value}%") + ->orWhere('email', 'like', "%{$value}%"); + }); + + if ($currentAssigneeId && (int) $currentAssigneeId > 0) { + $query->orWhere('id', (int) $currentAssigneeId); + } + } + + // Track total count matching query criteria to determine if "Load More" exists + $totalMatches = $query->count(); + $this->assignableUsersHasMore = $totalMatches > $this->assignableUsersPerPage; + + $users = $query + ->select('id', 'name') + ->limit($this->assignableUsersPerPage) + ->get(); + + // If there is no search term and we have an assigned user, ensure they are in the list + if ('' === $value && $currentAssigneeId && (int) $currentAssigneeId > 0) { + if (!$users->contains('id', (int) $currentAssigneeId)) { + $assignedUser = User::find($currentAssigneeId); + if ($assignedUser) { + $users->push($assignedUser); + } + } + } + + $this->assignableUsersSearchable = $users + ->map(fn($u) => ['id' => $u->id, 'name' => $u->name]) + ->toArray(); + } + + /** + * Increments the page window for assignable users dropdown. + */ + public function loadMoreAssignableUsers(): void + { + $this->assignableUsersPerPage += 10; + $this->searchAssignableUsers($this->assignableUsersSearchTerm); + } + + /** + * Select all matching assignable users based on current search context. + */ + public function selectAllAssignableUsers(): void + { + // Pluck IDs matching current search context criteria + $matchedIds = User::query() + ->when('' !== $this->assignableUsersSearchTerm, function ($q) { + $q->where('name', 'like', "%{$this->assignableUsersSearchTerm}%") + ->orWhere('email', 'like', "%{$this->assignableUsersSearchTerm}%"); + }) + ->pluck('id') + ->toArray(); + + // Assign the first matched record or pass the array depending on single/multiple context. + // Since updateAssignment expects a single nullable ID, we take the primary match or convert accordingly. + $this->assignedUserId = !empty($matchedIds) ? (int) $matchedIds[0] : null; + $this->updateAssignment(); + } + + /** + * Clear assignment selection. + */ + public function clearAssignableUsers(): void + { + $this->assignedUserId = null; + $this->updateAssignment(); + } + + public function selectAllRoles(): void + { + $this->form->notifiedRoleIds = Role::query()->pluck('id')->toArray(); + $this->searchRoles(); + } + + public function clearRoles(): void + { + $this->form->notifiedRoleIds = []; + } + + public function selectAllUsers(): void + { + $this->form->notifiedUserIds = User::query() + ->where('id', '!=', auth()->id()) + ->pluck('id') + ->toArray(); + $this->searchUsers(); + } + + public function clearUsers(): void + { + $this->form->notifiedUserIds = []; + } + + public function saveTicket(): void + { + $this->form->validate(); + + $dto = new CreateTicketRequest( + userId: (int) auth()->id(), + connectedAppId: $this->form->connectedAppId ? (int) $this->form->connectedAppId : null, + issueCategory: $this->form->issueCategory, + description: $this->form->description ? trim($this->form->description) : null, + notifiedRoleIds: $this->form->routingType === 'role' ? array_map('intval', + $this->form->notifiedRoleIds) : [], + notifiedUserIds: $this->form->routingType === 'user' ? array_map('intval', + $this->form->notifiedUserIds) : [], + attachment: $this->form->attachment, + ); + + $this->attempt( + action: function () use ($dto): void { + $this->ticketService->create($dto); + $this->form->reset(); + $this->drawerOpen = false; + $this->raise = false; + $this->appId = null; + + $this->searchApps(); + $this->searchRoles(); + $this->searchUsers(); + }, + successMessage: 'Support Ticket has been raised successfully!' + ); + } + + public function showTicket(int $id): void + { + $this->attempt( + action: function () use ($id): void { + $this->selectedTicketId = $id; + $this->selectedTicket = $this->ticketService->getTicket($id); + $this->assignedUserId = $this->selectedTicket->assignedUserId; + $this->replyMessage = ''; + + // Keep assignment lookup synchronized upon details injection + $this->searchAssignableUsers(); + $this->detailsModalOpen = true; + }, + showSuccess: false + ); + } + + public function changeStatus(int $ticketId, string $statusSlug): void + { + $this->attempt( + action: function () use ($ticketId, $statusSlug): void { + $this->ticketService->updateStatus($ticketId, $statusSlug); + if ($this->selectedTicketId === $ticketId) { + $this->selectedTicket = $this->ticketService->getTicket($ticketId); + } + }, + successMessage: 'Ticket status updated successfully!' + ); + } + + public function saveReply(): void + { + $this->validate(['replyMessage' => 'required|string|min:1']); + + $this->attempt( + action: function (): void { + $dto = new PostReplyRequest( + ticketId: $this->selectedTicketId, + userId: (int) auth()->id(), + message: $this->replyMessage, + ); + + $this->ticketService->postReply($dto); + $this->replyMessage = ''; + $this->selectedTicket = $this->ticketService->getTicket($this->selectedTicketId); + }, + successMessage: 'Comment posted successfully!' + ); + } + + public function updateAssignment(): void + { + $this->attempt( + action: function (): void { + $assignedId = $this->assignedUserId; + if (is_array($assignedId)) { + $assignedId = !empty($assignedId) ? $assignedId[0] : null; + } + $finalAssignedUserId = ($assignedId && (int) $assignedId > 0) ? (int) $assignedId : null; + + $this->ticketService->assignUser( + $this->selectedTicketId, + $finalAssignedUserId + ); + $this->selectedTicket = $this->ticketService->getTicket($this->selectedTicketId); + + // Refresh list options with correct paginated users + $this->searchAssignableUsers($this->assignableUsersSearchTerm); + }, + successMessage: 'Ticket assignment updated successfully!' + ); + } + + public function updatedAssignedUserId($value): void + { + $this->updateAssignment(); + } + + #[Computed] + public function canComment(): bool + { + if (!$this->selectedTicket) { + return false; + } + + $user = auth()->user(); + if (!$user) { + return false; + } + + if ($user->hasRole(DefaultUserRole::Admin)) { + return true; + } + + if ($this->selectedTicket->userId === $user->id) { + return true; + } + + if ($this->selectedTicket->assignedUserId === $user->id) { + return true; + } + + $ticket = Ticket::find($this->selectedTicketId); + if ($ticket) { + if ($ticket->notifiedUsers()->where('users.id', $user->id)->exists()) { + return true; + } + if ($ticket->notifiedRoles()->whereIn('roles.id', $user->roles->pluck('id')->toArray())->exists()) { + return true; + } + } + + return false; + } + + public function toggleDrawer(): void + { + $this->drawerOpen = !$this->drawerOpen; + if (!$this->drawerOpen) { + $this->reset(['raise', 'appId']); + $this->form->reset(); + } + } +}; +?> + +
+ @if(!auth()->user()->hasRole('Admin') && !auth()->user()->hasRole('admin') && $this->eligibleApps->isEmpty()) + + You do not currently have any active services expiring within 3 days. You can still submit a general + inquiry ticket without choosing a service. + + @endif + + + + + + + + + + Raise Ticket + + + @if($this->tickets->items()->isEmpty()) +
+
+ +
+ @if(!empty(trim($search)) || $statusFilter !== 'all') +

No Matching Tickets Found

+

+ We couldn't find any support tickets matching your search query or filter criteria. Try + adjusting your search term or status filter. +

+ @else +

No Support Tickets Found

+

+ You have not raised any support tickets yet. Click "Raise Ticket" to submit your renewal + queries. +

+ @endif +
+ @else + + @scope('cell_ticketId', $row) + + @endscope + + @scope('cell_appName', $row) + @if($row->appName) + {{ $row->appName }} + @else + General Support + @endif + @endscope + + @scope('cell_statusName', $row) + @php + $badgeClass = match($row->status) { + TicketStatusEnum::Open => 'badge-warning badge-soft', + TicketStatusEnum::InProgress => 'badge-info badge-soft', + TicketStatusEnum::Resolved => 'badge-success badge-soft', + TicketStatusEnum::Closed => 'badge-neutral badge-soft', + TicketStatusEnum::Hold => 'badge-warning badge-soft', + default => 'badge-ghost', + }; + @endphp + + {{ $row->status->label() }} + + @endscope + + @scope('cell_createdAt', $row) + + {{ Carbon\Carbon::parse($row->createdAt)->diffForHumans() }} + + @endscope + + @scope('actions', $row) +
+ + + @if(auth()->user()->hasRole('Admin') || auth()->user()->hasRole('admin') || auth()->user()->can('ticket:update') || $row->assignedUserId === auth()->id()) + + + + + @foreach($this->statuses as $status) + @php + $enumCase = TicketStatusEnum::tryFrom($status->name); + @endphp + @if($enumCase && $enumCase !== $row->status) + + @endif + @endforeach + + @endif +
+ @endscope +
+ @endif +
+ + + + + + + + + + + + @if($form->issueCategory === 'Others') + +
+ +
+ + +
+ + @if($form->attachment) +
+ + File successfully uploaded. +
+ @endif +
+ @else + +
+ +
+ @endif + +
+ + +
+ + + +
+ + + +
+ + @if($form->routingType === 'role') +
+ +
+ @else +
+ +
+ @endif +
+ + + + + + +
+
+ + + + @if($selectedTicket) +
+ +
+ +
+
+ Support Ticket +

{{ $selectedTicket->ticketId }}

+
+ + @php + $badgeClass = match($selectedTicket->status) { + TicketStatusEnum::Open => 'badge-warning badge-soft', + TicketStatusEnum::InProgress => 'badge-info badge-soft', + TicketStatusEnum::Resolved => 'badge-success badge-soft', + TicketStatusEnum::Closed => 'badge-neutral badge-soft', + TicketStatusEnum::Hold => 'badge-warning badge-soft', + default => 'badge-ghost', + }; + @endphp +
+ + {{ $selectedTicket->status->label() }} + + Raised {{ Carbon\Carbon::parse($selectedTicket->createdAt)->diffForHumans() }} +
+
+ + +
+
+ Customer +

{{ $selectedTicket->userName }}

+
+ +
+ Associated Service +

{{ $selectedTicket->appName ?? 'General Inquiry' }}

+
+ +
+ Issue Category +

{{ $selectedTicket->issueCategory }}

+
+ + +
+
+ Assigned Support + + + Saving... + +
+ @if(auth()->user()->hasRole(DefaultUserRole::Admin)) +
+ +
+ @else + @if($selectedTicket->assignedUserName) +
+ + + {{ $selectedTicket->assignedUserName }} + +
+ @else + + Unassigned + + @endif + @endif +
+ +
+ Notified Recipient(s) + @if(!empty($selectedTicket->notifiedUserNames)) +
+ + + Users: + + @foreach($selectedTicket->notifiedUserNames as $name) + + @endforeach +
+ @endif + @if(!empty($selectedTicket->notifiedRoleNames)) +
+ + + Roles: + + @foreach($selectedTicket->notifiedRoleNames as $name) + + @endforeach +
+ @endif + @if(empty($selectedTicket->notifiedUserNames) && empty($selectedTicket->notifiedRoleNames)) +

None specified

+ @endif +
+
+ + +
+ Description Details + @php + $description = $selectedTicket->description ?? 'No custom details provided.'; + $words = preg_split('/\s+/', trim($description)); + $wordCount = count($words); + $isLong = $wordCount > 50; + + if ($isLong) { + $shortDescription = implode(' ', array_slice($words, 0, 50)) . '...'; + } else { + $shortDescription = $description; + } + @endphp +
+

+ {{ $shortDescription }} +

+ @if($isLong) +

+ {{ $description }} +

+ + @endif +
+
+ + + @if(!empty($selectedTicket->attachments)) +
+ Attachments +
+ @foreach($selectedTicket->attachments as $attachment) +
+
+ @php + $icon = match(true) { + Str::contains($attachment['mime_type'], 'pdf') => 'lucide.file-text', + Str::contains($attachment['mime_type'], 'image') => 'lucide.image', + default => 'lucide.file', + }; + @endphp + +
+

+ {{ $attachment['file_name'] }} +

+

+ {{ round($attachment['file_size'] / 1024, 1) }} KB +

+
+
+ +
+ @endforeach +
+
+ @endif + + + @if(auth()->user()->hasRole('Admin') || auth()->user()->hasRole('admin') || auth()->user()->can('ticket:update') || auth()->id() === $selectedTicket->assignedUserId) +
+ Change Status +
+ @foreach($this->statuses as $status) + @php + $enumCase = TicketStatusEnum::tryFrom($status->name); + @endphp + @if($enumCase && $enumCase !== $selectedTicket->status) + + @endif + @endforeach +
+
+ @endif +
+ + +
+
+

+ + Discussion Comments +

+ + {{ count($selectedTicket->replies) }} comments + +
+ + @if($this->canComment) + +
+ @if(empty($selectedTicket->replies)) +
+ +

No comments yet

+

Be the first to start the conversation on this + support ticket.

+
+ @else + @foreach($selectedTicket->replies as $reply) + +
+ +
+
+ {{ $reply['userInitials'] }} +
+
+ + +
+
+
+ {{ $reply['userName'] }} + +
+ {{ $reply['createdAt'] }} +
+

{{ $reply['message'] }}

+
+
+ @endforeach + @endif +
+ + +
+
+ +
+ +
+ +
+ @else +
+ +

Conversation Access Restricted

+

+ Only raised/tagged participants (ticket owner, assigned support, directly notified + users, or notified-role members) have access to the discussion feed. +

+
+ @endif +
+
+ @endif +
+
diff --git a/resources/views/partials/head.blade.php b/resources/views/partials/head.blade.php index a6bdc44..52ae2f4 100644 --- a/resources/views/partials/head.blade.php +++ b/resources/views/partials/head.blade.php @@ -1,15 +1,17 @@ - - + + {{ filled($title ?? null) ? $title.' - '.config('app.name', 'Laravel') : config('app.name', 'Laravel') }} - - + @fonts @vite(['resources/css/app.css', 'resources/js/app.js']) @livewireStyles +{{--For date picker in maryui. Remove this if opt for native --}} + + diff --git a/resources/views/partials/settings-heading.blade.php b/resources/views/partials/settings-heading.blade.php deleted file mode 100644 index 925ace9..0000000 --- a/resources/views/partials/settings-heading.blade.php +++ /dev/null @@ -1,5 +0,0 @@ -
- {{ __('Settings') }} - {{ __('Manage your profile and account settings') }} - -
diff --git a/resources/views/saml/post_response.blade.php b/resources/views/saml/post_response.blade.php new file mode 100644 index 0000000..7ca70ec --- /dev/null +++ b/resources/views/saml/post_response.blade.php @@ -0,0 +1,85 @@ + + + + + Redirecting to {{ $appName }}... + + + +
+
+

Securely connecting to {{ $appName }}

+

You are being authenticated. We are securely transferring your session, please do not close this window.

+ +
+ + @if($relayState) + + @endif + +
+
+ + diff --git a/routes/settings.php b/routes/settings.php index 1fdabe3..3dbaa9e 100644 --- a/routes/settings.php +++ b/routes/settings.php @@ -2,29 +2,42 @@ declare(strict_types=1); -use App\Livewire\Settings\Appearance; -use App\Livewire\Settings\Profile; -use App\Livewire\Settings\Security; +use App\Enums\Permissions\{ProfilePermissionEnum, SecurityPermissionEnum}; +use App\Livewire\Settings\{Appearance, Profile, Security}; use Illuminate\Support\Facades\Route; use Laravel\Fortify\Features; +use Spatie\Permission\Middleware\PermissionMiddleware; -Route::middleware(['auth'])->group(function (): void { - Route::redirect('settings', 'settings/profile'); +// Grouping by the "Settings" sidebar category +Route::middleware(['auth'])->prefix('settings')->group(function (): void { - Route::livewire('settings/profile', Profile::class)->name('profile.edit'); -}); - -Route::middleware(['auth', 'verified'])->group(function (): void { - Route::livewire('settings/appearance', Appearance::class)->name('appearance.edit'); - - Route::livewire('settings/security', Security::class) - ->middleware( - when( - Features::canManageTwoFactorAuthentication() - && Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword'), - ['password.confirm'], - [], - ), - ) - ->name('security.edit'); + // Base settings redirect + Route::redirect('/', '/settings/profile')->name('settings'); + + // Profile Settings + Route::livewire('profile', Profile::class) + ->middleware(PermissionMiddleware::using(ProfilePermissionEnum::Access)) + ->name('profile.edit'); + + // Settings that require email verification + Route::middleware(['verified'])->group(function (): void { + + // Appearance Settings + Route::livewire('appearance', Appearance::class)->name('appearance.edit'); + + // Security Settings + Route::livewire('security', Security::class) + ->middleware( + array_merge( + [PermissionMiddleware::using(SecurityPermissionEnum::Access)], + when( + Features::canManageTwoFactorAuthentication() + && Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword'), + ['password.confirm'], + [], + ), + ) + ) + ->name('security.edit'); + }); }); diff --git a/routes/web.php b/routes/web.php index 91a29c1..766ebb2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -2,10 +2,79 @@ declare(strict_types=1); +use App\Enums\DefaultUserRole; +use App\Enums\Permissions\{AppPermissionEnum, ConnectionProviderPermissionEnum, PermissionPermissionEnum, RoleAndAppsPermissionEnum, UserPermissionEnum}; +use App\Http\Controllers\{ResolveDashboardRouteController, SamlIdpController}; use Illuminate\Support\Facades\Route; +use Spatie\Permission\Middleware\{PermissionMiddleware, RoleMiddleware}; Route::view('/', 'welcome')->name('home'); -Route::livewire('dashboard', 'pages::dashboard')->name('dashboard'); +Route::get('saml/metadata', [SamlIdpController::class, 'metadata'])->name('saml.metadata'); +Route::match(['get', 'post'], 'saml/sso', [SamlIdpController::class, 'sso'])->name('saml.sso'); -require __DIR__ . '/settings.php'; +Route::group(['middleware' => ['auth', 'verified']], function (): void { + Route::get('dashboard', ResolveDashboardRouteController::class)->name('dashboard'); + + Route::post('notifications/read-all', function () { + auth()->user()?->unreadNotifications->markAsRead(); + + return back(); + })->name('notifications.read-all'); + + Route::get('notifications/{id}/resolve', App\Http\Controllers\ResolveNotificationRouteController::class)->name('notifications.resolve'); + + Route::livewire('tickets', 'pages::tickets.index')->name('tickets.index'); + + Route::prefix('dashboard')->name('dashboard.')->group(function (): void { + Route::livewire('/user', 'pages::dashboards.user') + ->middleware(RoleMiddleware::using(DefaultUserRole::User)) + ->name('user'); + Route::livewire('admin', 'pages::dashboards.admin') + ->middleware(RoleMiddleware::using(DefaultUserRole::Admin)) + ->name('admin'); + }); + + Route::prefix('access-manager')->name('access-manager.')->group(function (): void { + Route::prefix('roles-and-apps')->name('roles-and-apps.') + ->middleware(PermissionMiddleware::using(RoleAndAppsPermissionEnum::Access)) + ->group(function (): void { + Route::livewire('roles-and-apps', 'pages::access-manager.roles-and-apps.index')->name('index'); + Route::livewire('roles-and-apps/{id}', 'pages::access-manager.roles-and-apps.show')->name('show'); + }); + + Route::prefix('users')->name('users.') + ->middleware(PermissionMiddleware::using(UserPermissionEnum::Access)) + ->group(function (): void { + Route::livewire('users', 'pages::access-manager.users.index')->name('index'); + }); + + Route::prefix('permissions')->name('permissions.') + ->middleware(PermissionMiddleware::using(PermissionPermissionEnum::Access)) + ->group(function (): void { + Route::livewire('/', 'pages::access-manager.permissions.index')->name('index'); + }); + }); + + Route::prefix('apps')->name('apps.')->group(function (): void { + Route::livewire('microsoft-federation', 'pages::connecton-providers.microsoft-federation') + ->middleware(PermissionMiddleware::using(ConnectionProviderPermissionEnum::Access)) + ->name('microsoft-federation'); + + Route::middleware(PermissionMiddleware::using(AppPermissionEnum::Access))->group(function (): void { + Route::livewire('create', 'pages::connected-apps.create')->name('create'); + Route::livewire('{id}/edit', 'pages::connected-apps.edit')->name('edit'); + Route::livewire('/', 'pages::connected-apps.index')->name('index'); + }); + + Route::prefix('connection-providers')->name('connectionProviders.') + ->middleware(PermissionMiddleware::using(ConnectionProviderPermissionEnum::Access)) + ->group(function (): void { + Route::livewire('create', 'pages::connecton-providers.create')->name('create'); + Route::livewire('{slug}/edit', 'pages::connecton-providers.edit')->name('edit'); + Route::livewire('index', 'pages::connecton-providers.index')->name('index'); + }); + }); +}); + +require __DIR__.'/settings.php'; diff --git a/storage/debugbar/.gitignore b/storage/debugbar/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/storage/debugbar/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore diff --git a/tests/ArchTest.php b/tests/ArchTest.php new file mode 100644 index 0000000..7746bf0 --- /dev/null +++ b/tests/ArchTest.php @@ -0,0 +1,18 @@ +expect(['ds', 'dsd', 'dsq', 'dd', 'ddd', 'dump', 'ray', 'die', 'var_dump', 'sleep', 'exit']) + ->not->toBeUsed(); + +arch('application uses strict types') + ->expect('App') + ->toUseStrictTypes(); + +arch('controllers have exact suffix') + ->expect('App\Http\Controllers') + ->toHaveSuffix('Controller'); + +arch('models do not use http helpers') + ->expect(['request', 'session', 'cookie']) + ->not->toBeUsedIn('App\Models'); diff --git a/tests/Feature/Auth/EmailVerificationTest.php b/tests/Feature/Auth/EmailVerificationTest.php index 0642168..5943fb1 100644 --- a/tests/Feature/Auth/EmailVerificationTest.php +++ b/tests/Feature/Auth/EmailVerificationTest.php @@ -4,8 +4,7 @@ use App\Models\User; use Illuminate\Auth\Events\Verified; -use Illuminate\Support\Facades\Event; -use Illuminate\Support\Facades\URL; +use Illuminate\Support\Facades\{Event, URL}; use Laravel\Fortify\Features; beforeEach(function (): void { @@ -36,7 +35,7 @@ Event::assertDispatched(Verified::class); expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); - $response->assertRedirect(route('dashboard', absolute: false) . '?verified=1'); + $response->assertRedirect(route('dashboard', absolute: false).'?verified=1'); }); test('email is not verified with invalid hash', function (): void { @@ -67,7 +66,7 @@ ); $this->actingAs($user)->get($verificationUrl) - ->assertRedirect(route('dashboard', absolute: false) . '?verified=1'); + ->assertRedirect(route('dashboard', absolute: false).'?verified=1'); expect($user->fresh()->hasVerifiedEmail())->toBeTrue(); Event::assertNotDispatched(Verified::class); diff --git a/tests/Feature/MicrosoftGraphFederationTest.php b/tests/Feature/MicrosoftGraphFederationTest.php new file mode 100644 index 0000000..1bdafb5 --- /dev/null +++ b/tests/Feature/MicrosoftGraphFederationTest.php @@ -0,0 +1,154 @@ +seed(Database\Seeders\DatabaseSeeder::class); + $this->admin = User::where('email', 'admin@example.com')->firstOrFail(); + + // Ensure mock SAML certificate directory and file exists for tests + $directory = storage_path('app/saml'); + if (! File::exists($directory)) { + File::makeDirectory($directory, 0755, true); + } + if (! File::exists("{$directory}/idp.crt")) { + File::put("{$directory}/idp.crt", "-----BEGIN CERTIFICATE-----\nMIIE3jCCAsagAwIBAgIQQcyDaZz3MI\n-----END CERTIFICATE-----"); + } +}); + +test('guest is redirected to login when visiting microsoft federation page', function (): void { + $response = $this->get(route('apps.microsoft-federation')); + + $response->assertRedirect(route('login')); +}); + +test('administrator can render microsoft federation page', function (): void { + $response = $this->actingAs($this->admin)->get(route('apps.microsoft-federation')); + + $response->assertStatus(200); +}); + +test('renders warning banner if credentials are not configured in environment', function (): void { + Config::set('services.microsoft_graph', [ + 'tenant_id' => '', + 'client_id' => '', + 'client_secret' => '', + 'domain_name' => '', + ]); + + Livewire::actingAs($this->admin) + ->test('pages::connecton-providers.microsoft-federation') + ->assertSet('credentialsLoaded', false) + ->assertSee('Environment Configuration Required'); +}); + +test('loads credentials and check status successfully if configured', function (): void { + Config::set('services.microsoft_graph', [ + 'tenant_id' => 'mock-tenant-id', + 'client_id' => 'mock-client-id', + 'client_secret' => 'mock-client-secret', + 'domain_name' => 'mockdomain.com', + 'display_name' => 'Company SSO', + 'mfa_behavior' => 'acceptIfMfaDoneByFederatedIdp', + ]); + + // Fake Microsoft Graph API responses + Http::fake([ + 'https://login.microsoftonline.com/mock-tenant-id/oauth2/v2.0/token' => Http::response([ + 'access_token' => 'mock-access-token', + 'token_type' => 'Bearer', + 'expires_in' => 3600, + ], 200), + 'https://graph.microsoft.com/v1.0/domains/mockdomain.com/federationConfiguration' => Http::response([ + 'value' => [ + [ + 'id' => 'mock-federation-id', + 'displayName' => 'Company SSO', + 'issuerUri' => 'http://localhost/saml/metadata', + 'preferredAuthenticationProtocol' => 'saml', + 'federatedIdpMfaBehavior' => 'acceptIfMfaDoneByFederatedIdp', + 'signingCertificate' => 'MIIE3jCCAsagAwIBAgIQQcyDaZz3MI', + ], + ], + ], 200), + ]); + + Livewire::actingAs($this->admin) + ->test('pages::connecton-providers.microsoft-federation') + ->assertSet('credentialsLoaded', true) + ->assertSet('connectionStatus', ConnectionStatusEnum::Connected) + ->assertSet('federationId', 'mock-federation-id') + ->assertSee('Active Federation State'); +}); + +test('can connect/federate domain successfully', function (): void { + Config::set('services.microsoft_graph', [ + 'tenant_id' => 'mock-tenant-id', + 'client_id' => 'mock-client-id', + 'client_secret' => 'mock-client-secret', + 'domain_name' => 'mockdomain.com', + 'display_name' => 'Company SSO', + 'mfa_behavior' => 'acceptIfMfaDoneByFederatedIdp', + ]); + + Http::fake([ + 'https://login.microsoftonline.com/mock-tenant-id/oauth2/v2.0/token' => Http::response([ + 'access_token' => 'mock-access-token', + ], 200), + 'https://graph.microsoft.com/v1.0/domains/mockdomain.com/federationConfiguration' => function ($request) { + if ('GET' === $request->method()) { + return Http::response(['value' => []], 200); + } + if ('POST' === $request->method()) { + return Http::response([ + 'id' => 'new-federation-id', + 'displayName' => 'Company SSO', + 'issuerUri' => 'http://localhost/saml/metadata', + 'preferredAuthenticationProtocol' => 'saml', + 'federatedIdpMfaBehavior' => 'acceptIfMfaDoneByFederatedIdp', + 'signingCertificate' => 'MIIE3jCCAsagAwIBAgIQQcyDaZz3MI', + ], 200); + } + + return Http::response(null, 404); + }, + ]); + + Livewire::actingAs($this->admin) + ->test('pages::connecton-providers.microsoft-federation') + ->set('connectionStatus', ConnectionStatusEnum::Disconnected) + ->call('connect') + ->assertSet('connectionStatus', ConnectionStatusEnum::Connected) + ->assertSet('federationId', 'new-federation-id'); +}); + +test('can disconnect federation successfully', function (): void { + Config::set('services.microsoft_graph', [ + 'tenant_id' => 'mock-tenant-id', + 'client_id' => 'mock-client-id', + 'client_secret' => 'mock-client-secret', + 'domain_name' => 'mockdomain.com', + 'display_name' => 'Company SSO', + 'mfa_behavior' => 'acceptIfMfaDoneByFederatedIdp', + ]); + + Http::fake([ + 'https://login.microsoftonline.com/mock-tenant-id/oauth2/v2.0/token' => Http::response([ + 'access_token' => 'mock-access-token', + ], 200), + 'https://graph.microsoft.com/v1.0/domains/mockdomain.com/federationConfiguration/mock-federation-id' => Http::response(null, 204), + ]); + + Livewire::actingAs($this->admin) + ->test('pages::connecton-providers.microsoft-federation') + ->set('connectionStatus', ConnectionStatusEnum::Connected) + ->set('federationId', 'mock-federation-id') + ->call('disconnect') + ->assertSet('connectionStatus', ConnectionStatusEnum::Disconnected) + ->assertSet('federationId', null); +}); diff --git a/tests/Feature/NotificationResolutionTest.php b/tests/Feature/NotificationResolutionTest.php new file mode 100644 index 0000000..40c0d1d --- /dev/null +++ b/tests/Feature/NotificationResolutionTest.php @@ -0,0 +1,63 @@ +seed(DatabaseSeeder::class); + } + + public function test_guest_cannot_resolve_notifications(): void + { + $this->get(route('notifications.resolve', ['id' => 'some-uuid'])) + ->assertRedirect(route('login')); + } + + public function test_user_can_resolve_and_redirect_notification_modularly(): void + { + $creator = User::factory()->create(); + $creator->assignRole('user'); + + $supportUser = User::factory()->create(); + $supportUser->assignRole('Support'); + + $openStatus = TicketStatus::where('name', 'open')->firstOrFail(); + + $ticket = Ticket::create([ + 'user_id' => $creator->id, + 'issue_category' => 'Billing Query', + 'status_id' => $openStatus->id, + 'assigned_user_id' => $supportUser->id, + ]); + + // Send a notification + $supportUser->notify(new TicketAssignedNotification($ticket)); + + // Retrieve unread notification + $notification = $supportUser->unreadNotifications()->firstOrFail(); + + $this->actingAs($supportUser); + + // Resolve notification + $response = $this->get(route('notifications.resolve', ['id' => $notification->id])); + + // Should mark as read + $this->assertSame(0, $supportUser->unreadNotifications()->count()); + + // Should redirect modularly to support tickets index page with ticket ID parameter + $response->assertRedirect(route('tickets.index', ['ticket' => $ticket->id])); + } +} diff --git a/tests/Feature/SamlIdpTest.php b/tests/Feature/SamlIdpTest.php new file mode 100644 index 0000000..2a51043 --- /dev/null +++ b/tests/Feature/SamlIdpTest.php @@ -0,0 +1,159 @@ +artisan('saml:generate-keys'); + } + + $this->seed(Database\Seeders\DatabaseSeeder::class); + + // Get seeded protocol, provider, and status + $this->protocol = ConnectionProtocol::query()->where('name', 'saml')->firstOrFail(); + $this->provider = ConnectionProvider::query()->where('slug', 'azure-fd')->firstOrFail(); + $this->status = ConnectionStatus::query()->where('name', 'connected')->firstOrFail(); + + // Create a mock SAML App + $this->samlApp = ConnectedApp::query()->create([ + 'name' => 'Microsoft Office 365', + 'slug' => 'microsoft-office-365', + 'connection_protocol_id' => $this->protocol->id, + 'connection_provider_id' => $this->provider->id, + 'connection_status_id' => $this->status->id, + 'settings' => [ + 'saml' => [ + 'entity_id' => 'urn:federation:MicrosoftOnline', + 'acs_url' => 'https://login.microsoftonline.com/login.srf', + ], + ], + ]); + + // Create a mock User + $this->user = User::factory()->create([ + 'email' => 'testuser@company.com', + 'immutable_id' => 'user-123-immutable', + ]); +}); + +test('saml metadata endpoint returns active public certificate and sso location', function (): void { + $response = $this->get(route('saml.metadata')); + + $response->assertStatus(200); + $response->assertHeader('Content-Type', 'application/xml; charset=utf-8'); + + $content = $response->getContent(); + expect($content)->toContain('EntityDescriptor') + ->toContain('IDPSSODescriptor') + ->toContain('SingleSignOnService') + ->toContain(route('saml.sso')) + ->toContain('X509Certificate'); +}); + +test('saml sso endpoint aborts if missing SAMLRequest', function (): void { + $response = $this->get(route('saml.sso')); + + $response->assertStatus(400); +}); + +test('saml sso endpoint redirects unauthenticated users to login page and remembers request', function (): void { + $xml = 'urn:federation:MicrosoftOnline'; + $samlRequest = base64_encode(gzdeflate($xml)); + $relayState = 'https://teams.microsoft.com/'; + + $response = $this->get(route('saml.sso', [ + 'SAMLRequest' => $samlRequest, + 'RelayState' => $relayState, + ])); + + $response->assertRedirect(route('login')); + + // Assert session retains pending details + expect(session('saml_pending_request'))->toBe($samlRequest); + expect(session('saml_pending_relay_state'))->toBe($relayState); +}); + +test('saml sso endpoint signs and redirects authenticated users via POST binding', function (): void { + $xml = 'urn:federation:MicrosoftOnline'; + $samlRequest = base64_encode(gzdeflate($xml)); + $relayState = 'https://teams.microsoft.com/'; + + // Log in the user + $this->actingAs($this->user); + + $response = $this->get(route('saml.sso', [ + 'SAMLRequest' => $samlRequest, + 'RelayState' => $relayState, + ])); + + $response->assertStatus(200); + $response->assertViewIs('saml.post_response'); + $response->assertViewHasAll([ + 'appName' => 'Microsoft Office 365', + 'acsUrl' => 'https://login.microsoftonline.com/login.srf', + 'relayState' => $relayState, + ]); + + $content = $response->getContent(); + expect($content)->toContain('name="SAMLResponse"') + ->toContain('name="RelayState"') + ->toContain('value="'.$relayState.'"'); + + // Verify session was cleared + expect(session('saml_pending_request'))->toBeNull(); +}); + +test('user sso settings and immutable id can be assigned via service and mapped to DTO', function (): void { + $userService = app(App\Services\UserService::class); + + // Assign roles and update immutable ID + $userService->assignRoles($this->user->id, [], 'new-custom-sso-immutable-id'); + + // Retrieve fresh user and assert + $freshUser = $this->user->fresh(); + expect($freshUser->immutable_id)->toBe('new-custom-sso-immutable-id'); + + // Assert UserIndexData maps it correctly + $dto = App\Data\User\UserIndexData::fromModel($freshUser); + expect($dto->immutableId)->toBe('new-custom-sso-immutable-id'); +}); + +test('saml settings field is only visible if selected roles contain a SAML app', function (): void { + // Create a role with a SAML connected app + $samlRole = App\Models\Role::findOrCreate('SAML User'); + $samlRole->update(['priority' => 10]); + $samlRole->apps()->attach($this->samlApp->id, ['duration' => 365]); + + // Create a role with no connected apps + $emptyRole = App\Models\Role::findOrCreate('Empty User'); + $emptyRole->update(['priority' => 20]); + + // Log in the administrator so they can view users + $admin = User::where('email', 'admin@example.com')->firstOrFail(); + $this->actingAs($admin); + + // 1. Livewire component with no roles selected should return false for showSamlSettings + Livewire::test('pages::access-manager.users.index') + ->set('form.roleIds', []) + ->assertSet('showSamlSettings', false); + + // 2. Livewire component with emptyRole selected should return false + Livewire::test('pages::access-manager.users.index') + ->set('form.roleIds', [$emptyRole->id]) + ->assertSet('showSamlSettings', false); + + // 3. Livewire component with samlRole selected should return true + Livewire::test('pages::access-manager.users.index') + ->set('form.roleIds', [$samlRole->id]) + ->assertSet('showSamlSettings', true); +}); diff --git a/tests/Feature/Settings/ProfileUpdateTest.php b/tests/Feature/Settings/ProfileUpdateTest.php index c07c860..5d8e930 100644 --- a/tests/Feature/Settings/ProfileUpdateTest.php +++ b/tests/Feature/Settings/ProfileUpdateTest.php @@ -2,12 +2,20 @@ declare(strict_types=1); +use App\Enums\Permissions\ProfilePermissionEnum; use App\Livewire\Settings\Profile; use App\Models\User; use Livewire\Livewire; +use Spatie\Permission\Models\Permission; + +beforeEach(function (): void { + Permission::findOrCreate(ProfilePermissionEnum::Access->value); +}); test('profile page is displayed', function (): void { - $this->actingAs($user = User::factory()->create()); + $user = User::factory()->create(); + $user->givePermissionTo(ProfilePermissionEnum::Access->value); + $this->actingAs($user); $this->get('/settings/profile')->assertOk(); }); diff --git a/tests/Feature/Settings/SecurityTest.php b/tests/Feature/Settings/SecurityTest.php index e626489..77343e2 100644 --- a/tests/Feature/Settings/SecurityTest.php +++ b/tests/Feature/Settings/SecurityTest.php @@ -2,11 +2,13 @@ declare(strict_types=1); +use App\Enums\Permissions\SecurityPermissionEnum; use App\Livewire\Settings\Security; use App\Models\User; use Illuminate\Support\Facades\Hash; use Laravel\Fortify\Features; use Livewire\Livewire; +use Spatie\Permission\Models\Permission; beforeEach(function (): void { $this->skipUnlessFortifyHas(Features::twoFactorAuthentication()); @@ -15,10 +17,13 @@ 'confirm' => true, 'confirmPassword' => true, ]); + + Permission::findOrCreate(SecurityPermissionEnum::Access->value); }); test('security settings page can be rendered', function (): void { $user = User::factory()->create(); + $user->givePermissionTo(SecurityPermissionEnum::Access->value); $this->actingAs($user) ->withSession(['auth.password_confirmed_at' => time()]) @@ -30,6 +35,7 @@ test('security settings page requires password confirmation when enabled', function (): void { $user = User::factory()->create(); + $user->givePermissionTo(SecurityPermissionEnum::Access->value); $response = $this->actingAs($user) ->get(route('security.edit')); @@ -41,6 +47,7 @@ config(['fortify.features' => []]); $user = User::factory()->create(); + $user->givePermissionTo(SecurityPermissionEnum::Access->value); $this->actingAs($user) ->withSession(['auth.password_confirmed_at' => time()]) diff --git a/tests/Feature/TicketActivityLogTest.php b/tests/Feature/TicketActivityLogTest.php new file mode 100644 index 0000000..651c502 --- /dev/null +++ b/tests/Feature/TicketActivityLogTest.php @@ -0,0 +1,244 @@ +seed(DatabaseSeeder::class); +}); + +test('assigned support user can change status of their assigned ticket and it logs activity', function (): void { + $user = User::factory()->create(); + $user->assignRole('user'); + + $supportUser = User::factory()->create(); + $supportUser->assignRole('user'); // normal user assigned as support staff + + // Create ticket via service + $dto = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Technical Issue', + description: 'SSO login error.', + notifiedRoleIds: [], + notifiedUserIds: [], + attachment: null, + ); + + $service = app(TicketService::class); + $ticketData = $service->create($dto); + + $ticket = Ticket::find($ticketData->id); + + // Assign the ticket to the support user + $service->assignUser($ticket->id, $supportUser->id); + + // Act as the assigned support user + $this->actingAs($supportUser); + + // Status change should be successful + $service->updateStatus($ticket->id, TicketStatusEnum::Hold->value); + + expect($ticket->fresh()->status->name)->toBe(TicketStatusEnum::Hold->value); + + // Verify activity log + $activity = Activity::where('subject_id', $ticket->id) + ->where('event', 'status_changed') + ->latest() + ->first(); + + expect($activity)->not->toBeNull() + ->and($activity->causer_id)->toBe($supportUser->id) + ->and($activity->properties['old_status'])->toBe(TicketStatusEnum::Open->value) + ->and($activity->properties['new_status'])->toBe(TicketStatusEnum::Hold->value) + ->and($activity->description)->toContain('Status updated from open to hold'); +}); + +test('unauthorized users cannot change status of ticket', function (): void { + $user = User::factory()->create(); + $user->assignRole('user'); + + $otherUser = User::factory()->create(); + $otherUser->assignRole('user'); + + $dto = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Technical Issue', + description: 'SSO login error.', + notifiedRoleIds: [], + notifiedUserIds: [], + attachment: null, + ); + + $service = app(TicketService::class); + $ticketData = $service->create($dto); + + // Act as other user + $this->actingAs($otherUser); + + // Changing status should fail with authorization exception + $this->expectException(Illuminate\Auth\Access\AuthorizationException::class); + $service->updateStatus($ticketData->id, TicketStatusEnum::Hold->value); +}); + +test('commenting on a ticket logs comment activity and status transition', function (): void { + $user = User::factory()->create(); + $user->assignRole('user'); + + $dto = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Technical Issue', + description: 'SSO login error.', + notifiedRoleIds: [], + notifiedUserIds: [], + attachment: null, + ); + + $service = app(TicketService::class); + $ticketData = $service->create($dto); + + $ticket = Ticket::find($ticketData->id); + + $this->actingAs($user); + + $replyDto = new PostReplyRequest( + ticketId: $ticket->id, + userId: $user->id, + message: 'This is a test comment.', + ); + + $service->postReply($replyDto); + + // Verify comment activity log + $commentActivity = Activity::where('subject_id', $ticket->id) + ->where('event', 'comment_posted') + ->first(); + + expect($commentActivity)->not->toBeNull() + ->and($commentActivity->properties['message'])->toBe('This is a test comment.') + ->and($commentActivity->description)->toContain('Comment posted on ticket'); + + // Verify transition status changed log (Open -> In Progress) + $statusActivity = Activity::where('subject_id', $ticket->id) + ->where('event', 'status_changed') + ->first(); + + expect($statusActivity)->not->toBeNull() + ->and($statusActivity->properties['old_status'])->toBe(TicketStatusEnum::Open->value) + ->and($statusActivity->properties['new_status'])->toBe(TicketStatusEnum::InProgress->value) + ->and($statusActivity->description)->toContain('Status updated from open to in-progress'); +}); + +test('getting/opening a ticket logs ticket_opened activity', function (): void { + $user = User::factory()->create(); + $user->assignRole('user'); + + $dto = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Technical Issue', + description: 'SSO login error.', + notifiedRoleIds: [], + notifiedUserIds: [], + attachment: null, + ); + + $service = app(TicketService::class); + $ticketData = $service->create($dto); + + $this->actingAs($user); + + // Retrieve ticket details + $service->getTicket($ticketData->id); + + // Verify ticket opened activity log + $activity = Activity::where('subject_id', $ticketData->id) + ->where('event', 'ticket_opened') + ->first(); + + expect($activity)->not->toBeNull() + ->and($activity->causer_id)->toBe($user->id) + ->and($activity->description)->toContain('opened by'); +}); + +test('notifying users logs user_notified activities', function (): void { + Notification::fake(); + + $user = User::factory()->create(); + $user->assignRole('user'); + + $admin = User::where('email', 'admin@example.com')->firstOrFail(); + + $this->actingAs($user); + + $dto = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Technical Issue', + description: 'SSO login error.', + notifiedRoleIds: [], + notifiedUserIds: [$admin->id], + attachment: null, + ); + + $service = app(TicketService::class); + $service->create($dto); + + // Verify user notified activity logs + $activities = Activity::where('subject_id', Ticket::latest()->first()->id) + ->where('event', 'user_notified') + ->get(); + + // Should log notifications for the specifically notified admin user and all seeded admins + expect($activities->count())->toBeGreaterThan(0); + + $adminNotificationLog = $activities->first(fn ($act) => $act->properties['recipient_id'] === $admin->id); + expect($adminNotificationLog)->not->toBeNull() + ->and($adminNotificationLog->properties['recipient_name'])->toBe($admin->name) + ->and($adminNotificationLog->description)->toContain('Notified user'); +}); + +test('status changes trigger status changed notification to the user', function (): void { + Notification::fake(); + + $user = User::factory()->create(); + $user->assignRole('user'); + + $admin = User::where('email', 'admin@example.com')->firstOrFail(); + + $dto = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Technical Issue', + description: 'SSO login error.', + notifiedRoleIds: [], + notifiedUserIds: [], + attachment: null, + ); + + $service = app(TicketService::class); + $ticketData = $service->create($dto); + + $ticket = Ticket::find($ticketData->id); + + // Act as admin to change status + $this->actingAs($admin); + + $service->updateStatus($ticket->id, TicketStatusEnum::Hold->value); + + // The user who raised the ticket should receive the status changed notification + Notification::assertSentTo( + $user, + TicketStatusChangedNotification::class + ); +}); diff --git a/tests/Feature/TicketSearchAndPaginationTest.php b/tests/Feature/TicketSearchAndPaginationTest.php new file mode 100644 index 0000000..96a9c55 --- /dev/null +++ b/tests/Feature/TicketSearchAndPaginationTest.php @@ -0,0 +1,188 @@ +seed(DatabaseSeeder::class); +}); + +test('anyone can search tickets by ticket id', function (): void { + $user = User::factory()->create(); + $user->assignRole('user'); + $this->actingAs($user); + + $service = app(TicketService::class); + + // Create Ticket 1 + $dto1 = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Technical Issue', + description: 'First test ticket.', + notifiedRoleIds: [], + notifiedUserIds: [], + attachment: null, + ); + $ticket1Data = $service->create($dto1); + + // Create Ticket 2 + $dto2 = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Billing Query', + description: 'Second test ticket.', + notifiedRoleIds: [], + notifiedUserIds: [], + attachment: null, + ); + $ticket2Data = $service->create($dto2); + + // Search by Ticket 1 code/ID using Livewire component + Livewire::test('pages::tickets.index') + ->set('search', $ticket1Data->ticketId) + ->assertSee($ticket1Data->ticketId) + ->assertDontSee($ticket2Data->ticketId); + + // Search by Ticket 2 code/ID + Livewire::test('pages::tickets.index') + ->set('search', $ticket2Data->ticketId) + ->assertSee($ticket2Data->ticketId) + ->assertDontSee($ticket1Data->ticketId); +}); + +test('tickets table is paginated to 10 items per page', function (): void { + $user = User::factory()->create(); + $user->assignRole('user'); + $this->actingAs($user); + + $service = app(TicketService::class); + + // Create 12 tickets + $createdTicketIds = []; + for ($i = 1; $i <= 12; $i++) { + $dto = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Technical Issue', + description: "Ticket number {$i}.", + notifiedRoleIds: [], + notifiedUserIds: [], + attachment: null, + ); + $ticketData = $service->create($dto); + Ticket::where('id', $ticketData->id)->update([ + 'created_at' => now()->addSeconds($i), + ]); + $createdTicketIds[] = $ticketData->ticketId; + } + + $component = Livewire::test('pages::tickets.index'); + + // On page 1, we should see the latest 10 tickets (since we order by latest, the last 10 created) + $page1Tickets = array_slice(array_reverse($createdTicketIds), 0, 10); + $page2Tickets = array_slice(array_reverse($createdTicketIds), 10, 2); + + foreach ($page1Tickets as $id) { + $component->assertSee($id); + } + + foreach ($page2Tickets as $id) { + $component->assertDontSee($id); + } + + // Go to page 2 + $component->call('gotoPage', 2); + + foreach ($page1Tickets as $id) { + $component->assertDontSee($id); + } + + foreach ($page2Tickets as $id) { + $component->assertSee($id); + } +}); + +test('status filtering works on paginated data at query level', function (): void { + $user = User::factory()->create(); + $user->assignRole('user'); + $this->actingAs($user); + + $service = app(TicketService::class); + $admin = User::where('email', 'admin@example.com')->firstOrFail(); + + // Create Ticket 1 (Open status by default) + $dto1 = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Technical Issue', + description: 'Open ticket.', + notifiedRoleIds: [], + notifiedUserIds: [], + attachment: null, + ); + $ticket1Data = $service->create($dto1); + + // Create Ticket 2 and change its status to Hold + $dto2 = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Billing Query', + description: 'Hold ticket.', + notifiedRoleIds: [], + notifiedUserIds: [], + attachment: null, + ); + $ticket2Data = $service->create($dto2); + + // Act as admin to change status + $this->actingAs($admin); + $service->updateStatus($ticket2Data->id, TicketStatusEnum::Hold->value); + + // Act as user to view + $this->actingAs($user); + + // Filter by Open + Livewire::test('pages::tickets.index') + ->set('statusFilter', TicketStatusEnum::Open->value) + ->assertSee($ticket1Data->ticketId) + ->assertDontSee($ticket2Data->ticketId); + + // Filter by Hold + Livewire::test('pages::tickets.index') + ->set('statusFilter', TicketStatusEnum::Hold->value) + ->assertSee($ticket2Data->ticketId) + ->assertDontSee($ticket1Data->ticketId); +}); + +test('normal user can see status filter and see custom empty state message when no results match search', function (): void { + $user = User::factory()->create(); + $user->assignRole('user'); + $this->actingAs($user); + + // Initial load: Should see default empty state message + Livewire::test('pages::tickets.index') + ->assertSee('No Support Tickets Found') + ->assertDontSee('No Matching Tickets Found') + ->assertSee('You have not raised any support tickets yet.'); + + // Search for non-existent ticket ID: Should see the custom empty search state message + Livewire::test('pages::tickets.index') + ->set('search', 'TKT-NONEXISTENT-99') + ->assertSee('No Matching Tickets Found') + ->assertDontSee('No Support Tickets Found') + ->assertSee('matching your search query or filter criteria'); + + // Filter by Hold status: Should see the custom empty filter state message + Livewire::test('pages::tickets.index') + ->set('statusFilter', 'hold') + ->assertSee('No Matching Tickets Found') + ->assertDontSee('No Support Tickets Found') + ->assertSee('matching your search query or filter criteria'); +}); diff --git a/tests/Feature/TicketSystemTest.php b/tests/Feature/TicketSystemTest.php new file mode 100644 index 0000000..4a8c0ba --- /dev/null +++ b/tests/Feature/TicketSystemTest.php @@ -0,0 +1,388 @@ +seed(DatabaseSeeder::class); +}); + +test('guests cannot access the tickets page', function (): void { + $this->get(route('tickets.index')) + ->assertRedirect(route('login')); +}); + +test('authenticated users with correct permissions can access the tickets page', function (): void { + $user = User::factory()->create(); + $user->assignRole('user'); + $this->actingAs($user); + + $this->get(route('tickets.index')) + ->assertOk() + ->assertSee('Support Tickets') + ->assertSee('You do not currently have any active services expiring within 3 days'); +}); + +test('submitting a support ticket creates the ticket and generates a unique ticket ID', function (): void { + $user = User::factory()->create(); + $user->assignRole('user'); + $this->actingAs($user); + + $app = ConnectedApp::first(); + $supportRole = Role::where('name', 'Support')->firstOrFail(); + + $dto = new CreateTicketRequest( + userId: $user->id, + connectedAppId: $app->id, + issueCategory: 'Billing Query', + description: 'Need assistance with billing renewal.', + notifiedRoleIds: [$supportRole->id], + notifiedUserIds: [], + attachment: null, + ); + + $service = app(TicketService::class); + $ticketData = $service->create($dto); + + expect($ticketData->ticketId)->toStartWith('TKT-') + ->and(mb_strlen($ticketData->ticketId))->toBe(12) + ->and($ticketData->issueCategory)->toBe('Billing Query') + ->and($ticketData->status->value)->toBe(TicketStatusEnum::Open->value) + ->and($ticketData->notifiedRoleNames)->toContain('Support'); + + $this->assertDatabaseHas('tickets', [ + 'user_id' => $user->id, + 'connected_app_id' => $app->id, + 'issue_category' => 'Billing Query', + ]); + + $this->assertDatabaseHas('ticket_notified_roles', [ + 'ticket_id' => $ticketData->id, + 'role_id' => $supportRole->id, + ]); +}); + +test('submitting a ticket with category Others requires at least 20 characters for description', function (): void { + $user = User::factory()->create(); + $user->assignRole('user'); + $this->actingAs($user); + + $supportRole = Role::where('name', 'Support')->firstOrFail(); + + // Test Validation Failure via Livewire component form object + Livewire::test('pages::tickets.index') + ->set('form.issueCategory', 'Others') + ->set('form.description', 'Short desc') + ->set('form.routingType', 'role') + ->set('form.notifiedRoleIds', [$supportRole->id]) + ->call('saveTicket') + ->assertHasErrors(['form.description' => 'min']); +}); + +test('attaching a file for Others category uploads and stores the file', function (): void { + Storage::fake('public'); + + $user = User::factory()->create(); + $user->assignRole('user'); + $this->actingAs($user); + + $supportRole = Role::where('name', 'Support')->firstOrFail(); + $file = UploadedFile::fake()->create('screenshot.png', 500); + + $dto = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Others', + description: 'A very detailed query containing more than twenty characters.', + notifiedRoleIds: [$supportRole->id], + notifiedUserIds: [], + attachment: $file, + ); + + $service = app(TicketService::class); + $ticketData = $service->create($dto); + + $this->assertDatabaseHas('ticket_attachments', [ + 'file_name' => 'screenshot.png', + 'mime_type' => 'image/png', + ]); + + expect(count($ticketData->attachments))->toBe(1); + Storage::disk('public')->assertExists($ticketData->attachments[0]['file_path']); +}); + +test('notification routing to a specific user triggers notification to that user', function (): void { + Notification::fake(); + + $user = User::factory()->create(); + $user->assignRole('user'); + $this->actingAs($user); + + $adminUser = User::where('email', 'admin@example.com')->firstOrFail(); + + $dto = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Technical Issue', + description: 'SSO log-in error.', + notifiedRoleIds: [], + notifiedUserIds: [$adminUser->id], + attachment: null, + ); + + $service = app(TicketService::class); + $service->create($dto); + + Notification::assertSentTo( + $adminUser, + TicketRaisedNotification::class + ); +}); + +test('notification routing to multiple roles triggers notifications to all users assigned to those roles', function (): void { + Notification::fake(); + + $user = User::factory()->create(); + $user->assignRole('user'); + $this->actingAs($user); + + $supportRole = Role::where('name', 'Support')->firstOrFail(); + $managerRole = Role::where('name', 'Manager')->firstOrFail(); + + // Create a support user + $supportUser = User::factory()->create(); + $supportUser->assignRole($supportRole); + + // Create a manager user + $managerUser = User::factory()->create(); + $managerUser->assignRole($managerRole); + + $dto = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Service Extension', + description: 'Requesting 3 days trial extension.', + notifiedRoleIds: [$supportRole->id, $managerRole->id], + notifiedUserIds: [], + attachment: null, + ); + + $service = app(TicketService::class); + $service->create($dto); + + Notification::assertSentTo( + [$supportUser, $managerUser], + TicketRaisedNotification::class + ); +}); + +test('admins can assign users to tickets and trigger assignment notifications', function (): void { + Notification::fake(); + + $admin = User::where('email', 'admin@example.com')->firstOrFail(); + $this->actingAs($admin); + + $user = User::factory()->create(); + $user->assignRole('user'); + + $ticket = App\Models\Ticket::create([ + 'user_id' => $user->id, + 'issue_category' => 'Billing Query', + 'status_id' => App\Models\TicketStatus::where('name', 'open')->firstOrFail()->id, + ]); + + $supportUser = User::factory()->create(); + $supportUser->assignRole('Support'); + + $service = app(TicketService::class); + $service->assignUser($ticket->id, $supportUser->id); + + $this->assertDatabaseHas('tickets', [ + 'id' => $ticket->id, + 'assigned_user_id' => $supportUser->id, + ]); + + Notification::assertSentTo( + $supportUser, + App\Notifications\TicketAssignedNotification::class + ); +}); + +test('authorized users can comment on tickets and unauthorized users are blocked', function (): void { + $creator = User::factory()->create(); + $creator->assignRole('user'); + + $unauthorizedUser = User::factory()->create(); + $unauthorizedUser->assignRole('user'); + + $ticket = App\Models\Ticket::create([ + 'user_id' => $creator->id, + 'issue_category' => 'Technical Issue', + 'status_id' => App\Models\TicketStatus::where('name', 'open')->firstOrFail()->id, + ]); + + $service = app(TicketService::class); + + // 1. Authorized user (Creator) can comment + $dto = new App\Data\Ticket\PostReplyRequest( + ticketId: $ticket->id, + userId: $creator->id, + message: 'This is a comment from the creator.', + ); + $replyData = $service->postReply($dto); + + expect($replyData->message)->toBe('This is a comment from the creator.'); + + $this->assertDatabaseHas('ticket_replies', [ + 'ticket_id' => $ticket->id, + 'user_id' => $creator->id, + 'message' => 'This is a comment from the creator.', + ]); + + // 2. Unauthorized user gets blocked + $dto2 = new App\Data\Ticket\PostReplyRequest( + ticketId: $ticket->id, + userId: $unauthorizedUser->id, + message: 'Should be blocked.', + ); + + expect(fn () => $service->postReply($dto2)) + ->toThrow(Illuminate\Auth\Access\AuthorizationException::class); +}); + +test('posting a comment transitions the status from open to in-progress', function (): void { + $creator = User::factory()->create(); + $creator->assignRole('user'); + + $openStatus = App\Models\TicketStatus::where('name', 'open')->firstOrFail(); + $inProgressStatus = App\Models\TicketStatus::where('name', 'in-progress')->firstOrFail(); + + $ticket = App\Models\Ticket::create([ + 'user_id' => $creator->id, + 'issue_category' => 'Service Extension', + 'status_id' => $openStatus->id, + ]); + + $service = app(TicketService::class); + + $dto = new App\Data\Ticket\PostReplyRequest( + ticketId: $ticket->id, + userId: $creator->id, + message: 'Transition this status.', + ); + + $service->postReply($dto); + + $this->assertDatabaseHas('tickets', [ + 'id' => $ticket->id, + 'status_id' => $inProgressStatus->id, + ]); +}); + +test('assigned support users can see their assigned tickets on their dashboard', function (): void { + $creator = User::factory()->create(); + $creator->assignRole('user'); + + $supportUser = User::factory()->create(); + $supportUser->assignRole('Support'); + + $openStatus = App\Models\TicketStatus::where('name', 'open')->firstOrFail(); + + $ticket = App\Models\Ticket::create([ + 'user_id' => $creator->id, + 'issue_category' => 'Technical Issue', + 'status_id' => $openStatus->id, + 'assigned_user_id' => $supportUser->id, + ]); + + $service = app(TicketService::class); + $tickets = $service->getListForUser($supportUser->id); + + expect(collect($tickets->all())->contains('id', $ticket->id))->toBeTrue(); +}); + +test('commenting on a ticket notifies the creator and assigned user', function (): void { + Notification::fake(); + + $creator = User::factory()->create(); + $creator->assignRole('user'); + + $supportUser = User::factory()->create(); + $supportUser->assignRole('Support'); + + $openStatus = App\Models\TicketStatus::where('name', 'open')->firstOrFail(); + + $ticket = App\Models\Ticket::create([ + 'user_id' => $creator->id, + 'issue_category' => 'Billing Query', + 'status_id' => $openStatus->id, + 'assigned_user_id' => $supportUser->id, + ]); + + // Another admin comments + $admin = User::where('email', 'admin@example.com')->firstOrFail(); + + $dto = new App\Data\Ticket\PostReplyRequest( + ticketId: $ticket->id, + userId: $admin->id, + message: 'This is an admin comment.', + ); + + $service = app(TicketService::class); + $service->postReply($dto); + + // Both the creator and the support assignee should be notified of the comment + Notification::assertSentTo( + $creator, + App\Notifications\TicketCommentedNotification::class + ); + + Notification::assertSentTo( + $supportUser, + App\Notifications\TicketCommentedNotification::class + ); + + // Commenter (admin) should not get notified + Notification::assertNotSentTo( + $admin, + App\Notifications\TicketCommentedNotification::class + ); +}); + +test('raising a support ticket always triggers a notification to all administrators', function (): void { + Notification::fake(); + + $user = User::factory()->create(); + $user->assignRole('user'); + $this->actingAs($user); + + $admin = User::where('email', 'admin@example.com')->firstOrFail(); + + $dto = new CreateTicketRequest( + userId: $user->id, + connectedAppId: null, + issueCategory: 'Technical Issue', + description: 'SSO log-in error.', + notifiedRoleIds: [], + notifiedUserIds: [], + attachment: null, + ); + + $service = app(TicketService::class); + $service->create($dto); + + // Administrator should be notified of the new ticket + Notification::assertSentTo( + $admin, + TicketRaisedNotification::class + ); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 4eccbe6..a610e26 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -31,7 +31,7 @@ | */ -expect()->extend('toBeOne', fn() => $this->toBe(1)); +expect()->extend('toBeOne', fn () => $this->toBe(1)); /* |-------------------------------------------------------------------------- diff --git a/tests/TestCase.php b/tests/TestCase.php index f64e620..f16f5d5 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -11,7 +11,7 @@ abstract class TestCase extends BaseTestCase { protected function skipUnlessFortifyHas(string $feature, ?string $message = null): void { - if ( ! Features::enabled($feature)) { + if (! Features::enabled($feature)) { $this->markTestSkipped($message ?? "Fortify feature [{$feature}] is not enabled."); } }