+18
@@ -0,0 +1,18 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
indent_size = 4
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[*.{yml,yaml}]
|
||||
indent_size = 2
|
||||
|
||||
[{compose,docker-compose}.{yml,yaml}]
|
||||
indent_size = 4
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
APP_NAME="RADIQ NDM"
|
||||
|
||||
# control_plane, managed_cloud, or self_hosted
|
||||
DEPLOYMENT_MODE=managed_cloud
|
||||
RADIQ_LICENSE_SERVER_URL=
|
||||
RADIQ_LICENSE_PUBLIC_KEY=
|
||||
APP_ENV=local
|
||||
APP_KEY=
|
||||
APP_DEBUG=true
|
||||
APP_URL=http://localhost:8000
|
||||
|
||||
APP_LOCALE=id
|
||||
APP_FALLBACK_LOCALE=en
|
||||
APP_FAKER_LOCALE=id_ID
|
||||
|
||||
APP_MAINTENANCE_DRIVER=file
|
||||
# APP_MAINTENANCE_STORE=database
|
||||
|
||||
# PHP_CLI_SERVER_WORKERS=4
|
||||
|
||||
BCRYPT_ROUNDS=12
|
||||
|
||||
LOG_CHANNEL=stack
|
||||
LOG_STACK=single
|
||||
LOG_DEPRECATIONS_CHANNEL=null
|
||||
LOG_LEVEL=debug
|
||||
|
||||
DB_CONNECTION=pgsql
|
||||
DB_HOST=
|
||||
DB_PORT=5432
|
||||
DB_DATABASE=
|
||||
DB_USERNAME=
|
||||
DB_PASSWORD=
|
||||
|
||||
SESSION_DRIVER=database
|
||||
SESSION_LIFETIME=120
|
||||
SESSION_ENCRYPT=false
|
||||
SESSION_PATH=/
|
||||
SESSION_DOMAIN=null
|
||||
|
||||
BROADCAST_CONNECTION=log
|
||||
FILESYSTEM_DISK=local
|
||||
QUEUE_CONNECTION=database
|
||||
|
||||
CACHE_STORE=database
|
||||
# CACHE_PREFIX=
|
||||
|
||||
MEMCACHED_HOST=127.0.0.1
|
||||
|
||||
REDIS_CLIENT=phpredis
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PASSWORD=null
|
||||
REDIS_PORT=6379
|
||||
|
||||
MAIL_MAILER=log
|
||||
MAIL_SCHEME=null
|
||||
MAIL_HOST=127.0.0.1
|
||||
MAIL_PORT=2525
|
||||
MAIL_USERNAME=null
|
||||
MAIL_PASSWORD=null
|
||||
MAIL_FROM_ADDRESS="hello@example.com"
|
||||
MAIL_FROM_NAME="${APP_NAME}"
|
||||
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
AWS_DEFAULT_REGION=us-east-1
|
||||
AWS_BUCKET=
|
||||
AWS_USE_PATH_STYLE_ENDPOINT=false
|
||||
|
||||
VITE_APP_NAME="${APP_NAME}"
|
||||
@@ -0,0 +1,11 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.blade.php diff=html
|
||||
*.css diff=css
|
||||
*.html diff=html
|
||||
*.md diff=markdown
|
||||
*.php diff=php
|
||||
|
||||
CHANGELOG.md export-ignore
|
||||
README.md export-ignore
|
||||
.github/workflows/browser-tests.yml export-ignore
|
||||
@@ -0,0 +1,12 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
cooldown:
|
||||
default-days: 5
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
@@ -0,0 +1,38 @@
|
||||
name: tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2
|
||||
with:
|
||||
php-version: '8.3'
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '22'
|
||||
|
||||
- name: Setup Application
|
||||
run: composer setup
|
||||
|
||||
- name: Run CI Checks
|
||||
run: composer ci:check
|
||||
@@ -0,0 +1,29 @@
|
||||
/.phpunit.cache
|
||||
/bootstrap/ssr
|
||||
/node_modules
|
||||
/public/build
|
||||
/public/fonts-manifest.dev.json
|
||||
/public/hot
|
||||
/public/storage
|
||||
/storage/*.key
|
||||
/storage/pail
|
||||
/resources/js/actions
|
||||
/resources/js/routes
|
||||
/resources/js/wayfinder
|
||||
/vendor
|
||||
.DS_Store
|
||||
.env
|
||||
.env.backup
|
||||
.env.production
|
||||
.phpactor.json
|
||||
.phpunit.result.cache
|
||||
Homestead.json
|
||||
Homestead.yaml
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
/auth.json
|
||||
/.fleet
|
||||
/.idea
|
||||
/.nova
|
||||
/.vscode
|
||||
/.zed
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
ignore-scripts=true
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
resources/js/components/ui/*
|
||||
resources/views/mail/*
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"singleAttributePerLine": false,
|
||||
"htmlWhitespaceSensitivity": "css",
|
||||
"printWidth": 80,
|
||||
"plugins": [
|
||||
"prettier-plugin-tailwindcss"
|
||||
],
|
||||
"tailwindFunctions": [
|
||||
"clsx",
|
||||
"cn",
|
||||
"cva"
|
||||
],
|
||||
"tailwindStylesheet": "resources/css/app.css",
|
||||
"tabWidth": 4,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "**/*.yml",
|
||||
"options": {
|
||||
"tabWidth": 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+71
@@ -0,0 +1,71 @@
|
||||
# RADIQ NDM
|
||||
|
||||
RADIQ NDM (Network Device Management) is a centralized, multi-tenant platform for managing ISP and network-device access.
|
||||
|
||||
## Phase 1 architecture
|
||||
|
||||
- PHP 8.3 and Laravel 13.
|
||||
- React 19, TypeScript, Inertia 3, Tailwind CSS 4, and shadcn/ui.
|
||||
- Laravel Fortify/session authentication with registration disabled. Users are provisioned by authorized administrators.
|
||||
- Spatie Laravel Permission 8 with team mode mapped to `tenant_id`.
|
||||
- Laravel Sanctum for `/api/v1` token authentication.
|
||||
- Database-backed session, cache, and queue for the foundation. Redis is deferred until operational load justifies it.
|
||||
- Tenant context is established server-side for every web/API request. Tenant-owned Eloquent models use a reusable global scope.
|
||||
- Platform access requires the explicit platform-admin marker and cross-tenant permission; normal administrators remain tenant-bound.
|
||||
|
||||
## Phase 1 database
|
||||
|
||||
- `tenants`: UUID public identifier, unique slug, active state.
|
||||
- `users`: UUID, nullable tenant for platform identities, hashed application password, active/platform flags.
|
||||
- `roles`, `permissions`, and tenant-aware model pivots supplied by Spatie.
|
||||
- Authentication support tables for sessions, resets, passkeys, 2FA, and API tokens.
|
||||
- Database queue/cache foundation tables.
|
||||
|
||||
Secrets belong only in `.env`. The committed `.env.example` intentionally contains blank database credentials.
|
||||
|
||||
## Development commands (Windows)
|
||||
|
||||
Ensure PHP 8.3 is first in the process path because the machine also contains an older XAMPP PHP:
|
||||
|
||||
```powershell
|
||||
$env:Path = 'C:\php83;' + $env:Path
|
||||
C:\php83\php.exe artisan about
|
||||
C:\php83\php.exe artisan migrate:status
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Run tests with the available SQLite DLLs loaded for the test process:
|
||||
|
||||
```powershell
|
||||
C:\php83\php.exe -d "extension=C:\php83\ext\php_pdo_sqlite.dll" -d "extension=C:\php83\ext\php_sqlite3.dll" vendor\bin\phpunit
|
||||
npm run types:check
|
||||
npm run lint:check
|
||||
```
|
||||
|
||||
## Current scope
|
||||
|
||||
Phase 1 foundation is ready for user acceptance testing. Authentication, tenant context, RBAC schema, base API, Master Admin bootstrap, platform dashboard, Tenant + Tenant Admin provisioning, tenant user management, password reset, and account activation are present. Device/driver functionality intentionally has not started pending acceptance of Phase 1.
|
||||
|
||||
Phase 2 device inventory is now available: global vendor/type/model catalogs, tenant-scoped device CRUD, encrypted and masked device credentials, server-side search/filter/pagination, and configurable Tenant User create/update-own/delete-own policy. Network connection testing remains disabled until the Phase 3 driver engine is implemented.
|
||||
|
||||
MikroTik provisioning is available through the native RouterOS API client. Tenant Admin configures an encrypted Base User template, then explicitly activates a MikroTik device after storing its bootstrap credential. Activation synchronizes `RADIQ-READ`, `RADIQ-WRITE`, and `RADIQ-NOC` without RouterOS `policy`, plus `RADIQ-MANAGER` with user-management rights.
|
||||
|
||||
Centralized Device Users are managed independently from application users. One encrypted credential can target many devices, while each assignment tracks queued/pending/processing/synced/failed/unsupported state. Queue jobs contain only tenant and assignment IDs. Pending offline devices are re-queued by the scheduler every five minutes.
|
||||
|
||||
Roles and permissions are fixed by the application and have no management UI. The only roles are `MASTER ADMIN`, `TENANT ADMIN`, and `TENANT USER`. A Tenant Admin can only create and manage Tenant User accounts in its own tenant. Device permissions for Tenant User are governed by the fixed tenant policy and will be refined with the device module.
|
||||
|
||||
## Deployment and licensing boundary
|
||||
|
||||
- `control_plane`: RADIQ-owned licensing/master administration. Stores customer tenants, installations, license lifecycle, limits, and renewal history.
|
||||
- `managed_cloud`: RADIQ-hosted multi-tenant NDM. Platform Master Admin exists here; each ISP remains a tenant.
|
||||
- `self_hosted`: installed on an ISP/customer server. The customer is still a tenant and receives Tenant Owner access, never Platform Master Admin. Its signed license is issued and renewed by the RADIQ control plane.
|
||||
|
||||
Raw license and instance secrets are never stored. Only SHA-256 hashes are persisted. The schema supports signed license payloads so a self-hosted installation can validate a license with a public key; the signing private key must exist only on the RADIQ control plane. Runtime activation/heartbeat and enforcement will be implemented as a dedicated licensing increment before production distribution.
|
||||
|
||||
Create the first Platform Super Admin interactively (the password is never passed as a command option):
|
||||
|
||||
```powershell
|
||||
C:\php83\php.exe artisan app:create-platform-admin
|
||||
```
|
||||
|
||||
Public registration is intentionally disabled. Subsequent tenant users will be created from the permission-protected Administration UI.
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Concerns\PasswordValidationRules;
|
||||
use App\Concerns\ProfileValidationRules;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Laravel\Fortify\Contracts\CreatesNewUsers;
|
||||
|
||||
class CreateNewUser implements CreatesNewUsers
|
||||
{
|
||||
use PasswordValidationRules, ProfileValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and create a newly registered user.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
public function create(array $input): User
|
||||
{
|
||||
Validator::make($input, [
|
||||
...$this->profileRules(),
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
return User::create([
|
||||
'name' => $input['name'],
|
||||
'email' => $input['email'],
|
||||
'password' => $input['password'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Actions\Fortify;
|
||||
|
||||
use App\Concerns\PasswordValidationRules;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Laravel\Fortify\Contracts\ResetsUserPasswords;
|
||||
|
||||
class ResetUserPassword implements ResetsUserPasswords
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Validate and reset the user's forgotten password.
|
||||
*
|
||||
* @param array<string, string> $input
|
||||
*/
|
||||
public function reset(User $user, array $input): void
|
||||
{
|
||||
Validator::make($input, [
|
||||
'password' => $this->passwordRules(),
|
||||
])->validate();
|
||||
|
||||
$user->forceFill([
|
||||
'password' => $input['password'],
|
||||
])->save();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Casts;
|
||||
|
||||
use App\Services\Encryption\TenantEnvelopeEncryption;
|
||||
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use RuntimeException;
|
||||
|
||||
class TenantEncrypted implements CastsAttributes
|
||||
{
|
||||
public function get(Model $model, string $key, mixed $value, array $attributes): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
$tenantId = (int) ($attributes['tenant_id'] ?? 0);
|
||||
if ($tenantId < 1) {
|
||||
throw new RuntimeException('TENANT_CONTEXT_REQUIRED_FOR_DECRYPTION');
|
||||
}
|
||||
|
||||
return app(TenantEnvelopeEncryption::class)->decrypt($tenantId, $value);
|
||||
}
|
||||
|
||||
public function set(Model $model, string $key, mixed $value, array $attributes): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
$tenantId = (int) ($attributes['tenant_id'] ?? $model->getAttribute('tenant_id'));
|
||||
if ($tenantId < 1) {
|
||||
throw new RuntimeException('TENANT_CONTEXT_REQUIRED_FOR_ENCRYPTION');
|
||||
}
|
||||
|
||||
return app(TenantEnvelopeEncryption::class)->encrypt($tenantId, $value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Concerns;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
trait PasswordValidationRules
|
||||
{
|
||||
/**
|
||||
* Get the validation rules used to validate passwords.
|
||||
*
|
||||
* @return array<int, Password|ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
protected function passwordRules(): array
|
||||
{
|
||||
return ['required', 'string', Password::default(), 'confirmed'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules used to validate the current password.
|
||||
*
|
||||
* @return array<int, Password|ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
protected function currentPasswordRules(): array
|
||||
{
|
||||
return ['required', 'string', 'current_password'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Concerns;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
trait ProfileValidationRules
|
||||
{
|
||||
/**
|
||||
* Get the validation rules used to validate user profiles.
|
||||
*
|
||||
* @return array<string, array<int, ValidationRule|array<mixed>|string>>
|
||||
*/
|
||||
protected function profileRules(?int $userId = null): array
|
||||
{
|
||||
return [
|
||||
'name' => $this->nameRules(),
|
||||
'email' => $this->emailRules($userId),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules used to validate user names.
|
||||
*
|
||||
* @return array<int, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
protected function nameRules(): array
|
||||
{
|
||||
return ['required', 'string', 'max:255'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules used to validate user emails.
|
||||
*
|
||||
* @return array<int, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
protected function emailRules(?int $userId = null): array
|
||||
{
|
||||
return [
|
||||
'required',
|
||||
'string',
|
||||
'email',
|
||||
'max:255',
|
||||
$userId === null
|
||||
? Rule::unique(User::class)
|
||||
: Rule::unique(User::class)->ignore($userId),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
#[Signature('app:create-platform-admin {--name=} {--email=}')]
|
||||
#[Description('Create the first platform-level super administrator')]
|
||||
class CreatePlatformAdmin extends Command
|
||||
{
|
||||
public function handle(): int
|
||||
{
|
||||
if (config('deployment.mode') === 'self_hosted') {
|
||||
$this->error('Platform Super Admin hanya dibuat pada RADIQ control plane atau managed cloud.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$name = $this->option('name') ?: $this->ask('Full name');
|
||||
$email = $this->option('email') ?: $this->ask('Email address');
|
||||
$password = $this->secret('Password (minimum 12 characters)');
|
||||
$confirmation = $this->secret('Confirm password');
|
||||
|
||||
$validator = Validator::make(
|
||||
compact('name', 'email', 'password') + ['password_confirmation' => $confirmation],
|
||||
[
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()],
|
||||
],
|
||||
);
|
||||
|
||||
if ($validator->fails()) {
|
||||
foreach ($validator->errors()->all() as $error) {
|
||||
$this->error($error);
|
||||
}
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$admin = new User;
|
||||
$admin->forceFill([
|
||||
'tenant_id' => null,
|
||||
'name' => $name,
|
||||
'email' => $email,
|
||||
'email_verified_at' => now(),
|
||||
'password' => $password,
|
||||
'is_platform_admin' => true,
|
||||
'is_active' => true,
|
||||
])->save();
|
||||
|
||||
$this->info('Platform Master Admin created successfully.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Services\TenantProvisioningService;
|
||||
use Illuminate\Console\Attributes\Description;
|
||||
use Illuminate\Console\Attributes\Signature;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
#[Signature('app:install-tenant {--name=} {--slug=} {--admin-name=} {--admin-email=} {--domain=}')]
|
||||
#[Description('Provision the only Tenant Admin account for a self-hosted installation')]
|
||||
class InstallTenant extends Command
|
||||
{
|
||||
/**
|
||||
* Execute the console command.
|
||||
*/
|
||||
public function handle(TenantProvisioningService $service): int
|
||||
{
|
||||
if (config('deployment.mode') !== 'self_hosted') {
|
||||
$this->error('Command ini hanya boleh digunakan saat DEPLOYMENT_MODE=self_hosted.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
if (Tenant::exists()) {
|
||||
$this->error('Instalasi self-hosted ini sudah memiliki tenant.');
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$data = [
|
||||
'name' => $this->option('name') ?: $this->ask('Nama tenant/ISP'),
|
||||
'slug' => $this->option('slug') ?: $this->ask('Slug tenant'),
|
||||
'owner_name' => $this->option('admin-name') ?: $this->ask('Nama Tenant Admin'),
|
||||
'owner_email' => $this->option('admin-email') ?: $this->ask('Email Tenant Admin'),
|
||||
'owner_password' => $this->secret('Password Tenant Admin (minimum 12 karakter)'),
|
||||
'deployment_type' => 'self_hosted', 'deployment_domain' => $this->option('domain'),
|
||||
];
|
||||
$confirmation = $this->secret('Konfirmasi password Tenant Admin');
|
||||
$validator = Validator::make($data + ['owner_password_confirmation' => $confirmation], [
|
||||
'name' => ['required', 'string', 'max:255'], 'slug' => ['required', 'alpha_dash', 'max:100'],
|
||||
'owner_name' => ['required', 'string', 'max:255'], 'owner_email' => ['required', 'email'],
|
||||
'owner_password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()],
|
||||
]);
|
||||
if ($validator->fails()) {
|
||||
foreach ($validator->errors()->all() as $error) {
|
||||
$this->error($error);
|
||||
}
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
$service->createWithOwner($data);
|
||||
$this->info('RADIQ NDM self-hosted berhasil dipasang. Hanya akun Tenant Admin yang dibuat.');
|
||||
|
||||
return self::SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum DeploymentMode: string
|
||||
{
|
||||
case ControlPlane = 'control_plane';
|
||||
case ManagedCloud = 'managed_cloud';
|
||||
case SelfHosted = 'self_hosted';
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum LicenseStatus: string
|
||||
{
|
||||
case Pending = 'pending';
|
||||
case Active = 'active';
|
||||
case Grace = 'grace';
|
||||
case Expired = 'expired';
|
||||
case Suspended = 'suspended';
|
||||
case Revoked = 'revoked';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum SystemRole: string
|
||||
{
|
||||
case MasterAdmin = 'MASTER ADMIN';
|
||||
case TenantAdmin = 'TENANT ADMIN';
|
||||
case TenantUser = 'TENANT USER';
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Administration;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Administration\StoreTenantRequest;
|
||||
use App\Http\Requests\Administration\UpdateTenantRequest;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use App\Services\TenantProvisioningService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class TenantController extends Controller
|
||||
{
|
||||
public function index(): Response
|
||||
{
|
||||
$this->authorize('viewAny', Tenant::class);
|
||||
|
||||
$tenants = Tenant::query()
|
||||
->withCount('users')
|
||||
->with('installations:id,tenant_id,deployment_type,domain,status,last_seen_at')
|
||||
->orderBy('name')
|
||||
->paginate(15)
|
||||
->withQueryString();
|
||||
|
||||
return Inertia::render('administration/tenants/index', ['tenants' => $tenants]);
|
||||
}
|
||||
|
||||
public function create(): Response
|
||||
{
|
||||
$this->authorize('create', Tenant::class);
|
||||
|
||||
return Inertia::render('administration/tenants/create');
|
||||
}
|
||||
|
||||
public function store(StoreTenantRequest $request, TenantProvisioningService $service): RedirectResponse
|
||||
{
|
||||
$tenant = $service->createWithOwner($request->validated());
|
||||
|
||||
return to_route('administration.tenants.edit', $tenant)
|
||||
->with('success', 'Tenant dan Tenant Owner berhasil dibuat.');
|
||||
}
|
||||
|
||||
public function edit(Tenant $tenant): Response
|
||||
{
|
||||
$this->authorize('update', $tenant);
|
||||
|
||||
$users = User::query()
|
||||
->withoutGlobalScope('tenant')
|
||||
->where('tenant_id', $tenant->id)
|
||||
->orderBy('name')
|
||||
->get(['uuid', 'name', 'email', 'is_active', 'created_at']);
|
||||
|
||||
return Inertia::render('administration/tenants/edit', [
|
||||
'tenant' => $tenant,
|
||||
'users' => $users,
|
||||
'installation' => $tenant->installations()->first(['id', 'deployment_type', 'domain', 'status', 'last_seen_at']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateTenantRequest $request, Tenant $tenant): RedirectResponse
|
||||
{
|
||||
$this->authorize('update', $tenant);
|
||||
$data = $request->validated();
|
||||
$tenant->update(collect($data)->only(['name', 'slug', 'is_active'])->all());
|
||||
if (isset($data['deployment_type'])) {
|
||||
$tenant->installations()->first()?->update([
|
||||
'deployment_type' => $data['deployment_type'],
|
||||
'domain' => $data['deployment_domain'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
return back()->with('success', 'Tenant berhasil diperbarui.');
|
||||
}
|
||||
|
||||
public function destroy(Tenant $tenant): RedirectResponse
|
||||
{
|
||||
$this->authorize('delete', $tenant);
|
||||
abort_if($tenant->users()->exists(), 422, 'Tenant yang masih memiliki user tidak dapat dihapus. Nonaktifkan tenant sebagai gantinya.');
|
||||
$tenant->delete();
|
||||
|
||||
return to_route('administration.tenants.index')->with('success', 'Tenant berhasil dihapus.');
|
||||
}
|
||||
}
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Administration;
|
||||
|
||||
use App\Enums\SystemRole;
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Administration\ResetUserPasswordRequest;
|
||||
use App\Http\Requests\Administration\StoreUserRequest;
|
||||
use App\Http\Requests\Administration\UpdateUserRequest;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class UserController extends Controller
|
||||
{
|
||||
public function index(Tenant $tenant): Response
|
||||
{
|
||||
abort_unless(request()->user()->can('user.view'), 403);
|
||||
|
||||
$users = User::query()
|
||||
->with('roles:id,name')
|
||||
->when(! request()->user()->is_platform_admin, fn ($query) => $query->role(SystemRole::TenantUser->value))
|
||||
->orderBy('name')
|
||||
->paginate(15)
|
||||
->withQueryString();
|
||||
|
||||
return Inertia::render('administration/users/index', compact('tenant', 'users'));
|
||||
}
|
||||
|
||||
public function create(Tenant $tenant): Response
|
||||
{
|
||||
abort_unless(request()->user()->can('user.create'), 403);
|
||||
|
||||
return Inertia::render('administration/users/create', ['tenant' => $tenant]);
|
||||
}
|
||||
|
||||
public function store(StoreUserRequest $request, Tenant $tenant): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$user = User::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => $data['name'],
|
||||
'email' => $data['email'],
|
||||
'password' => $data['password'],
|
||||
'is_active' => $data['is_active'] ?? true,
|
||||
]);
|
||||
$user->forceFill(['email_verified_at' => now()])->save();
|
||||
$user->assignRole(SystemRole::TenantUser->value);
|
||||
|
||||
return to_route('administration.users.index', $tenant)->with('success', 'User berhasil dibuat.');
|
||||
}
|
||||
|
||||
public function edit(Tenant $tenant, User $user): Response
|
||||
{
|
||||
$this->ensureManageableUser($tenant, $user);
|
||||
abort_unless(request()->user()->can('user.update'), 403);
|
||||
|
||||
return Inertia::render('administration/users/edit', [
|
||||
'tenant' => $tenant,
|
||||
'managedUser' => $user->load('roles:id,name'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(UpdateUserRequest $request, Tenant $tenant, User $user): RedirectResponse
|
||||
{
|
||||
$this->ensureManageableUser($tenant, $user);
|
||||
$data = $request->validated();
|
||||
$user->update(['name' => $data['name'], 'email' => $data['email']]);
|
||||
|
||||
return back()->with('success', 'User berhasil diperbarui.');
|
||||
}
|
||||
|
||||
public function resetPassword(ResetUserPasswordRequest $request, Tenant $tenant, User $user): RedirectResponse
|
||||
{
|
||||
$this->ensureManageableUser($tenant, $user);
|
||||
$user->update(['password' => $request->validated('password')]);
|
||||
$user->tokens()->delete();
|
||||
|
||||
return back()->with('success', 'Password user berhasil direset dan token API dicabut.');
|
||||
}
|
||||
|
||||
public function toggleActive(Tenant $tenant, User $user): RedirectResponse
|
||||
{
|
||||
$this->ensureManageableUser($tenant, $user);
|
||||
abort_unless(request()->user()->can('user.update'), 403);
|
||||
abort_if(request()->user()->is($user), 422, 'Anda tidak dapat menonaktifkan akun sendiri.');
|
||||
abort_if($user->is_active && $user->hasRole(SystemRole::TenantAdmin->value), 422, 'Akun Tenant Admin utama tidak dapat dinonaktifkan dari pengelolaan user tenant.');
|
||||
$user->update(['is_active' => ! $user->is_active]);
|
||||
$user->tokens()->delete();
|
||||
|
||||
return back()->with('success', $user->is_active ? 'User diaktifkan.' : 'User dinonaktifkan.');
|
||||
}
|
||||
|
||||
public function destroy(Tenant $tenant, User $user): RedirectResponse
|
||||
{
|
||||
$this->ensureManageableUser($tenant, $user);
|
||||
abort_unless(request()->user()->can('user.delete'), 403);
|
||||
abort_if(request()->user()->is($user), 422, 'Anda tidak dapat menghapus akun sendiri.');
|
||||
abort_if($user->hasRole(SystemRole::TenantAdmin->value), 422, 'Akun Tenant Admin utama tidak dapat dihapus dari pengelolaan user tenant.');
|
||||
$user->delete();
|
||||
|
||||
return to_route('administration.users.index', $tenant)->with('success', 'User berhasil dihapus.');
|
||||
}
|
||||
|
||||
private function ensureManageableUser(Tenant $tenant, User $user): void
|
||||
{
|
||||
abort_unless($user->tenant_id === $tenant->id && ! $user->is_platform_admin, 404);
|
||||
abort_if(
|
||||
! request()->user()->is_platform_admin && ! $user->hasRole(SystemRole::TenantUser->value),
|
||||
403,
|
||||
'Tenant Admin hanya dapat mengelola Tenant User.',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\V1;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreTenantRequest;
|
||||
use App\Http\Requests\UpdateTenantRequest;
|
||||
use App\Http\Resources\TenantResource;
|
||||
use App\Models\Tenant;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
class TenantController extends Controller
|
||||
{
|
||||
public function index(): AnonymousResourceCollection
|
||||
{
|
||||
$this->authorize('viewAny', Tenant::class);
|
||||
$user = request()->user();
|
||||
$query = Tenant::query()->orderBy('name');
|
||||
|
||||
if (! ($user->is_platform_admin && $user->can('tenant.access-any'))) {
|
||||
$query->whereKey($user->tenant_id);
|
||||
}
|
||||
|
||||
return TenantResource::collection($query->paginate());
|
||||
}
|
||||
|
||||
public function store(StoreTenantRequest $request): JsonResponse
|
||||
{
|
||||
$tenant = Tenant::create($request->validated());
|
||||
|
||||
return response()->json(['success' => true, 'message' => 'Tenant created successfully', 'data' => new TenantResource($tenant)], 201);
|
||||
}
|
||||
|
||||
public function show(Tenant $tenant): JsonResponse
|
||||
{
|
||||
$this->authorize('view', $tenant);
|
||||
|
||||
return response()->json(['success' => true, 'message' => 'Tenant retrieved successfully', 'data' => new TenantResource($tenant)]);
|
||||
}
|
||||
|
||||
public function update(UpdateTenantRequest $request, Tenant $tenant): JsonResponse
|
||||
{
|
||||
$this->authorize('update', $tenant);
|
||||
$tenant->update($request->validated());
|
||||
|
||||
return response()->json(['success' => true, 'message' => 'Tenant updated successfully', 'data' => new TenantResource($tenant)]);
|
||||
}
|
||||
|
||||
public function destroy(Tenant $tenant): JsonResponse
|
||||
{
|
||||
$this->authorize('delete', $tenant);
|
||||
$tenant->delete();
|
||||
|
||||
return response()->json(['success' => true, 'message' => 'Tenant deleted successfully', 'data' => null]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Foundation\Validation\ValidatesRequests;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
use AuthorizesRequests, ValidatesRequests;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\LicenseStatus;
|
||||
use App\Enums\SystemRole;
|
||||
use App\Models\DeploymentInstallation;
|
||||
use App\Models\Device;
|
||||
use App\Models\License;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class DashboardController extends Controller
|
||||
{
|
||||
public function __invoke(): Response
|
||||
{
|
||||
$user = request()->user();
|
||||
|
||||
if ($user->is_platform_admin) {
|
||||
return Inertia::render('dashboard', [
|
||||
'role' => SystemRole::MasterAdmin->value,
|
||||
'tenant' => null,
|
||||
'stats' => [
|
||||
'tenants' => Tenant::count(),
|
||||
'activeTenants' => Tenant::where('is_active', true)->count(),
|
||||
'users' => User::withoutGlobalScope('tenant')->where('is_platform_admin', false)->count(),
|
||||
'activeLicenses' => License::withoutGlobalScope('tenant')->where('status', LicenseStatus::Active->value)->count(),
|
||||
'installations' => DeploymentInstallation::withoutGlobalScope('tenant')->count(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
$role = $user->hasRole(SystemRole::TenantAdmin->value)
|
||||
? SystemRole::TenantAdmin->value
|
||||
: SystemRole::TenantUser->value;
|
||||
|
||||
return Inertia::render('dashboard', [
|
||||
'role' => $role,
|
||||
'tenant' => $user->tenant?->only(['id', 'name', 'slug', 'is_active']),
|
||||
'stats' => [
|
||||
'users' => User::count(),
|
||||
'activeUsers' => User::where('is_active', true)->count(),
|
||||
'devices' => Device::count(),
|
||||
'canAddDevice' => $user->can('device.create'),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreDeviceAccessUserRequest;
|
||||
use App\Http\Requests\UpdateDeviceAccessUserRequest;
|
||||
use App\Jobs\SyncDeviceUser;
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceAccessUser;
|
||||
use App\Models\DeviceUserAssignment;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class DeviceAccessUserController extends Controller
|
||||
{
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
abort_unless($request->user()->can('device.user.view'), 403);
|
||||
$users = DeviceAccessUser::query()->with(['assignments' => fn ($query) => $query->with('device:id,name')])
|
||||
->when($request->integer('device_id'), fn ($query, $deviceId) => $query->whereHas('assignments', fn ($assignment) => $assignment->where('device_id', $deviceId)))
|
||||
->when($request->string('search')->isNotEmpty(), fn ($query) => $query->where(fn ($nested) => $nested->where('display_name', 'ilike', '%'.$request->string('search').'%')->orWhere('username', 'ilike', '%'.$request->string('search').'%')))
|
||||
->orderBy('display_name')->paginate(15)->withQueryString();
|
||||
|
||||
return Inertia::render('device-users/index', ['deviceUsers' => $users, 'devices' => Device::orderBy('name')->get(['id', 'name']), 'filters' => $request->only('search', 'device_id')]);
|
||||
}
|
||||
|
||||
public function create(): Response
|
||||
{
|
||||
abort_unless(request()->user()->can('device.user.create'), 403);
|
||||
|
||||
return Inertia::render('device-users/form', ['deviceUser' => null, 'devices' => $this->devices()]);
|
||||
}
|
||||
|
||||
public function store(StoreDeviceAccessUserRequest $request): RedirectResponse
|
||||
{
|
||||
$data = $request->validated();
|
||||
$this->protectManagerGroup($data['group_name']);
|
||||
$assignments = DB::transaction(function () use ($request, $data) {
|
||||
$user = DeviceAccessUser::create(['tenant_id' => $request->user()->tenant_id, 'display_name' => $data['display_name'], 'username' => $data['username'], 'password' => $data['password'], 'is_enabled' => $data['is_enabled'] ?? true, 'notes' => $data['notes'] ?? null, 'created_by' => $request->user()->id, 'updated_by' => $request->user()->id]);
|
||||
|
||||
return collect($data['device_ids'])->map(fn ($deviceId) => DeviceUserAssignment::create(['tenant_id' => $request->user()->tenant_id, 'device_access_user_id' => $user->id, 'device_id' => $deviceId, 'group_name' => $data['group_name'], 'sync_status' => 'queued']));
|
||||
});
|
||||
$assignments->each(fn ($assignment) => SyncDeviceUser::dispatch($request->user()->tenant_id, $assignment->id));
|
||||
|
||||
return to_route('device-users.index')->with('success', 'User perangkat dibuat dan sinkronisasi dimasukkan ke queue.');
|
||||
}
|
||||
|
||||
public function edit(DeviceAccessUser $deviceAccessUser): Response
|
||||
{
|
||||
$this->ensureOwned($deviceAccessUser);
|
||||
abort_unless(request()->user()->can('device.user.update'), 403);
|
||||
|
||||
return Inertia::render('device-users/form', ['deviceUser' => $deviceAccessUser->load('assignments:id,device_access_user_id,device_id,group_name'), 'devices' => $this->devices()]);
|
||||
}
|
||||
|
||||
public function update(UpdateDeviceAccessUserRequest $request, DeviceAccessUser $deviceAccessUser): RedirectResponse
|
||||
{
|
||||
$this->ensureOwned($deviceAccessUser);
|
||||
$data = $request->validated();
|
||||
$this->protectManagerGroup($data['group_name']);
|
||||
$assignments = DB::transaction(function () use ($request, $data, $deviceAccessUser) {
|
||||
$attributes = ['display_name' => $data['display_name'], 'username' => $data['username'], 'is_enabled' => $data['is_enabled'] ?? false, 'notes' => $data['notes'] ?? null, 'updated_by' => $request->user()->id];
|
||||
if (filled($data['password'] ?? null)) {
|
||||
$attributes['password'] = $data['password'];
|
||||
}
|
||||
$deviceAccessUser->update($attributes);
|
||||
$selected = collect($data['device_ids'])->map(fn ($id) => (int) $id);
|
||||
$deviceAccessUser->assignments()->whereNotIn('device_id', $selected)->update(['desired_operation' => 'delete', 'sync_status' => 'queued']);
|
||||
foreach ($selected as $deviceId) {
|
||||
$deviceAccessUser->assignments()->updateOrCreate(['device_id' => $deviceId], ['tenant_id' => $request->user()->tenant_id, 'group_name' => $data['group_name'], 'desired_operation' => 'upsert', 'sync_status' => 'queued', 'error_code' => null]);
|
||||
}
|
||||
|
||||
return $deviceAccessUser->assignments()->where('sync_status', 'queued')->get();
|
||||
});
|
||||
$assignments->each(fn ($assignment) => SyncDeviceUser::dispatch($request->user()->tenant_id, $assignment->id));
|
||||
|
||||
return to_route('device-users.index')->with('success', 'Perubahan disimpan dan sinkronisasi dijadwalkan.');
|
||||
}
|
||||
|
||||
public function destroy(DeviceAccessUser $deviceAccessUser): RedirectResponse
|
||||
{
|
||||
$this->ensureOwned($deviceAccessUser);
|
||||
abort_unless(request()->user()->can('device.user.delete'), 403);
|
||||
$assignments = DB::transaction(function () use ($deviceAccessUser) {
|
||||
$deviceAccessUser->assignments()->update(['desired_operation' => 'delete', 'sync_status' => 'queued']);
|
||||
$items = $deviceAccessUser->assignments()->get();
|
||||
$deviceAccessUser->delete();
|
||||
|
||||
return $items;
|
||||
});
|
||||
$assignments->each(fn ($assignment) => SyncDeviceUser::dispatch(request()->user()->tenant_id, $assignment->id));
|
||||
|
||||
return back()->with('success', 'Penghapusan user perangkat dimasukkan ke queue.');
|
||||
}
|
||||
|
||||
private function devices()
|
||||
{
|
||||
return Device::with('vendor:id,name')->where('is_active', true)->orderBy('name')->get(['id', 'name', 'device_vendor_id', 'status', 'activation_status']);
|
||||
}
|
||||
|
||||
private function ensureOwned(DeviceAccessUser $user): void
|
||||
{
|
||||
abort_unless(! request()->user()->is_platform_admin && $user->tenant_id === request()->user()->tenant_id, 404);
|
||||
}
|
||||
|
||||
private function protectManagerGroup(string $group): void
|
||||
{
|
||||
abort_if($group === 'RADIQ-MANAGER' && ! request()->user()->can('device.master_account.manage'), 403);
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\SystemRole;
|
||||
use App\Models\Device;
|
||||
use App\Models\TenantDevicePolicy;
|
||||
use App\Services\Devices\MikrotikActivationService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Throwable;
|
||||
|
||||
class DeviceActivationController extends Controller
|
||||
{
|
||||
public function __invoke(Device $device, MikrotikActivationService $service): RedirectResponse
|
||||
{
|
||||
$user = request()->user();
|
||||
abort_if($user->is_platform_admin || $device->tenant_id !== $user->tenant_id, 404);
|
||||
abort_unless($user->can('device.connect'), 403);
|
||||
if ($user->hasRole(SystemRole::TenantUser->value)) {
|
||||
$policy = TenantDevicePolicy::where('tenant_id', $user->tenant_id)->first();
|
||||
abort_if(! $policy?->tenant_user_can_update_own || $device->created_by !== $user->id, 403);
|
||||
}
|
||||
|
||||
$device->update(['activation_status' => 'processing', 'activation_message' => null]);
|
||||
try {
|
||||
$service->activate($device->load('vendor'));
|
||||
$device->update(['activation_status' => 'active', 'activated_at' => now(), 'activation_message' => 'Group dan Base User RADIQ berhasil disinkronkan.']);
|
||||
|
||||
return back()->with('success', 'Perangkat aktif dan akun management RADIQ berhasil dibuat.');
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$message = str($exception->getMessage())->before(':')->limit(80)->toString();
|
||||
$device->update(['activation_status' => 'failed', 'activation_message' => $message]);
|
||||
|
||||
return back()->withErrors(['activation' => 'Aktivasi gagal: '.$message]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\SystemRole;
|
||||
use App\Http\Requests\StoreDeviceRequest;
|
||||
use App\Http\Requests\UpdateDeviceRequest;
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceModel;
|
||||
use App\Models\DeviceType;
|
||||
use App\Models\DeviceVendor;
|
||||
use App\Models\TenantDevicePolicy;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class DeviceController extends Controller
|
||||
{
|
||||
/**
|
||||
* Display a listing of the resource.
|
||||
*/
|
||||
public function index(Request $request): Response
|
||||
{
|
||||
$this->ensureTenantAccount();
|
||||
abort_unless($request->user()->can('device.view'), 403);
|
||||
$devices = Device::query()->with(['vendor:id,name', 'type:id,name', 'model:id,name', 'creator:id,name'])->withCount('userAssignments')
|
||||
->when($request->string('search')->isNotEmpty(), fn ($query) => $query->where(fn ($nested) => $nested
|
||||
->where('name', 'ilike', '%'.$request->string('search').'%')
|
||||
->orWhere('management_address', 'ilike', '%'.$request->string('search').'%')
|
||||
->orWhere('location', 'ilike', '%'.$request->string('search').'%')))
|
||||
->when($request->integer('vendor'), fn ($query, $vendor) => $query->where('device_vendor_id', $vendor))
|
||||
->when($request->string('status')->isNotEmpty(), fn ($query) => $query->where('status', $request->string('status')))
|
||||
->latest()->paginate(15)->withQueryString();
|
||||
|
||||
return Inertia::render('devices/index', ['devices' => $devices, 'vendors' => DeviceVendor::where('is_active', true)->get(['id', 'name']), 'filters' => $request->only('search', 'vendor', 'status'), 'policy' => $this->policy(), 'isTenantAdmin' => $request->user()->hasRole(SystemRole::TenantAdmin->value)]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for creating a new resource.
|
||||
*/
|
||||
public function create(): Response
|
||||
{
|
||||
$this->authorizeCreate();
|
||||
|
||||
return Inertia::render('devices/form', $this->catalog() + ['device' => null]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a newly created resource in storage.
|
||||
*/
|
||||
public function store(StoreDeviceRequest $request): RedirectResponse
|
||||
{
|
||||
$this->authorizeCreate();
|
||||
$this->validateModelCombination($request);
|
||||
Device::create($request->validated() + ['tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id, 'updated_by' => $request->user()->id]);
|
||||
|
||||
return to_route('devices.index')->with('success', 'Perangkat berhasil ditambahkan.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the specified resource.
|
||||
*/
|
||||
public function show(Device $device): Response
|
||||
{
|
||||
$this->ensureOwnedDevice($device);
|
||||
abort_unless(request()->user()->can('device.view'), 403);
|
||||
|
||||
return Inertia::render('devices/show', [
|
||||
'device' => $device->load(['vendor:id,name,slug', 'type:id,name', 'model:id,name', 'creator:id,name']),
|
||||
'credentials' => request()->user()->can('device.credential.view')
|
||||
? $device->credentials()->get(['id', 'name', 'username', 'connection_type', 'privilege_type', 'is_master', 'is_active', 'last_verified_at'])
|
||||
: [],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the form for editing the specified resource.
|
||||
*/
|
||||
public function edit(Device $device): Response
|
||||
{
|
||||
$this->ensureOwnedDevice($device);
|
||||
$this->authorizeUpdate($device);
|
||||
|
||||
return Inertia::render('devices/form', $this->catalog() + ['device' => $device]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the specified resource in storage.
|
||||
*/
|
||||
public function update(UpdateDeviceRequest $request, Device $device): RedirectResponse
|
||||
{
|
||||
$this->ensureOwnedDevice($device);
|
||||
$this->authorizeUpdate($device);
|
||||
$this->validateModelCombination($request);
|
||||
$device->update($request->validated() + ['updated_by' => $request->user()->id]);
|
||||
|
||||
return to_route('devices.show', $device)->with('success', 'Perangkat berhasil diperbarui.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the specified resource from storage.
|
||||
*/
|
||||
public function destroy(Device $device): RedirectResponse
|
||||
{
|
||||
$this->ensureOwnedDevice($device);
|
||||
abort_unless(request()->user()->can('device.delete'), 403);
|
||||
abort_if(request()->user()->hasRole(SystemRole::TenantUser->value) && (! $this->policy()->tenant_user_can_delete_own || $device->created_by !== request()->user()->id), 403);
|
||||
$device->delete();
|
||||
|
||||
return to_route('devices.index')->with('success', 'Perangkat berhasil dihapus.');
|
||||
}
|
||||
|
||||
private function catalog(): array
|
||||
{
|
||||
return ['vendors' => DeviceVendor::where('is_active', true)->get(['id', 'name']), 'types' => DeviceType::where('is_active', true)->get(['id', 'name']), 'models' => DeviceModel::where('is_active', true)->get(['id', 'name', 'device_vendor_id', 'device_type_id'])];
|
||||
}
|
||||
|
||||
private function policy(): TenantDevicePolicy
|
||||
{
|
||||
return TenantDevicePolicy::firstOrCreate(['tenant_id' => request()->user()->tenant_id]);
|
||||
}
|
||||
|
||||
private function ensureTenantAccount(): void
|
||||
{
|
||||
abort_if(request()->user()->is_platform_admin || ! request()->user()->tenant_id, 403);
|
||||
}
|
||||
|
||||
private function ensureOwnedDevice(Device $device): void
|
||||
{
|
||||
$this->ensureTenantAccount();
|
||||
abort_unless($device->tenant_id === request()->user()->tenant_id, 404);
|
||||
}
|
||||
|
||||
private function authorizeCreate(): void
|
||||
{
|
||||
$this->ensureTenantAccount();
|
||||
abort_unless(request()->user()->can('device.create'), 403);
|
||||
abort_if(request()->user()->hasRole(SystemRole::TenantUser->value) && ! $this->policy()->tenant_user_can_create, 403);
|
||||
}
|
||||
|
||||
private function authorizeUpdate(Device $device): void
|
||||
{
|
||||
abort_unless(request()->user()->can('device.update'), 403);
|
||||
abort_if(request()->user()->hasRole(SystemRole::TenantUser->value) && (! $this->policy()->tenant_user_can_update_own || $device->created_by !== request()->user()->id), 403);
|
||||
}
|
||||
|
||||
private function validateModelCombination(Request $request): void
|
||||
{
|
||||
if ($request->filled('device_model_id')) {
|
||||
abort_unless(DeviceModel::whereKey($request->integer('device_model_id'))->where('device_vendor_id', $request->integer('device_vendor_id'))->where('device_type_id', $request->integer('device_type_id'))->exists(), 422, 'Model tidak sesuai dengan vendor dan tipe perangkat.');
|
||||
}
|
||||
}
|
||||
}
|
||||
+53
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Http\Requests\StoreDeviceCredentialRequest;
|
||||
use App\Http\Requests\UpdateDeviceCredentialRequest;
|
||||
use App\Models\Device;
|
||||
use App\Models\DeviceCredential;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class DeviceCredentialController extends Controller
|
||||
{
|
||||
public function store(StoreDeviceCredentialRequest $request, Device $device): RedirectResponse
|
||||
{
|
||||
$this->ensureOwned($device);
|
||||
$data = $request->validated();
|
||||
$data['is_master'] = $data['privilege_type'] === 'master' || ($data['is_master'] ?? false);
|
||||
abort_if(($data['is_master'] ?? false) && ! $request->user()->can('device.master_account.manage'), 403);
|
||||
DeviceCredential::create($data + ['tenant_id' => $request->user()->tenant_id, 'device_id' => $device->id, 'created_by' => $request->user()->id, 'updated_by' => $request->user()->id]);
|
||||
|
||||
return back()->with('success', 'Credential perangkat berhasil disimpan secara terenkripsi.');
|
||||
}
|
||||
|
||||
public function update(UpdateDeviceCredentialRequest $request, Device $device, DeviceCredential $credential): RedirectResponse
|
||||
{
|
||||
$this->ensureOwned($device, $credential);
|
||||
abort_if($credential->is_master && ! $request->user()->can('device.master_account.manage'), 403);
|
||||
$data = $request->validated();
|
||||
$data['is_master'] = $data['privilege_type'] === 'master' || ($data['is_master'] ?? false);
|
||||
if (blank($data['password'] ?? null)) {
|
||||
unset($data['password']);
|
||||
}
|
||||
$credential->update($data + ['updated_by' => $request->user()->id]);
|
||||
|
||||
return back()->with('success', 'Credential perangkat berhasil diperbarui.');
|
||||
}
|
||||
|
||||
public function destroy(Device $device, DeviceCredential $credential): RedirectResponse
|
||||
{
|
||||
$this->ensureOwned($device, $credential);
|
||||
abort_unless(request()->user()->can('device.credential.delete'), 403);
|
||||
abort_if($credential->is_master && ! request()->user()->can('device.master_account.manage'), 403);
|
||||
$credential->delete();
|
||||
|
||||
return back()->with('success', 'Credential perangkat berhasil dihapus.');
|
||||
}
|
||||
|
||||
private function ensureOwned(Device $device, ?DeviceCredential $credential = null): void
|
||||
{
|
||||
abort_if(request()->user()->is_platform_admin || $device->tenant_id !== request()->user()->tenant_id, 404);
|
||||
abort_if($credential && ($credential->tenant_id !== $device->tenant_id || $credential->device_id !== $device->id), 404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Jobs\SyncDeviceUser;
|
||||
use App\Models\DeviceUserAssignment;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
|
||||
class DeviceUserSyncController extends Controller
|
||||
{
|
||||
public function __invoke(DeviceUserAssignment $assignment): RedirectResponse
|
||||
{
|
||||
abort_unless(request()->user()->can('device.user.update'), 403);
|
||||
abort_unless($assignment->tenant_id === request()->user()->tenant_id, 404);
|
||||
$assignment->update(['sync_status' => 'queued', 'error_code' => null, 'message' => null]);
|
||||
SyncDeviceUser::dispatch(request()->user()->tenant_id, $assignment->id);
|
||||
|
||||
return back()->with('success', 'Sinkronisasi ulang dimasukkan ke queue.');
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Settings\ProfileDeleteRequest;
|
||||
use App\Http\Requests\Settings\ProfileUpdateRequest;
|
||||
use Illuminate\Contracts\Auth\MustVerifyEmail;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class ProfileController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the user's profile settings page.
|
||||
*/
|
||||
public function edit(Request $request): Response
|
||||
{
|
||||
return Inertia::render('settings/profile', [
|
||||
'mustVerifyEmail' => $request->user() instanceof MustVerifyEmail,
|
||||
'status' => $request->session()->get('status'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's profile information.
|
||||
*/
|
||||
public function update(ProfileUpdateRequest $request): RedirectResponse
|
||||
{
|
||||
$request->user()->fill($request->validated());
|
||||
|
||||
if ($request->user()->isDirty('email')) {
|
||||
$request->user()->email_verified_at = null;
|
||||
}
|
||||
|
||||
$request->user()->save();
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => __('Profile updated.')]);
|
||||
|
||||
return to_route('profile.edit');
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the user's profile.
|
||||
*/
|
||||
public function destroy(ProfileDeleteRequest $request): RedirectResponse
|
||||
{
|
||||
$user = $request->user();
|
||||
|
||||
Auth::logout();
|
||||
|
||||
$user->delete();
|
||||
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect('/');
|
||||
}
|
||||
}
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Settings\PasswordUpdateRequest;
|
||||
use App\Http\Requests\Settings\TwoFactorAuthenticationRequest;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
class SecurityController extends Controller
|
||||
{
|
||||
/**
|
||||
* Show the user's security settings page.
|
||||
*/
|
||||
public function edit(TwoFactorAuthenticationRequest $request): Response
|
||||
{
|
||||
$props = [
|
||||
'canManageTwoFactor' => Features::canManageTwoFactorAuthentication(),
|
||||
'canManagePasskeys' => Features::canManagePasskeys(),
|
||||
'passkeys' => Features::canManagePasskeys()
|
||||
? $request->user()
|
||||
->passkeys()
|
||||
->select(['id', 'name', 'credential', 'created_at', 'last_used_at'])
|
||||
->latest()
|
||||
->get()
|
||||
->map(fn ($passkey) => [
|
||||
'id' => $passkey->id,
|
||||
'name' => $passkey->name,
|
||||
'authenticator' => $passkey->authenticator,
|
||||
'created_at_diff' => $passkey->created_at->diffForHumans(),
|
||||
'last_used_at_diff' => $passkey->last_used_at?->diffForHumans(),
|
||||
])
|
||||
->values()
|
||||
->all()
|
||||
: [],
|
||||
'passwordRules' => Password::defaults()->toPasswordRulesString(),
|
||||
];
|
||||
|
||||
if (Features::canManageTwoFactorAuthentication()) {
|
||||
$request->ensureStateIsValid();
|
||||
|
||||
$props['twoFactorEnabled'] = $request->user()->hasEnabledTwoFactorAuthentication();
|
||||
$props['requiresConfirmation'] = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm');
|
||||
}
|
||||
|
||||
return Inertia::render('settings/security', $props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the user's password.
|
||||
*/
|
||||
public function update(PasswordUpdateRequest $request): RedirectResponse
|
||||
{
|
||||
$request->user()->update([
|
||||
'password' => $request->password,
|
||||
]);
|
||||
|
||||
Inertia::flash('toast', ['type' => 'success', 'message' => __('Password updated.')]);
|
||||
|
||||
return back();
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\SystemRole;
|
||||
use App\Models\TenantDevicePolicy;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class TenantDevicePolicyController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->hasRole(SystemRole::TenantAdmin->value), 403);
|
||||
$data = $request->validate([
|
||||
'tenant_user_can_create' => ['required', 'boolean'],
|
||||
'tenant_user_can_update_own' => ['required', 'boolean'],
|
||||
'tenant_user_can_delete_own' => ['required', 'boolean'],
|
||||
]);
|
||||
TenantDevicePolicy::updateOrCreate(['tenant_id' => $request->user()->tenant_id], $data);
|
||||
|
||||
return back()->with('success', 'Aturan perangkat Tenant User berhasil diperbarui.');
|
||||
}
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\SystemRole;
|
||||
use App\Models\TenantDeviceSetting;
|
||||
use App\Models\TenantEncryptionKey;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Inertia\Inertia;
|
||||
use Inertia\Response;
|
||||
|
||||
class TenantDeviceSettingController extends Controller
|
||||
{
|
||||
public function edit(Request $request): Response
|
||||
{
|
||||
abort_unless($request->user()->hasRole(SystemRole::TenantAdmin->value), 403);
|
||||
$setting = TenantDeviceSetting::where('tenant_id', $request->user()->tenant_id)->first();
|
||||
|
||||
$key = TenantEncryptionKey::withoutGlobalScope('tenant')->where('tenant_id', $request->user()->tenant_id)->where('is_active', true)->first();
|
||||
|
||||
return Inertia::render('devices/settings', [
|
||||
'setting' => $setting?->only(['base_username', 'use_tls', 'verify_tls', 'connection_timeout']),
|
||||
'hasPassword' => (bool) $setting,
|
||||
'encryptionKey' => $key?->only(['version', 'source', 'created_at']),
|
||||
]);
|
||||
}
|
||||
|
||||
public function update(Request $request): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->hasRole(SystemRole::TenantAdmin->value), 403);
|
||||
$setting = TenantDeviceSetting::where('tenant_id', $request->user()->tenant_id)->first();
|
||||
$data = $request->validate([
|
||||
'base_username' => ['required', 'regex:/^[A-Za-z0-9][A-Za-z0-9_.@#-]*[A-Za-z0-9]$/', 'max:64'],
|
||||
'base_password' => [$setting ? 'nullable' : 'required', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()],
|
||||
'use_tls' => ['required', 'boolean'], 'verify_tls' => ['required', 'boolean'],
|
||||
'connection_timeout' => ['required', 'integer', 'between:3,30'],
|
||||
]);
|
||||
if (blank($data['base_password'] ?? null)) {
|
||||
unset($data['base_password']);
|
||||
}
|
||||
TenantDeviceSetting::updateOrCreate(['tenant_id' => $request->user()->tenant_id], $data);
|
||||
|
||||
return back()->with('success', 'Base User perangkat berhasil disimpan secara terenkripsi.');
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Enums\SystemRole;
|
||||
use App\Services\Encryption\TenantEnvelopeEncryption;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
|
||||
class TenantEncryptionController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, TenantEnvelopeEncryption $encryption): RedirectResponse
|
||||
{
|
||||
abort_unless($request->user()->hasRole(SystemRole::TenantAdmin->value), 403);
|
||||
$data = $request->validate([
|
||||
'mode' => ['required', 'in:generated,manual'],
|
||||
'manual_key' => ['nullable', 'required_if:mode,manual', 'string', 'min:32', 'max:4096', 'confirmed'],
|
||||
'current_password' => ['required', 'string'],
|
||||
]);
|
||||
abort_unless(Hash::check($data['current_password'], $request->user()->password), 422, 'Password akun Tenant Admin tidak valid.');
|
||||
$key = $encryption->rotate($request->user()->tenant_id, $data['mode'] === 'manual' ? $data['manual_key'] : null, $request->user()->id);
|
||||
|
||||
return back()->with('success', "Encryption key tenant berhasil dirotasi ke versi {$key->version}. Semua secret database telah dienkripsi ulang.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class EnsureActiveUser
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
if ($request->user() && ! $request->user()->is_active) {
|
||||
Auth::guard('web')->logout();
|
||||
$request->session()->invalidate();
|
||||
$request->session()->regenerateToken();
|
||||
|
||||
return redirect()->route('login')->withErrors(['email' => 'Akun Anda sedang dinonaktifkan.']);
|
||||
}
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\View;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class HandleAppearance
|
||||
{
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
View::share('appearance', $request->cookie('appearance') ?? 'system');
|
||||
|
||||
return $next($request);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Middleware;
|
||||
|
||||
class HandleInertiaRequests extends Middleware
|
||||
{
|
||||
/**
|
||||
* The root template that's loaded on the first page visit.
|
||||
*
|
||||
* @see https://inertiajs.com/server-side-setup#root-template
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
protected $rootView = 'app';
|
||||
|
||||
/**
|
||||
* Determines the current asset version.
|
||||
*
|
||||
* @see https://inertiajs.com/asset-versioning
|
||||
*/
|
||||
public function version(Request $request): ?string
|
||||
{
|
||||
return parent::version($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* Define the props that are shared by default.
|
||||
*
|
||||
* @see https://inertiajs.com/shared-data
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function share(Request $request): array
|
||||
{
|
||||
return [
|
||||
...parent::share($request),
|
||||
'name' => config('app.name'),
|
||||
'auth' => [
|
||||
'user' => $request->user(),
|
||||
'permissions' => fn () => $request->user()?->getAllPermissions()->pluck('name')->values() ?? [],
|
||||
],
|
||||
'flash' => [
|
||||
'success' => fn () => $request->session()->get('success'),
|
||||
],
|
||||
'sidebarOpen' => ! $request->hasCookie('sidebar_state') || $request->cookie('sidebar_state') === 'true',
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\TenantContext;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SelectAdministrationTenant
|
||||
{
|
||||
public function __construct(private readonly TenantContext $context) {}
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$tenant = $request->route('tenant');
|
||||
abort_unless($tenant instanceof Tenant, 404);
|
||||
|
||||
$user = $request->user();
|
||||
abort_unless($user?->is_platform_admin || $user?->tenant_id === $tenant->id, 403);
|
||||
|
||||
$previousTenantId = $this->context->id();
|
||||
$this->context->set($tenant->id);
|
||||
setPermissionsTeamId($tenant->id);
|
||||
|
||||
try {
|
||||
return $next($request);
|
||||
} finally {
|
||||
$this->context->set($previousTenantId);
|
||||
setPermissionsTeamId($previousTenantId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Middleware;
|
||||
|
||||
use App\Support\TenantContext;
|
||||
use Closure;
|
||||
use Illuminate\Http\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class SetTenantContext
|
||||
{
|
||||
public function __construct(private readonly TenantContext $context) {}
|
||||
|
||||
/**
|
||||
* Handle an incoming request.
|
||||
*
|
||||
* @param Closure(Request): (Response) $next
|
||||
*/
|
||||
public function handle(Request $request, Closure $next): Response
|
||||
{
|
||||
$user = $request->user();
|
||||
$requestedTenantId = $request->header('X-Tenant-ID');
|
||||
|
||||
if ($user?->is_platform_admin && $requestedTenantId !== null) {
|
||||
abort_unless($user->can('tenant.access-any'), 403);
|
||||
$this->context->set((int) $requestedTenantId);
|
||||
} else {
|
||||
$this->context->set($user?->tenant_id);
|
||||
}
|
||||
|
||||
setPermissionsTeamId($this->context->id());
|
||||
|
||||
try {
|
||||
return $next($request);
|
||||
} finally {
|
||||
$this->context->clear();
|
||||
setPermissionsTeamId(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Administration;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class ResetUserPasswordRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('user.update') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()],
|
||||
];
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Administration;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class StoreTenantRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('tenant.create') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['required', 'alpha_dash', 'max:100', 'unique:tenants,slug'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
'owner_name' => ['required', 'string', 'max:255'],
|
||||
'owner_email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||
'owner_password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()],
|
||||
'deployment_type' => ['sometimes', 'in:managed_cloud,self_hosted'],
|
||||
'deployment_domain' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Administration;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class StoreUserRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('user.create') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Administration;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateTenantRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('tenant.update') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['required', 'alpha_dash', 'max:100', 'unique:tenants,slug,'.$this->route('tenant')->id],
|
||||
'is_active' => ['required', 'boolean'],
|
||||
'deployment_type' => ['sometimes', 'in:managed_cloud,self_hosted'],
|
||||
'deployment_domain' => ['nullable', 'string', 'max:255'],
|
||||
];
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Administration;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateUserRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('user.update') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($this->route('user')->id)],
|
||||
];
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use App\Concerns\PasswordValidationRules;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class PasswordUpdateRequest extends FormRequest
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'current_password' => $this->currentPasswordRules(),
|
||||
'password' => $this->passwordRules(),
|
||||
];
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use App\Concerns\PasswordValidationRules;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ProfileDeleteRequest extends FormRequest
|
||||
{
|
||||
use PasswordValidationRules;
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'password' => $this->currentPasswordRules(),
|
||||
];
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use App\Concerns\ProfileValidationRules;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class ProfileUpdateRequest extends FormRequest
|
||||
{
|
||||
use ProfileValidationRules;
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return $this->profileRules($this->user()->id);
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests\Settings;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Laravel\Fortify\InteractsWithTwoFactorState;
|
||||
|
||||
class TwoFactorAuthenticationRequest extends FormRequest
|
||||
{
|
||||
use InteractsWithTwoFactorState;
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class StoreDeviceAccessUserRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('device.user.create') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return $this->rulesForUser();
|
||||
}
|
||||
|
||||
protected function rulesForUser(?int $ignoreId = null): array
|
||||
{
|
||||
$tenantId = $this->user()->tenant_id;
|
||||
|
||||
return [
|
||||
'display_name' => ['required', 'string', 'max:255'],
|
||||
'username' => ['required', 'regex:/^[A-Za-z0-9][A-Za-z0-9_.@#-]*[A-Za-z0-9]$/', 'max:64', Rule::unique('device_access_users')->where('tenant_id', $tenantId)->ignore($ignoreId)],
|
||||
'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()],
|
||||
'group_name' => ['required', 'in:RADIQ-READ,RADIQ-WRITE,RADIQ-NOC,RADIQ-MANAGER'],
|
||||
'device_ids' => ['required', 'array', 'min:1'],
|
||||
'device_ids.*' => ['integer', Rule::exists('devices', 'id')->where('tenant_id', $tenantId)],
|
||||
'is_enabled' => ['sometimes', 'boolean'], 'notes' => ['nullable', 'string', 'max:5000'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreDeviceCredentialRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('device.credential.create') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return self::deviceCredentialRules();
|
||||
}
|
||||
|
||||
public static function deviceCredentialRules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'], 'username' => ['required', 'string', 'max:255'],
|
||||
'password' => ['required', 'string', 'max:4096'],
|
||||
'connection_type' => ['required', 'in:routeros_api,ssh,telnet,snmp,vendor_api'],
|
||||
'privilege_type' => ['required', 'in:master,noc,technician,monitoring,custom'],
|
||||
'is_master' => ['sometimes', 'boolean'], 'is_active' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreDeviceRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('device.create') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return self::deviceRules();
|
||||
}
|
||||
|
||||
public static function deviceRules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'], 'hostname' => ['nullable', 'string', 'max:255'],
|
||||
'device_vendor_id' => ['required', 'exists:device_vendors,id'], 'device_type_id' => ['required', 'exists:device_types,id'],
|
||||
'device_model_id' => ['nullable', 'exists:device_models,id'], 'serial_number' => ['nullable', 'string', 'max:255'],
|
||||
'management_address' => ['required', 'string', 'max:255'],
|
||||
'connection_type' => ['required', 'in:routeros_api,ssh,telnet,snmp,vendor_api'],
|
||||
'management_port' => ['required', 'integer', 'between:1,65535'], 'location' => ['nullable', 'string', 'max:255'],
|
||||
'latitude' => ['nullable', 'numeric', 'between:-90,90'], 'longitude' => ['nullable', 'numeric', 'between:-180,180'],
|
||||
'firmware_version' => ['nullable', 'string', 'max:255'], 'software_version' => ['nullable', 'string', 'max:255'],
|
||||
'notes' => ['nullable', 'string', 'max:5000'], 'is_active' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class StoreTenantRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('tenant.create') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['required', 'alpha_dash', 'max:100', 'unique:tenants,slug'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use App\Models\DeviceAccessUser;
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class UpdateDeviceAccessUserRequest extends StoreDeviceAccessUserRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('device.user.update') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
/** @var DeviceAccessUser $deviceAccessUser */
|
||||
$deviceAccessUser = $this->route('deviceAccessUser');
|
||||
$rules = $this->rulesForUser($deviceAccessUser->id);
|
||||
$rules['password'] = ['nullable', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()];
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateDeviceCredentialRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('device.credential.update') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
$rules = StoreDeviceCredentialRequest::deviceCredentialRules();
|
||||
$rules['password'] = ['nullable', 'string', 'max:4096'];
|
||||
|
||||
return $rules;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateDeviceRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('device.update') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return StoreDeviceRequest::deviceRules();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Contracts\Validation\ValidationRule;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
|
||||
class UpdateTenantRequest extends FormRequest
|
||||
{
|
||||
/**
|
||||
* Determine if the user is authorized to make this request.
|
||||
*/
|
||||
public function authorize(): bool
|
||||
{
|
||||
return $this->user()?->can('tenant.update') ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the validation rules that apply to the request.
|
||||
*
|
||||
* @return array<string, ValidationRule|array<mixed>|string>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
||||
'slug' => ['sometimes', 'required', 'alpha_dash', 'max:100', 'unique:tenants,slug,'.$this->route('tenant')->id],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class TenantResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* Transform the resource into an array.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'uuid' => $this->uuid,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'is_active' => $this->is_active,
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Jobs;
|
||||
|
||||
use App\Models\DeviceUserAssignment;
|
||||
use App\Models\TenantDeviceSetting;
|
||||
use App\Network\Clients\RouterOs\RouterOsApiClient;
|
||||
use App\Network\Drivers\Mikrotik\MikrotikDriver;
|
||||
use App\Support\TenantContext;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Queue\Queueable;
|
||||
use Throwable;
|
||||
|
||||
class SyncDeviceUser implements ShouldQueue
|
||||
{
|
||||
use Queueable;
|
||||
|
||||
public int $tries = 1;
|
||||
|
||||
public function __construct(public readonly int $tenantId, public readonly int $assignmentId) {}
|
||||
|
||||
public function handle(TenantContext $context): void
|
||||
{
|
||||
$context->set($this->tenantId);
|
||||
setPermissionsTeamId($this->tenantId);
|
||||
try {
|
||||
$assignment = DeviceUserAssignment::with(['device.vendor', 'device.credentials', 'accessUser'])->find($this->assignmentId);
|
||||
if (! $assignment || $assignment->tenant_id !== $this->tenantId) {
|
||||
return;
|
||||
}
|
||||
if ($assignment->device->vendor->slug !== 'mikrotik' || $assignment->device->connection_type !== 'routeros_api') {
|
||||
$assignment->update(['sync_status' => 'unsupported', 'error_code' => 'DRIVER_NOT_AVAILABLE', 'message' => 'Driver user perangkat belum tersedia untuk vendor ini.']);
|
||||
|
||||
return;
|
||||
}
|
||||
$setting = TenantDeviceSetting::where('tenant_id', $this->tenantId)->first();
|
||||
if (! $setting) {
|
||||
$assignment->update(['sync_status' => 'pending', 'error_code' => 'BASE_SETTING_REQUIRED', 'message' => 'Menunggu Base User perangkat dikonfigurasi.']);
|
||||
|
||||
return;
|
||||
}
|
||||
$credential = $assignment->device->credentials->where('is_active', true)->sortByDesc('is_master')->first();
|
||||
if (! $credential) {
|
||||
$assignment->update(['sync_status' => 'pending', 'error_code' => 'CREDENTIAL_REQUIRED', 'message' => 'Menunggu credential aktif perangkat.']);
|
||||
|
||||
return;
|
||||
}
|
||||
$assignment->update(['sync_status' => 'processing', 'attempts' => $assignment->attempts + 1, 'last_attempted_at' => now(), 'error_code' => null, 'message' => null]);
|
||||
$driver = new MikrotikDriver(new RouterOsApiClient($assignment->device->management_address, $assignment->device->management_port, $credential->username, $credential->password, $setting->connection_timeout, $setting->use_tls, $setting->verify_tls));
|
||||
if ($assignment->desired_operation === 'delete') {
|
||||
$driver->deleteUser($assignment->accessUser->username);
|
||||
$result = [];
|
||||
} else {
|
||||
$result = $driver->syncUser($assignment->accessUser->username, $assignment->accessUser->password, $assignment->group_name, $assignment->accessUser->is_enabled);
|
||||
}
|
||||
$assignment->update(['sync_status' => 'synced', 'remote_id' => $result['remote_id'] ?? $assignment->remote_id, 'last_synced_at' => now(), 'error_code' => null, 'message' => 'Sinkronisasi berhasil.']);
|
||||
$assignment->device->update(['status' => 'online', 'last_seen_at' => now()]);
|
||||
} catch (Throwable $exception) {
|
||||
report($exception);
|
||||
$code = str($exception->getMessage())->before(':')->limit(64)->toString();
|
||||
$pending = in_array($code, ['DEVICE_UNREACHABLE', 'CONNECTION_TIMEOUT', 'CREDENTIAL_REQUIRED'], true);
|
||||
DeviceUserAssignment::whereKey($this->assignmentId)->update(['sync_status' => $pending ? 'pending' : 'failed', 'error_code' => $code ?: 'UNKNOWN_ERROR', 'message' => $pending ? 'Perangkat belum dapat dijangkau; akan dicoba kembali.' : 'Sinkronisasi gagal tanpa membuka data sensitif.']);
|
||||
if ($pending) {
|
||||
DeviceUserAssignment::whereKey($this->assignmentId)->first()?->device()->update(['status' => 'offline']);
|
||||
}
|
||||
} finally {
|
||||
$context->clear();
|
||||
setPermissionsTeamId(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Concerns;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Support\TenantContext;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
trait BelongsToTenant
|
||||
{
|
||||
protected static function bootBelongsToTenant(): void
|
||||
{
|
||||
static::addGlobalScope('tenant', function (Builder $builder): void {
|
||||
$tenantId = app(TenantContext::class)->id();
|
||||
|
||||
if ($tenantId !== null) {
|
||||
$builder->where($builder->qualifyColumn('tenant_id'), $tenantId);
|
||||
}
|
||||
});
|
||||
|
||||
static::creating(function (self $model): void {
|
||||
$tenantId = app(TenantContext::class)->id();
|
||||
|
||||
if ($tenantId !== null && $model->getAttribute('tenant_id') === null) {
|
||||
$model->setAttribute('tenant_id', $tenantId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public function tenant(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Tenant::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\DeploymentMode;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DeploymentInstallation extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = ['tenant_id', 'name', 'deployment_type', 'domain', 'instance_key_hash', 'fingerprint_hash', 'status', 'activated_at', 'last_seen_at'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(fn (self $installation) => $installation->uuid ??= (string) Str::uuid());
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['deployment_type' => DeploymentMode::class, 'activated_at' => 'immutable_datetime', 'last_seen_at' => 'immutable_datetime'];
|
||||
}
|
||||
|
||||
public function licenses(): HasMany
|
||||
{
|
||||
return $this->hasMany(License::class);
|
||||
}
|
||||
}
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class Device extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = ['tenant_id', 'name', 'hostname', 'device_vendor_id', 'device_type_id', 'device_model_id', 'serial_number', 'management_address', 'connection_type', 'management_port', 'location', 'latitude', 'longitude', 'firmware_version', 'software_version', 'status', 'last_seen_at', 'notes', 'is_active', 'created_by', 'updated_by', 'activation_status', 'activated_at', 'activation_message'];
|
||||
|
||||
protected $hidden = ['credentials'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(fn (Device $device) => $device->uuid ??= (string) Str::uuid());
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_active' => 'boolean', 'last_seen_at' => 'datetime', 'activated_at' => 'datetime', 'latitude' => 'decimal:7', 'longitude' => 'decimal:7'];
|
||||
}
|
||||
|
||||
public function vendor()
|
||||
{
|
||||
return $this->belongsTo(DeviceVendor::class, 'device_vendor_id');
|
||||
}
|
||||
|
||||
public function type()
|
||||
{
|
||||
return $this->belongsTo(DeviceType::class, 'device_type_id');
|
||||
}
|
||||
|
||||
public function model()
|
||||
{
|
||||
return $this->belongsTo(DeviceModel::class, 'device_model_id');
|
||||
}
|
||||
|
||||
public function credentials()
|
||||
{
|
||||
return $this->hasMany(DeviceCredential::class);
|
||||
}
|
||||
|
||||
public function creator()
|
||||
{
|
||||
return $this->belongsTo(User::class, 'created_by');
|
||||
}
|
||||
|
||||
public function userAssignments()
|
||||
{
|
||||
return $this->hasMany(DeviceUserAssignment::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Casts\TenantEncrypted;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DeviceAccessUser extends Model
|
||||
{
|
||||
use BelongsToTenant, SoftDeletes;
|
||||
|
||||
protected $fillable = ['tenant_id', 'display_name', 'username', 'password', 'is_enabled', 'notes', 'created_by', 'updated_by'];
|
||||
|
||||
protected $hidden = ['password'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(fn (self $user) => $user->uuid ??= (string) Str::uuid());
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['password' => TenantEncrypted::class, 'is_enabled' => 'boolean'];
|
||||
}
|
||||
|
||||
public function assignments()
|
||||
{
|
||||
return $this->hasMany(DeviceUserAssignment::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Casts\TenantEncrypted;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DeviceCredential extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = ['tenant_id', 'device_id', 'name', 'username', 'password', 'connection_type', 'privilege_type', 'is_master', 'is_active', 'last_verified_at', 'created_by', 'updated_by'];
|
||||
|
||||
protected $hidden = ['password'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(fn (DeviceCredential $credential) => $credential->uuid ??= (string) Str::uuid());
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['password' => TenantEncrypted::class, 'is_master' => 'boolean', 'is_active' => 'boolean', 'last_verified_at' => 'datetime'];
|
||||
}
|
||||
|
||||
public function device()
|
||||
{
|
||||
return $this->belongsTo(Device::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class DeviceModel extends Model
|
||||
{
|
||||
protected $fillable = ['device_vendor_id', 'device_type_id', 'name', 'model_code', 'description', 'is_active'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_active' => 'boolean'];
|
||||
}
|
||||
|
||||
public function vendor()
|
||||
{
|
||||
return $this->belongsTo(DeviceVendor::class, 'device_vendor_id');
|
||||
}
|
||||
|
||||
public function type()
|
||||
{
|
||||
return $this->belongsTo(DeviceType::class, 'device_type_id');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class DeviceType extends Model
|
||||
{
|
||||
protected $fillable = ['name', 'slug', 'is_active'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_active' => 'boolean'];
|
||||
}
|
||||
|
||||
public function models()
|
||||
{
|
||||
return $this->hasMany(DeviceModel::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class DeviceUserAssignment extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = ['tenant_id', 'device_access_user_id', 'device_id', 'group_name', 'desired_operation', 'sync_status', 'remote_id', 'error_code', 'message', 'attempts', 'last_attempted_at', 'last_synced_at'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(fn (self $assignment) => $assignment->uuid ??= (string) Str::uuid());
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['last_attempted_at' => 'datetime', 'last_synced_at' => 'datetime'];
|
||||
}
|
||||
|
||||
public function accessUser()
|
||||
{
|
||||
return $this->belongsTo(DeviceAccessUser::class, 'device_access_user_id')->withTrashed();
|
||||
}
|
||||
|
||||
public function device()
|
||||
{
|
||||
return $this->belongsTo(Device::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class DeviceVendor extends Model
|
||||
{
|
||||
protected $fillable = ['name', 'slug', 'description', 'is_active'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_active' => 'boolean'];
|
||||
}
|
||||
|
||||
public function models()
|
||||
{
|
||||
return $this->hasMany(DeviceModel::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Enums\LicenseStatus;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class License extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = ['tenant_id', 'deployment_installation_id', 'license_key_hash', 'plan', 'status', 'max_devices', 'max_users', 'features', 'signed_payload', 'starts_at', 'expires_at', 'grace_until', 'issued_by'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(fn (self $license) => $license->uuid ??= (string) Str::uuid());
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['status' => LicenseStatus::class, 'features' => 'array', 'starts_at' => 'immutable_datetime', 'expires_at' => 'immutable_datetime', 'grace_until' => 'immutable_datetime'];
|
||||
}
|
||||
|
||||
public function installation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DeploymentInstallation::class, 'deployment_installation_id');
|
||||
}
|
||||
|
||||
public function issuer(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'issued_by');
|
||||
}
|
||||
|
||||
public function events(): HasMany
|
||||
{
|
||||
return $this->hasMany(LicenseEvent::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class LicenseEvent extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = ['tenant_id', 'license_id', 'deployment_installation_id', 'actor_id', 'event_type', 'metadata', 'occurred_at'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(fn (self $event) => $event->uuid ??= (string) Str::uuid());
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['metadata' => 'array', 'occurred_at' => 'immutable_datetime'];
|
||||
}
|
||||
|
||||
public function license(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(License::class);
|
||||
}
|
||||
|
||||
public function installation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(DeploymentInstallation::class, 'deployment_installation_id');
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Support\Str;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class Tenant extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = ['name', 'slug', 'is_active'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (Tenant $tenant): void {
|
||||
$tenant->uuid ??= (string) Str::uuid();
|
||||
});
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_active' => 'boolean'];
|
||||
}
|
||||
|
||||
public function users(): HasMany
|
||||
{
|
||||
return $this->hasMany(User::class);
|
||||
}
|
||||
|
||||
public function roles(): HasMany
|
||||
{
|
||||
return $this->hasMany(Role::class);
|
||||
}
|
||||
|
||||
public function installations(): HasMany
|
||||
{
|
||||
return $this->hasMany(DeploymentInstallation::class);
|
||||
}
|
||||
|
||||
public function licenses(): HasMany
|
||||
{
|
||||
return $this->hasMany(License::class);
|
||||
}
|
||||
|
||||
public function devices(): HasMany
|
||||
{
|
||||
return $this->hasMany(Device::class);
|
||||
}
|
||||
|
||||
public function devicePolicy()
|
||||
{
|
||||
return $this->hasOne(TenantDevicePolicy::class);
|
||||
}
|
||||
|
||||
public function deviceSetting()
|
||||
{
|
||||
return $this->hasOne(TenantDeviceSetting::class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TenantDevicePolicy extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = ['tenant_id', 'tenant_user_can_create', 'tenant_user_can_update_own', 'tenant_user_can_delete_own'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['tenant_user_can_create' => 'boolean', 'tenant_user_can_update_own' => 'boolean', 'tenant_user_can_delete_own' => 'boolean'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Casts\TenantEncrypted;
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class TenantDeviceSetting extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = ['tenant_id', 'base_username', 'base_password', 'use_tls', 'verify_tls', 'connection_timeout'];
|
||||
|
||||
protected $hidden = ['base_password'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['base_password' => TenantEncrypted::class, 'use_tls' => 'boolean', 'verify_tls' => 'boolean', 'connection_timeout' => 'integer'];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class TenantEncryptionKey extends Model
|
||||
{
|
||||
use BelongsToTenant;
|
||||
|
||||
protected $fillable = ['tenant_id', 'version', 'wrapped_key', 'is_active', 'source', 'created_by', 'retired_at'];
|
||||
|
||||
protected $hidden = ['wrapped_key'];
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(fn (self $key) => $key->uuid ??= (string) Str::uuid());
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return ['is_active' => 'boolean', 'retired_at' => 'immutable_datetime'];
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use App\Models\Concerns\BelongsToTenant;
|
||||
use Database\Factories\UserFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\Hidden;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Foundation\Auth\User as Authenticatable;
|
||||
use Illuminate\Notifications\Notifiable;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Str;
|
||||
use Laravel\Fortify\Contracts\PasskeyUser;
|
||||
use Laravel\Fortify\PasskeyAuthenticatable;
|
||||
use Laravel\Fortify\TwoFactorAuthenticatable;
|
||||
use Laravel\Sanctum\HasApiTokens;
|
||||
use Spatie\Permission\Traits\HasRoles;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $name
|
||||
* @property string $email
|
||||
* @property Carbon|null $email_verified_at
|
||||
* @property string $password
|
||||
* @property string|null $two_factor_secret
|
||||
* @property string|null $two_factor_recovery_codes
|
||||
* @property Carbon|null $two_factor_confirmed_at
|
||||
* @property string|null $remember_token
|
||||
* @property Carbon|null $created_at
|
||||
* @property Carbon|null $updated_at
|
||||
*/
|
||||
#[Fillable(['tenant_id', 'name', 'email', 'password', 'is_active'])]
|
||||
#[Hidden(['password', 'two_factor_secret', 'two_factor_recovery_codes', 'remember_token'])]
|
||||
class User extends Authenticatable implements PasskeyUser
|
||||
{
|
||||
/** @use HasFactory<UserFactory> */
|
||||
use BelongsToTenant, HasApiTokens, HasFactory, HasRoles, Notifiable, PasskeyAuthenticatable, TwoFactorAuthenticatable;
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (User $user): void {
|
||||
$user->uuid ??= (string) Str::uuid();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the attributes that should be cast.
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'email_verified_at' => 'datetime',
|
||||
'password' => 'hashed',
|
||||
'two_factor_confirmed_at' => 'datetime',
|
||||
'is_platform_admin' => 'boolean',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
}
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace App\Network\Clients\RouterOs;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
class RouterOsApiClient
|
||||
{
|
||||
/** @var resource|null */
|
||||
private $socket;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $host,
|
||||
private readonly int $port,
|
||||
private readonly string $username,
|
||||
private readonly string $password,
|
||||
private readonly int $timeout = 10,
|
||||
private readonly bool $tls = false,
|
||||
private readonly bool $verifyTls = true,
|
||||
) {}
|
||||
|
||||
public function connect(): void
|
||||
{
|
||||
$transport = $this->tls ? 'tls' : 'tcp';
|
||||
$context = stream_context_create(['ssl' => ['verify_peer' => $this->verifyTls, 'verify_peer_name' => $this->verifyTls, 'SNI_enabled' => true]]);
|
||||
$socket = @stream_socket_client("{$transport}://{$this->host}:{$this->port}", $errorNumber, $errorMessage, $this->timeout, STREAM_CLIENT_CONNECT, $context);
|
||||
if (! is_resource($socket)) {
|
||||
throw new RuntimeException('DEVICE_UNREACHABLE: koneksi RouterOS API gagal.');
|
||||
}
|
||||
$this->socket = $socket;
|
||||
stream_set_timeout($this->socket, $this->timeout);
|
||||
$this->command(['/login', '=name='.$this->username, '=password='.$this->password]);
|
||||
}
|
||||
|
||||
public function disconnect(): void
|
||||
{
|
||||
if (is_resource($this->socket)) {
|
||||
fclose($this->socket);
|
||||
}
|
||||
$this->socket = null;
|
||||
}
|
||||
|
||||
/** @return list<array<string, string>> */
|
||||
public function command(array $words): array
|
||||
{
|
||||
if (! is_resource($this->socket)) {
|
||||
throw new RuntimeException('DRIVER_NOT_CONNECTED');
|
||||
}
|
||||
foreach ($words as $word) {
|
||||
$this->writeWord($word);
|
||||
}
|
||||
$this->writeWord('');
|
||||
|
||||
$rows = [];
|
||||
while (true) {
|
||||
$sentence = $this->readSentence();
|
||||
$type = array_shift($sentence);
|
||||
if ($type === '!trap' || $type === '!fatal') {
|
||||
throw new RuntimeException('COMMAND_REJECTED: RouterOS menolak operasi.');
|
||||
}
|
||||
if ($type === '!re') {
|
||||
$rows[] = $this->attributes($sentence);
|
||||
}
|
||||
if ($type === '!done') {
|
||||
return $rows;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function writeWord(string $word): void
|
||||
{
|
||||
$length = strlen($word);
|
||||
$prefix = match (true) {
|
||||
$length < 0x80 => chr($length),
|
||||
$length < 0x4000 => pack('n', $length | 0x8000),
|
||||
$length < 0x200000 => substr(pack('N', $length | 0xC0000000), 1),
|
||||
$length < 0x10000000 => pack('N', $length | 0xE0000000),
|
||||
default => chr(0xF0).pack('N', $length),
|
||||
};
|
||||
$this->writeAll($prefix.$word);
|
||||
}
|
||||
|
||||
/** @return list<string> */
|
||||
private function readSentence(): array
|
||||
{
|
||||
$words = [];
|
||||
while (($word = $this->readWord()) !== '') {
|
||||
$words[] = $word;
|
||||
}
|
||||
|
||||
return $words;
|
||||
}
|
||||
|
||||
private function readWord(): string
|
||||
{
|
||||
$length = $this->readLength();
|
||||
|
||||
return $length === 0 ? '' : $this->readBytes($length);
|
||||
}
|
||||
|
||||
private function readLength(): int
|
||||
{
|
||||
$first = ord($this->readBytes(1));
|
||||
if (($first & 0x80) === 0) {
|
||||
return $first;
|
||||
}
|
||||
if (($first & 0xC0) === 0x80) {
|
||||
return (($first & 0x3F) << 8) + ord($this->readBytes(1));
|
||||
}
|
||||
if (($first & 0xE0) === 0xC0) {
|
||||
$bytes = $this->readBytes(2);
|
||||
|
||||
return (($first & 0x1F) << 16) + (ord($bytes[0]) << 8) + ord($bytes[1]);
|
||||
}
|
||||
if (($first & 0xF0) === 0xE0) {
|
||||
$bytes = $this->readBytes(3);
|
||||
|
||||
return (($first & 0x0F) << 24) + (ord($bytes[0]) << 16) + (ord($bytes[1]) << 8) + ord($bytes[2]);
|
||||
}
|
||||
|
||||
return unpack('N', $this->readBytes(4))[1];
|
||||
}
|
||||
|
||||
private function readBytes(int $length): string
|
||||
{
|
||||
$data = '';
|
||||
while (strlen($data) < $length) {
|
||||
$chunk = fread($this->socket, $length - strlen($data));
|
||||
if ($chunk === false || $chunk === '') {
|
||||
throw new RuntimeException('CONNECTION_TIMEOUT: respons RouterOS tidak lengkap.');
|
||||
}
|
||||
$data .= $chunk;
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
private function writeAll(string $data): void
|
||||
{
|
||||
while ($data !== '') {
|
||||
$written = fwrite($this->socket, $data);
|
||||
if ($written === false || $written === 0) {
|
||||
throw new RuntimeException('CONNECTION_FAILED');
|
||||
}
|
||||
$data = substr($data, $written);
|
||||
}
|
||||
}
|
||||
|
||||
private function attributes(array $words): array
|
||||
{
|
||||
$attributes = [];
|
||||
foreach ($words as $word) {
|
||||
if (str_starts_with($word, '=')) {
|
||||
[, $key, $value] = array_pad(explode('=', $word, 3), 3, '');
|
||||
$attributes[$key] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $attributes;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Network\Contracts;
|
||||
|
||||
interface DeviceDriverInterface
|
||||
{
|
||||
public function testConnection(): array;
|
||||
|
||||
public function provisionBaseAccess(string $username, string $password): array;
|
||||
|
||||
public function syncUser(string $username, string $password, string $group, bool $enabled): array;
|
||||
|
||||
public function deleteUser(string $username): void;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Network\Drivers\Mikrotik;
|
||||
|
||||
use App\Network\Clients\RouterOs\RouterOsApiClient;
|
||||
use App\Network\Contracts\DeviceDriverInterface;
|
||||
|
||||
class MikrotikDriver implements DeviceDriverInterface
|
||||
{
|
||||
private const GROUPS = [
|
||||
'RADIQ-READ' => 'local,ssh,read,test,winbox,api',
|
||||
'RADIQ-WRITE' => 'local,ssh,read,write,test,winbox,password,api',
|
||||
'RADIQ-NOC' => 'local,ssh,read,write,test,winbox,password,api',
|
||||
'RADIQ-MANAGER' => 'local,ssh,read,write,policy,test,winbox,password,sensitive,api',
|
||||
];
|
||||
|
||||
public function __construct(private readonly RouterOsApiClient $client) {}
|
||||
|
||||
public function testConnection(): array
|
||||
{
|
||||
$this->client->connect();
|
||||
try {
|
||||
return $this->client->command(['/system/identity/print', '=.proplist=name'])[0] ?? [];
|
||||
} finally {
|
||||
$this->client->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
public function provisionBaseAccess(string $username, string $password): array
|
||||
{
|
||||
$this->client->connect();
|
||||
try {
|
||||
foreach (self::GROUPS as $name => $policies) {
|
||||
$existing = $this->client->command(['/user/group/print', '?name='.$name, '=.proplist=.id']);
|
||||
if ($existing === []) {
|
||||
$this->client->command(['/user/group/add', '=name='.$name, '=policy='.$policies, '=comment=Managed by RADIQ NDM']);
|
||||
} else {
|
||||
$this->client->command(['/user/group/set', '=.id='.$existing[0]['.id'], '=policy='.$policies, '=comment=Managed by RADIQ NDM']);
|
||||
}
|
||||
}
|
||||
|
||||
$users = $this->client->command(['/user/print', '?name='.$username, '=.proplist=.id']);
|
||||
if ($users === []) {
|
||||
$this->client->command(['/user/add', '=name='.$username, '=password='.$password, '=group=RADIQ-MANAGER', '=disabled=no', '=comment=Managed by RADIQ NDM']);
|
||||
} else {
|
||||
$this->client->command(['/user/set', '=.id='.$users[0]['.id'], '=password='.$password, '=group=RADIQ-MANAGER', '=disabled=no', '=comment=Managed by RADIQ NDM']);
|
||||
}
|
||||
|
||||
return ['groups' => array_keys(self::GROUPS), 'username' => $username];
|
||||
} finally {
|
||||
$this->client->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
public function syncUser(string $username, string $password, string $group, bool $enabled): array
|
||||
{
|
||||
$this->client->connect();
|
||||
try {
|
||||
$users = $this->client->command(['/user/print', '?name='.$username, '=.proplist=.id']);
|
||||
if ($users === []) {
|
||||
$this->client->command(['/user/add', '=name='.$username, '=password='.$password, '=group='.$group, '=disabled='.($enabled ? 'no' : 'yes'), '=comment=Managed by RADIQ NDM']);
|
||||
$users = $this->client->command(['/user/print', '?name='.$username, '=.proplist=.id']);
|
||||
} else {
|
||||
$this->client->command(['/user/set', '=.id='.$users[0]['.id'], '=password='.$password, '=group='.$group, '=disabled='.($enabled ? 'no' : 'yes'), '=comment=Managed by RADIQ NDM']);
|
||||
}
|
||||
|
||||
return ['remote_id' => $users[0]['.id'] ?? null];
|
||||
} finally {
|
||||
$this->client->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
public function deleteUser(string $username): void
|
||||
{
|
||||
$this->client->connect();
|
||||
try {
|
||||
$users = $this->client->command(['/user/print', '?name='.$username, '=.proplist=.id']);
|
||||
if ($users !== []) {
|
||||
$this->client->command(['/user/remove', '=.id='.$users[0]['.id']]);
|
||||
}
|
||||
} finally {
|
||||
$this->client->disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\User;
|
||||
|
||||
class TenantPolicy
|
||||
{
|
||||
/**
|
||||
* Determine whether the user can view any models.
|
||||
*/
|
||||
public function viewAny(User $user): bool
|
||||
{
|
||||
return $user->can('tenant.view');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can view the model.
|
||||
*/
|
||||
public function view(User $user, Tenant $tenant): bool
|
||||
{
|
||||
return $user->can('tenant.view') && ($user->tenant_id === $tenant->id || $user->is_platform_admin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can create models.
|
||||
*/
|
||||
public function create(User $user): bool
|
||||
{
|
||||
return $user->is_platform_admin && $user->can('tenant.create');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can update the model.
|
||||
*/
|
||||
public function update(User $user, Tenant $tenant): bool
|
||||
{
|
||||
return $user->can('tenant.update') && ($user->tenant_id === $tenant->id || $user->is_platform_admin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can delete the model.
|
||||
*/
|
||||
public function delete(User $user, Tenant $tenant): bool
|
||||
{
|
||||
return $user->is_platform_admin && $user->can('tenant.delete');
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can restore the model.
|
||||
*/
|
||||
public function restore(User $user, Tenant $tenant): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the user can permanently delete the model.
|
||||
*/
|
||||
public function forceDelete(User $user, Tenant $tenant): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Support\TenantContext;
|
||||
use Carbon\CarbonImmutable;
|
||||
use Illuminate\Support\Facades\Date;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
|
||||
class AppServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(TenantContext::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
Gate::before(fn ($user): ?bool => $user->is_platform_admin && config('deployment.mode') !== 'self_hosted' ? true : null);
|
||||
|
||||
$this->configureDefaults();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure default behaviors for production-ready applications.
|
||||
*/
|
||||
protected function configureDefaults(): void
|
||||
{
|
||||
Date::use(CarbonImmutable::class);
|
||||
|
||||
DB::prohibitDestructiveCommands(
|
||||
app()->isProduction(),
|
||||
);
|
||||
|
||||
Password::defaults(fn (): ?Password => app()->isProduction()
|
||||
? Password::min(12)
|
||||
->mixedCase()
|
||||
->letters()
|
||||
->numbers()
|
||||
->symbols()
|
||||
->uncompromised()
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Actions\Fortify\CreateNewUser;
|
||||
use App\Actions\Fortify\ResetUserPassword;
|
||||
use App\Models\User;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use Illuminate\Support\Str;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Inertia\Inertia;
|
||||
use Laravel\Fortify\Features;
|
||||
use Laravel\Fortify\Fortify;
|
||||
|
||||
class FortifyServiceProvider extends ServiceProvider
|
||||
{
|
||||
/**
|
||||
* Register any application services.
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
/**
|
||||
* Bootstrap any application services.
|
||||
*/
|
||||
public function boot(): void
|
||||
{
|
||||
$this->configureActions();
|
||||
$this->configureViews();
|
||||
$this->configureRateLimiting();
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Fortify actions.
|
||||
*/
|
||||
private function configureActions(): void
|
||||
{
|
||||
Fortify::authenticateUsing(function (Request $request): ?User {
|
||||
$user = User::query()->withoutGlobalScope('tenant')->where('email', $request->string('email'))->first();
|
||||
|
||||
return $user && $user->is_active && Hash::check($request->string('password'), $user->password)
|
||||
? $user
|
||||
: null;
|
||||
});
|
||||
|
||||
Fortify::resetUserPasswordsUsing(ResetUserPassword::class);
|
||||
Fortify::createUsersUsing(CreateNewUser::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure Fortify views.
|
||||
*/
|
||||
private function configureViews(): void
|
||||
{
|
||||
Fortify::loginView(fn (Request $request) => Inertia::render('auth/login', [
|
||||
'canResetPassword' => Features::enabled(Features::resetPasswords()),
|
||||
'status' => $request->session()->get('status'),
|
||||
]));
|
||||
|
||||
Fortify::resetPasswordView(fn (Request $request) => Inertia::render('auth/reset-password', [
|
||||
'email' => $request->email,
|
||||
'token' => $request->route('token'),
|
||||
'passwordRules' => Password::defaults()->toPasswordRulesString(),
|
||||
]));
|
||||
|
||||
Fortify::requestPasswordResetLinkView(fn (Request $request) => Inertia::render('auth/forgot-password', [
|
||||
'status' => $request->session()->get('status'),
|
||||
]));
|
||||
|
||||
Fortify::verifyEmailView(fn (Request $request) => Inertia::render('auth/verify-email', [
|
||||
'status' => $request->session()->get('status'),
|
||||
]));
|
||||
|
||||
Fortify::twoFactorChallengeView(fn () => Inertia::render('auth/two-factor-challenge'));
|
||||
|
||||
Fortify::confirmPasswordView(fn () => Inertia::render('auth/confirm-password'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure rate limiting.
|
||||
*/
|
||||
private function configureRateLimiting(): void
|
||||
{
|
||||
RateLimiter::for('two-factor', function (Request $request) {
|
||||
return 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());
|
||||
|
||||
return Limit::perMinute(5)->by($throttleKey);
|
||||
});
|
||||
|
||||
RateLimiter::for('passkeys', function (Request $request) {
|
||||
return Limit::perMinute(10)->by(
|
||||
($request->input('credential.id') ?: $request->session()->getId()).'|'.$request->ip(),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Devices;
|
||||
|
||||
use App\Models\Device;
|
||||
use App\Models\TenantDeviceSetting;
|
||||
use App\Network\Clients\RouterOs\RouterOsApiClient;
|
||||
use App\Network\Drivers\Mikrotik\MikrotikDriver;
|
||||
use RuntimeException;
|
||||
|
||||
class MikrotikActivationService
|
||||
{
|
||||
public function activate(Device $device): array
|
||||
{
|
||||
abort_unless($device->vendor?->slug === 'mikrotik' && $device->connection_type === 'routeros_api', 422, 'Aktivasi otomatis saat ini hanya mendukung MikroTik RouterOS API.');
|
||||
$setting = TenantDeviceSetting::where('tenant_id', $device->tenant_id)->first();
|
||||
if (! $setting) {
|
||||
throw new RuntimeException('BASE_SETTING_REQUIRED: atur Base User dan Password terlebih dahulu.');
|
||||
}
|
||||
$credential = $device->credentials()->where('is_active', true)->orderByDesc('is_master')->first();
|
||||
if (! $credential) {
|
||||
throw new RuntimeException('CREDENTIAL_REQUIRED: tambahkan credential login perangkat terlebih dahulu.');
|
||||
}
|
||||
|
||||
$client = new RouterOsApiClient($device->management_address, $device->management_port, $credential->username, $credential->password, $setting->connection_timeout, $setting->use_tls, $setting->verify_tls);
|
||||
|
||||
return (new MikrotikDriver($client))->provisionBaseAccess($setting->base_username, $setting->base_password);
|
||||
}
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Encryption;
|
||||
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantEncryptionKey;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use RuntimeException;
|
||||
|
||||
class TenantEnvelopeEncryption
|
||||
{
|
||||
private const PREFIX = 'radiq:v1:';
|
||||
|
||||
public function ensureKey(int $tenantId, ?int $actorId = null): TenantEncryptionKey
|
||||
{
|
||||
return TenantEncryptionKey::withoutGlobalScope('tenant')->where('tenant_id', $tenantId)->where('is_active', true)->first()
|
||||
?? $this->createKey($tenantId, null, $actorId);
|
||||
}
|
||||
|
||||
public function encrypt(int $tenantId, string $plaintext, ?TenantEncryptionKey $key = null): string
|
||||
{
|
||||
$key ??= $this->ensureKey($tenantId);
|
||||
$dek = Crypt::decryptString($key->wrapped_key);
|
||||
$nonce = random_bytes(12);
|
||||
$tag = '';
|
||||
$ciphertext = openssl_encrypt($plaintext, 'aes-256-gcm', $dek, OPENSSL_RAW_DATA, $nonce, $tag, (string) $tenantId);
|
||||
if ($ciphertext === false) {
|
||||
throw new RuntimeException('ENCRYPTION_FAILED');
|
||||
}
|
||||
|
||||
return self::PREFIX.$key->id.':'.base64_encode($nonce).':'.base64_encode($tag).':'.base64_encode($ciphertext);
|
||||
}
|
||||
|
||||
public function decrypt(int $tenantId, string $payload): string
|
||||
{
|
||||
if (! str_starts_with($payload, self::PREFIX)) {
|
||||
return Crypt::decryptString($payload);
|
||||
}
|
||||
$parts = explode(':', $payload, 6);
|
||||
if (count($parts) !== 6) {
|
||||
throw new RuntimeException('INVALID_ENCRYPTED_PAYLOAD');
|
||||
}
|
||||
$key = TenantEncryptionKey::withoutGlobalScope('tenant')->where('tenant_id', $tenantId)->findOrFail((int) $parts[2]);
|
||||
$dek = Crypt::decryptString($key->wrapped_key);
|
||||
$plaintext = openssl_decrypt(base64_decode($parts[5], true), 'aes-256-gcm', $dek, OPENSSL_RAW_DATA, base64_decode($parts[3], true), base64_decode($parts[4], true), (string) $tenantId);
|
||||
if ($plaintext === false) {
|
||||
throw new RuntimeException('DECRYPTION_FAILED');
|
||||
}
|
||||
|
||||
return $plaintext;
|
||||
}
|
||||
|
||||
public function rotate(int $tenantId, ?string $manualKey, int $actorId): TenantEncryptionKey
|
||||
{
|
||||
return DB::transaction(function () use ($tenantId, $manualKey, $actorId): TenantEncryptionKey {
|
||||
Tenant::whereKey($tenantId)->lockForUpdate()->firstOrFail();
|
||||
$oldKeys = TenantEncryptionKey::withoutGlobalScope('tenant')->where('tenant_id', $tenantId)->lockForUpdate()->get();
|
||||
$newKey = $this->createKey($tenantId, $manualKey, $actorId, ($oldKeys->max('version') ?? 0) + 1);
|
||||
foreach (DB::table('device_credentials')->where('tenant_id', $tenantId)->lockForUpdate()->get(['id', 'password']) as $credential) {
|
||||
DB::table('device_credentials')->where('id', $credential->id)->update(['password' => $this->encrypt($tenantId, $this->decrypt($tenantId, $credential->password), $newKey), 'updated_at' => now()]);
|
||||
}
|
||||
foreach (DB::table('tenant_device_settings')->where('tenant_id', $tenantId)->lockForUpdate()->get(['id', 'base_password']) as $setting) {
|
||||
DB::table('tenant_device_settings')->where('id', $setting->id)->update(['base_password' => $this->encrypt($tenantId, $this->decrypt($tenantId, $setting->base_password), $newKey), 'updated_at' => now()]);
|
||||
}
|
||||
foreach (DB::table('device_access_users')->where('tenant_id', $tenantId)->lockForUpdate()->get(['id', 'password']) as $accessUser) {
|
||||
DB::table('device_access_users')->where('id', $accessUser->id)->update(['password' => $this->encrypt($tenantId, $this->decrypt($tenantId, $accessUser->password), $newKey), 'updated_at' => now()]);
|
||||
}
|
||||
TenantEncryptionKey::withoutGlobalScope('tenant')->where('tenant_id', $tenantId)->where('id', '!=', $newKey->id)->where('is_active', true)->update(['is_active' => false, 'retired_at' => now()]);
|
||||
|
||||
return $newKey;
|
||||
});
|
||||
}
|
||||
|
||||
private function createKey(int $tenantId, ?string $manualKey, ?int $actorId, int $version = 1): TenantEncryptionKey
|
||||
{
|
||||
$tenant = Tenant::findOrFail($tenantId);
|
||||
$dek = $manualKey === null ? random_bytes(32) : hash_hkdf('sha256', $manualKey, 32, 'RADIQ-NDM:'.$tenant->uuid);
|
||||
|
||||
return TenantEncryptionKey::withoutGlobalScope('tenant')->create([
|
||||
'tenant_id' => $tenantId, 'version' => $version, 'wrapped_key' => Crypt::encryptString($dek),
|
||||
'is_active' => true, 'source' => $manualKey === null ? 'generated' : 'manual', 'created_by' => $actorId,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Enums\SystemRole;
|
||||
use App\Models\DeploymentInstallation;
|
||||
use App\Models\Tenant;
|
||||
use App\Models\TenantDevicePolicy;
|
||||
use App\Models\User;
|
||||
use App\Services\Encryption\TenantEnvelopeEncryption;
|
||||
use App\Support\SystemRolePermissions;
|
||||
use App\Support\TenantContext;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
class TenantProvisioningService
|
||||
{
|
||||
public function __construct(private readonly TenantContext $context) {}
|
||||
|
||||
/** @param array{name:string,slug:string,is_active?:bool,owner_name:string,owner_email:string,owner_password:string} $data */
|
||||
public function createWithOwner(array $data): Tenant
|
||||
{
|
||||
return DB::transaction(function () use ($data): Tenant {
|
||||
$tenant = Tenant::create([
|
||||
'name' => $data['name'],
|
||||
'slug' => $data['slug'],
|
||||
'is_active' => $data['is_active'] ?? true,
|
||||
]);
|
||||
TenantDevicePolicy::create(['tenant_id' => $tenant->id]);
|
||||
app(TenantEnvelopeEncryption::class)->ensureKey($tenant->id);
|
||||
$deploymentType = $data['deployment_type'] ?? 'managed_cloud';
|
||||
DeploymentInstallation::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => $deploymentType === 'self_hosted' ? 'Server Tenant' : 'RADIQ Managed Cloud',
|
||||
'deployment_type' => $deploymentType,
|
||||
'domain' => $data['deployment_domain'] ?? null,
|
||||
'instance_key_hash' => hash('sha256', random_bytes(32)),
|
||||
'status' => $deploymentType === 'managed_cloud' ? 'active' : 'pending',
|
||||
'activated_at' => $deploymentType === 'managed_cloud' ? now() : null,
|
||||
]);
|
||||
|
||||
$this->context->set($tenant->id);
|
||||
setPermissionsTeamId($tenant->id);
|
||||
|
||||
try {
|
||||
$roles = $this->createDefaultRoles($tenant);
|
||||
|
||||
$owner = User::create([
|
||||
'tenant_id' => $tenant->id,
|
||||
'name' => $data['owner_name'],
|
||||
'email' => $data['owner_email'],
|
||||
'password' => $data['owner_password'],
|
||||
'is_active' => true,
|
||||
]);
|
||||
$owner->forceFill(['email_verified_at' => now()])->save();
|
||||
$owner->assignRole($roles[SystemRole::TenantAdmin->value]);
|
||||
} finally {
|
||||
$this->context->clear();
|
||||
setPermissionsTeamId(null);
|
||||
}
|
||||
|
||||
return $tenant;
|
||||
});
|
||||
}
|
||||
|
||||
/** @return array<string, Role> */
|
||||
private function createDefaultRoles(Tenant $tenant): array
|
||||
{
|
||||
$definitions = SystemRolePermissions::tenantRoles();
|
||||
|
||||
$roles = [];
|
||||
|
||||
foreach ($definitions as $name => $permissions) {
|
||||
$role = Role::create(['tenant_id' => $tenant->id, 'name' => $name, 'guard_name' => 'web']);
|
||||
$role->syncPermissions($permissions);
|
||||
$roles[$name] = $role;
|
||||
}
|
||||
|
||||
return $roles;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
use App\Enums\SystemRole;
|
||||
|
||||
final class SystemRolePermissions
|
||||
{
|
||||
/** @return array<string, list<string>> */
|
||||
public static function tenantRoles(): array
|
||||
{
|
||||
return [
|
||||
SystemRole::TenantAdmin->value => [
|
||||
'user.view', 'user.create', 'user.update', 'user.delete',
|
||||
'device.view', 'device.create', 'device.update', 'device.delete', 'device.connect',
|
||||
'device.credential.view', 'device.credential.create', 'device.credential.update', 'device.credential.delete',
|
||||
'device.user.view', 'device.user.create', 'device.user.update', 'device.user.delete', 'device.user.mass_update',
|
||||
'device.master_account.manage', 'device.command.execute', 'device.command.mass_execute',
|
||||
'device.config.view', 'device.config.deploy', 'device.backup.create', 'device.backup.download',
|
||||
'device.backup.restore', 'device.monitoring.view', 'audit.view', 'license.view', 'installation.view',
|
||||
],
|
||||
SystemRole::TenantUser->value => [
|
||||
'device.view', 'device.create', 'device.update', 'device.connect',
|
||||
'device.user.view', 'device.user.create', 'device.user.update',
|
||||
'device.command.execute', 'device.config.view', 'device.backup.create',
|
||||
'device.backup.download', 'device.monitoring.view',
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support;
|
||||
|
||||
final class TenantContext
|
||||
{
|
||||
private ?int $tenantId = null;
|
||||
|
||||
public function set(?int $tenantId): void
|
||||
{
|
||||
$this->tenantId = $tenantId;
|
||||
}
|
||||
|
||||
public function id(): ?int
|
||||
{
|
||||
return $this->tenantId;
|
||||
}
|
||||
|
||||
public function clear(): void
|
||||
{
|
||||
$this->tenantId = null;
|
||||
}
|
||||
}
|
||||
Vendored
+18
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use Illuminate\Foundation\Application;
|
||||
use Symfony\Component\Console\Input\ArgvInput;
|
||||
|
||||
define('LARAVEL_START', microtime(true));
|
||||
|
||||
// Register the Composer autoloader...
|
||||
require __DIR__.'/vendor/autoload.php';
|
||||
|
||||
// Bootstrap Laravel and handle the command...
|
||||
/** @var Application $app */
|
||||
$app = require_once __DIR__.'/bootstrap/app.php';
|
||||
|
||||
$status = $app->handleCommand(new ArgvInput);
|
||||
|
||||
exit($status);
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
use App\Http\Middleware\EnsureActiveUser;
|
||||
use App\Http\Middleware\HandleAppearance;
|
||||
use App\Http\Middleware\HandleInertiaRequests;
|
||||
use App\Http\Middleware\SelectAdministrationTenant;
|
||||
use App\Http\Middleware\SetTenantContext;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
use Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
return Application::configure(basePath: dirname(__DIR__))
|
||||
->withRouting(
|
||||
web: __DIR__.'/../routes/web.php',
|
||||
api: __DIR__.'/../routes/api.php',
|
||||
commands: __DIR__.'/../routes/console.php',
|
||||
health: '/up',
|
||||
)
|
||||
->withMiddleware(function (Middleware $middleware): void {
|
||||
$middleware->encryptCookies(except: ['appearance', 'sidebar_state']);
|
||||
|
||||
$middleware->web(append: [
|
||||
SetTenantContext::class,
|
||||
EnsureActiveUser::class,
|
||||
HandleAppearance::class,
|
||||
HandleInertiaRequests::class,
|
||||
AddLinkHeadersForPreloadedAssets::class,
|
||||
]);
|
||||
|
||||
$middleware->api(append: [SetTenantContext::class]);
|
||||
$middleware->alias(['admin.tenant' => SelectAdministrationTenant::class]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(
|
||||
fn (Request $request) => $request->is('api/*') || $request->expectsJson(),
|
||||
);
|
||||
})->create();
|
||||
@@ -0,0 +1,2 @@
|
||||
*
|
||||
!.gitignore
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Providers\FortifyServiceProvider;
|
||||
|
||||
return [
|
||||
AppServiceProvider::class,
|
||||
FortifyServiceProvider::class,
|
||||
];
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "resources/css/app.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
{
|
||||
"$schema": "https://getcomposer.org/schema.json",
|
||||
"name": "laravel/react-starter-kit",
|
||||
"type": "project",
|
||||
"description": "The skeleton application for the Laravel framework.",
|
||||
"keywords": [
|
||||
"laravel",
|
||||
"framework"
|
||||
],
|
||||
"license": "MIT",
|
||||
"require": {
|
||||
"php": "^8.3",
|
||||
"inertiajs/inertia-laravel": "^3.0",
|
||||
"laravel/chisel": "^0.1.0",
|
||||
"laravel/fortify": "^1.37.2",
|
||||
"laravel/framework": "^13.17",
|
||||
"laravel/sanctum": "^4.3",
|
||||
"laravel/tinker": "^3.0",
|
||||
"laravel/wayfinder": "^0.1.14",
|
||||
"spatie/laravel-permission": "^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"fakerphp/faker": "^1.24",
|
||||
"larastan/larastan": "^3.9",
|
||||
"laravel/pail": "^1.2.5",
|
||||
"laravel/pao": "^1.0.6",
|
||||
"laravel/pint": "^1.27",
|
||||
"laravel/sail": "^1.53",
|
||||
"mockery/mockery": "^1.6",
|
||||
"nunomaduro/collision": "^8.9.3",
|
||||
"phpunit/phpunit": "^12.5.23"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "app/",
|
||||
"Database\\Factories\\": "database/factories/",
|
||||
"Database\\Seeders\\": "database/seeders/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"setup": [
|
||||
"composer install",
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\"",
|
||||
"@php artisan key:generate",
|
||||
"@php artisan migrate --force",
|
||||
"npm install",
|
||||
"npm run build"
|
||||
],
|
||||
"dev": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"@php artisan dev"
|
||||
],
|
||||
"lint": [
|
||||
"pint --parallel"
|
||||
],
|
||||
"lint:check": [
|
||||
"pint --parallel --test"
|
||||
],
|
||||
"ci:check": [
|
||||
"Composer\\Config::disableProcessTimeout",
|
||||
"npm run lint:check",
|
||||
"npm run format:check",
|
||||
"npm run types:check",
|
||||
"@test"
|
||||
],
|
||||
"types:check": [
|
||||
"phpstan analyse"
|
||||
],
|
||||
"test": [
|
||||
"@php artisan config:clear --ansi",
|
||||
"@lint:check",
|
||||
"@types:check",
|
||||
"@php artisan test"
|
||||
],
|
||||
"post-autoload-dump": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
|
||||
"@php artisan package:discover --ansi"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
|
||||
],
|
||||
"post-root-package-install": [
|
||||
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
|
||||
],
|
||||
"post-create-project-cmd": [
|
||||
"@php artisan key:generate --ansi",
|
||||
"@php -r \"file_exists('database/database.sqlite') || touch('database/database.sqlite');\"",
|
||||
"@php artisan migrate --graceful --ansi"
|
||||
],
|
||||
"pre-package-uninstall": [
|
||||
"Illuminate\\Foundation\\ComposerScripts::prePackageUninstall"
|
||||
]
|
||||
},
|
||||
"extra": {
|
||||
"laravel": {
|
||||
"dont-discover": [],
|
||||
"installer": {
|
||||
"post-create-project": [
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"config": {
|
||||
"optimize-autoloader": true,
|
||||
"preferred-install": "dist",
|
||||
"sort-packages": true,
|
||||
"allow-plugins": {
|
||||
"pestphp/pest-plugin": true,
|
||||
"php-http/discovery": true
|
||||
}
|
||||
},
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true
|
||||
}
|
||||
+10583
File diff suppressed because it is too large
Load Diff
+126
@@ -0,0 +1,126 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value is the name of your application, which will be used when the
|
||||
| framework needs to place the application's name in a notification or
|
||||
| other UI elements where an application name needs to be displayed.
|
||||
|
|
||||
*/
|
||||
|
||||
'name' => env('APP_NAME', 'RADIQ NDM'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Environment
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the "environment" your application is currently
|
||||
| running in. This may determine how you prefer to configure various
|
||||
| services the application utilizes. Set this in your ".env" file.
|
||||
|
|
||||
*/
|
||||
|
||||
'env' => env('APP_ENV', 'production'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Debug Mode
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When your application is in debug mode, detailed error messages with
|
||||
| stack traces will be shown on every error that occurs within your
|
||||
| application. If disabled, a simple generic error page is shown.
|
||||
|
|
||||
*/
|
||||
|
||||
'debug' => (bool) env('APP_DEBUG', false),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application URL
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This URL is used by the console to properly generate URLs when using
|
||||
| the Artisan command line tool. You should set this to the root of
|
||||
| the application so that it's available within Artisan commands.
|
||||
|
|
||||
*/
|
||||
|
||||
'url' => env('APP_URL', 'http://localhost'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Timezone
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default timezone for your application, which
|
||||
| will be used by the PHP date and date-time functions. The timezone
|
||||
| is set to "UTC" by default as it is suitable for most use cases.
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => 'UTC',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Application Locale Configuration
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The application locale determines the default locale that will be used
|
||||
| by Laravel's translation / localization methods. This option can be
|
||||
| set to any locale for which you plan to have translation strings.
|
||||
|
|
||||
*/
|
||||
|
||||
'locale' => env('APP_LOCALE', 'en'),
|
||||
|
||||
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
|
||||
|
||||
'faker_locale' => env('APP_FAKER_LOCALE', 'en_US'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Encryption Key
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This key is utilized by Laravel's encryption services and should be set
|
||||
| to a random, 32 character string to ensure that all encrypted values
|
||||
| are secure. You should do this prior to deploying the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'cipher' => 'AES-256-CBC',
|
||||
|
||||
'key' => env('APP_KEY'),
|
||||
|
||||
'previous_keys' => [
|
||||
...array_filter(
|
||||
explode(',', (string) env('APP_PREVIOUS_KEYS', '')),
|
||||
),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Maintenance Mode Driver
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options determine the driver used to determine and
|
||||
| manage Laravel's "maintenance mode" status. The "cache" driver will
|
||||
| allow maintenance mode to be controlled across multiple machines.
|
||||
|
|
||||
| Supported drivers: "file", "cache", "array"
|
||||
|
|
||||
*/
|
||||
|
||||
'maintenance' => [
|
||||
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
|
||||
'store' => env('APP_MAINTENANCE_STORE', 'database'),
|
||||
],
|
||||
|
||||
];
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
use App\Models\User;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Defaults
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option defines the default authentication "guard" and password
|
||||
| reset "broker" for your application. You may change these values
|
||||
| as required, but they're a perfect start for most applications.
|
||||
|
|
||||
*/
|
||||
|
||||
'defaults' => [
|
||||
'guard' => env('AUTH_GUARD', 'web'),
|
||||
'passwords' => env('AUTH_PASSWORD_BROKER', 'users'),
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Authentication Guards
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Next, you may define every authentication guard for your application.
|
||||
| Of course, a great default configuration has been defined for you
|
||||
| which utilizes session storage plus the Eloquent user provider.
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| Supported: "session"
|
||||
|
|
||||
*/
|
||||
|
||||
'guards' => [
|
||||
'web' => [
|
||||
'driver' => 'session',
|
||||
'provider' => 'users',
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| User Providers
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| All authentication guards have a user provider, which defines how the
|
||||
| users are actually retrieved out of your database or other storage
|
||||
| system used by the application. Typically, Eloquent is utilized.
|
||||
|
|
||||
| If you have multiple user tables or models you may configure multiple
|
||||
| providers to represent the model / table. These providers may then
|
||||
| be assigned to any extra authentication guards you have defined.
|
||||
|
|
||||
| Supported: "database", "eloquent"
|
||||
|
|
||||
*/
|
||||
|
||||
'providers' => [
|
||||
'users' => [
|
||||
'driver' => 'eloquent',
|
||||
'model' => env('AUTH_MODEL', User::class),
|
||||
],
|
||||
|
||||
// 'users' => [
|
||||
// 'driver' => 'database',
|
||||
// 'table' => 'users',
|
||||
// ],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Resetting Passwords
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These configuration options specify the behavior of Laravel's password
|
||||
| reset functionality, including the table utilized for token storage
|
||||
| and the user provider that is invoked to actually retrieve users.
|
||||
|
|
||||
| The expiry time is the number of minutes that each reset token will be
|
||||
| considered valid. This security feature keeps tokens short-lived so
|
||||
| they have less time to be guessed. You may change this as needed.
|
||||
|
|
||||
| The throttle setting is the number of seconds a user must wait before
|
||||
| generating more password reset tokens. This prevents the user from
|
||||
| quickly generating a very large amount of password reset tokens.
|
||||
|
|
||||
*/
|
||||
|
||||
'passwords' => [
|
||||
'users' => [
|
||||
'provider' => 'users',
|
||||
'table' => env('AUTH_PASSWORD_RESET_TOKEN_TABLE', 'password_reset_tokens'),
|
||||
'expire' => 60,
|
||||
'throttle' => 60,
|
||||
],
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Password Confirmation Timeout
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define the number of seconds before a password confirmation
|
||||
| window expires and users are asked to re-enter their password via the
|
||||
| confirmation screen. By default, the timeout lasts for three hours.
|
||||
|
|
||||
*/
|
||||
|
||||
'password_timeout' => env('AUTH_PASSWORD_TIMEOUT', 10800),
|
||||
|
||||
];
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Cache Store
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This option controls the default cache store that will be used by the
|
||||
| framework. This connection is utilized if another isn't explicitly
|
||||
| specified when running a cache operation inside the application.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('CACHE_STORE', 'database'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Stores
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may define all of the cache "stores" for your application as
|
||||
| well as their drivers. You may even define multiple stores for the
|
||||
| same cache driver to group types of items stored in your caches.
|
||||
|
|
||||
| Supported drivers: "array", "database", "file", "memcached",
|
||||
| "redis", "dynamodb", "storage", "octane",
|
||||
| "session", "failover", "null"
|
||||
|
|
||||
*/
|
||||
|
||||
'stores' => [
|
||||
|
||||
'array' => [
|
||||
'driver' => 'array',
|
||||
'serialize' => false,
|
||||
],
|
||||
|
||||
'database' => [
|
||||
'driver' => 'database',
|
||||
'connection' => env('DB_CACHE_CONNECTION'),
|
||||
'table' => env('DB_CACHE_TABLE', 'cache'),
|
||||
'lock_connection' => env('DB_CACHE_LOCK_CONNECTION'),
|
||||
'lock_table' => env('DB_CACHE_LOCK_TABLE'),
|
||||
],
|
||||
|
||||
'file' => [
|
||||
'driver' => 'file',
|
||||
'path' => storage_path('framework/cache/data'),
|
||||
'lock_path' => storage_path('framework/cache/data'),
|
||||
],
|
||||
|
||||
'storage' => [
|
||||
'driver' => 'storage',
|
||||
'disk' => env('CACHE_STORAGE_DISK'),
|
||||
'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
|
||||
],
|
||||
|
||||
'memcached' => [
|
||||
'driver' => 'memcached',
|
||||
'persistent_id' => env('MEMCACHED_PERSISTENT_ID'),
|
||||
'sasl' => [
|
||||
env('MEMCACHED_USERNAME'),
|
||||
env('MEMCACHED_PASSWORD'),
|
||||
],
|
||||
'options' => [
|
||||
// Memcached::OPT_CONNECT_TIMEOUT => 2000,
|
||||
],
|
||||
'servers' => [
|
||||
[
|
||||
'host' => env('MEMCACHED_HOST', '127.0.0.1'),
|
||||
'port' => env('MEMCACHED_PORT', 11211),
|
||||
'weight' => 100,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'redis' => [
|
||||
'driver' => 'redis',
|
||||
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
|
||||
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
|
||||
],
|
||||
|
||||
'dynamodb' => [
|
||||
'driver' => 'dynamodb',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
|
||||
'table' => env('DYNAMODB_CACHE_TABLE', 'cache'),
|
||||
'endpoint' => env('DYNAMODB_ENDPOINT'),
|
||||
],
|
||||
|
||||
'octane' => [
|
||||
'driver' => 'octane',
|
||||
],
|
||||
|
||||
'failover' => [
|
||||
'driver' => 'failover',
|
||||
'stores' => [
|
||||
'database',
|
||||
'array',
|
||||
],
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Cache Key Prefix
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When utilizing the APC, database, memcached, Redis, and DynamoDB cache
|
||||
| stores, there might be other applications using the same cache. For
|
||||
| that reason, you may prefix every cache key to avoid collisions.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => env('CACHE_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-cache-'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Serializable Classes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value determines the classes that can be unserialized from cache
|
||||
| storage. By default, no PHP classes will be unserialized from your
|
||||
| cache to prevent gadget chain attacks if your APP_KEY is leaked.
|
||||
|
|
||||
*/
|
||||
|
||||
'serializable_classes' => false,
|
||||
|
||||
];
|
||||
+184
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Support\Str;
|
||||
use Pdo\Mysql;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Database Connection Name
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which of the database connections below you wish
|
||||
| to use as your default connection for database operations. This is
|
||||
| the connection which will be utilized unless another connection
|
||||
| is explicitly specified when you execute a query / statement.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('DB_CONNECTION', 'sqlite'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Database Connections
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below are all of the database connections defined for your application.
|
||||
| An example configuration is provided for each database system which
|
||||
| is supported by Laravel. You're free to add / remove connections.
|
||||
|
|
||||
*/
|
||||
|
||||
'connections' => [
|
||||
|
||||
'sqlite' => [
|
||||
'driver' => 'sqlite',
|
||||
'url' => env('DB_URL'),
|
||||
'database' => env('DB_DATABASE', database_path('database.sqlite')),
|
||||
'prefix' => '',
|
||||
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
|
||||
'busy_timeout' => null,
|
||||
'journal_mode' => null,
|
||||
'synchronous' => null,
|
||||
'transaction_mode' => 'DEFERRED',
|
||||
],
|
||||
|
||||
'mysql' => [
|
||||
'driver' => 'mysql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'mariadb' => [
|
||||
'driver' => 'mariadb',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '3306'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'unix_socket' => env('DB_SOCKET', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8mb4'),
|
||||
'collation' => env('DB_COLLATION', 'utf8mb4_unicode_ci'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'strict' => true,
|
||||
'engine' => null,
|
||||
'options' => extension_loaded('pdo_mysql') ? array_filter([
|
||||
Mysql::ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
|
||||
]) : [],
|
||||
],
|
||||
|
||||
'pgsql' => [
|
||||
'driver' => 'pgsql',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', '127.0.0.1'),
|
||||
'port' => env('DB_PORT', '5432'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
'search_path' => 'public',
|
||||
'sslmode' => env('DB_SSLMODE', 'prefer'),
|
||||
],
|
||||
|
||||
'sqlsrv' => [
|
||||
'driver' => 'sqlsrv',
|
||||
'url' => env('DB_URL'),
|
||||
'host' => env('DB_HOST', 'localhost'),
|
||||
'port' => env('DB_PORT', '1433'),
|
||||
'database' => env('DB_DATABASE', 'laravel'),
|
||||
'username' => env('DB_USERNAME', 'root'),
|
||||
'password' => env('DB_PASSWORD', ''),
|
||||
'charset' => env('DB_CHARSET', 'utf8'),
|
||||
'prefix' => '',
|
||||
'prefix_indexes' => true,
|
||||
// 'encrypt' => env('DB_ENCRYPT', 'yes'),
|
||||
// 'trust_server_certificate' => env('DB_TRUST_SERVER_CERTIFICATE', 'false'),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Migration Repository Table
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This table keeps track of all the migrations that have already run for
|
||||
| your application. Using this information, we can determine which of
|
||||
| the migrations on disk haven't actually been run on the database.
|
||||
|
|
||||
*/
|
||||
|
||||
'migrations' => [
|
||||
'table' => 'migrations',
|
||||
'update_date_on_publish' => true,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Redis Databases
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Redis is an open source, fast, and advanced key-value store that also
|
||||
| provides a richer body of commands than a typical key-value system
|
||||
| such as Memcached. You may define your connection settings here.
|
||||
|
|
||||
*/
|
||||
|
||||
'redis' => [
|
||||
|
||||
'client' => env('REDIS_CLIENT', 'phpredis'),
|
||||
|
||||
'options' => [
|
||||
'cluster' => env('REDIS_CLUSTER', 'redis'),
|
||||
'prefix' => env('REDIS_PREFIX', Str::slug((string) env('APP_NAME', 'laravel')).'-database-'),
|
||||
'persistent' => env('REDIS_PERSISTENT', false),
|
||||
],
|
||||
|
||||
'default' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_DB', '0'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
'cache' => [
|
||||
'url' => env('REDIS_URL'),
|
||||
'host' => env('REDIS_HOST', '127.0.0.1'),
|
||||
'username' => env('REDIS_USERNAME'),
|
||||
'password' => env('REDIS_PASSWORD'),
|
||||
'port' => env('REDIS_PORT', '6379'),
|
||||
'database' => env('REDIS_CACHE_DB', '1'),
|
||||
'max_retries' => env('REDIS_MAX_RETRIES', 3),
|
||||
'backoff_algorithm' => env('REDIS_BACKOFF_ALGORITHM', 'decorrelated_jitter'),
|
||||
'backoff_base' => env('REDIS_BACKOFF_BASE', 100),
|
||||
'backoff_cap' => env('REDIS_BACKOFF_CAP', 1000),
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\DeploymentMode;
|
||||
|
||||
return [
|
||||
'mode' => env('DEPLOYMENT_MODE', DeploymentMode::ManagedCloud->value),
|
||||
'license_server_url' => env('RADIQ_LICENSE_SERVER_URL'),
|
||||
'license_public_key' => env('RADIQ_LICENSE_PUBLIC_KEY'),
|
||||
];
|
||||
@@ -0,0 +1,80 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Default Filesystem Disk
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify the default filesystem disk that should be used
|
||||
| by the framework. The "local" disk, as well as a variety of cloud
|
||||
| based disks are available to your application for file storage.
|
||||
|
|
||||
*/
|
||||
|
||||
'default' => env('FILESYSTEM_DISK', 'local'),
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Filesystem Disks
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Below you may configure as many filesystem disks as necessary, and you
|
||||
| may even configure multiple disks for the same driver. Examples for
|
||||
| most supported storage drivers are configured here for reference.
|
||||
|
|
||||
| Supported drivers: "local", "ftp", "sftp", "s3"
|
||||
|
|
||||
*/
|
||||
|
||||
'disks' => [
|
||||
|
||||
'local' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/private'),
|
||||
'serve' => true,
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
'public' => [
|
||||
'driver' => 'local',
|
||||
'root' => storage_path('app/public'),
|
||||
'url' => rtrim((string) env('APP_URL', 'http://localhost'), '/').'/storage',
|
||||
'visibility' => 'public',
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
's3' => [
|
||||
'driver' => 's3',
|
||||
'key' => env('AWS_ACCESS_KEY_ID'),
|
||||
'secret' => env('AWS_SECRET_ACCESS_KEY'),
|
||||
'region' => env('AWS_DEFAULT_REGION'),
|
||||
'bucket' => env('AWS_BUCKET'),
|
||||
'url' => env('AWS_URL'),
|
||||
'endpoint' => env('AWS_ENDPOINT'),
|
||||
'use_path_style_endpoint' => env('AWS_USE_PATH_STYLE_ENDPOINT', false),
|
||||
'throw' => false,
|
||||
'report' => false,
|
||||
],
|
||||
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Symbolic Links
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the symbolic links that will be created when the
|
||||
| `storage:link` Artisan command is executed. The array keys should be
|
||||
| the locations of the links and the values should be their targets.
|
||||
|
|
||||
*/
|
||||
|
||||
'links' => [
|
||||
public_path('storage') => storage_path('app/public'),
|
||||
],
|
||||
|
||||
];
|
||||
+177
@@ -0,0 +1,177 @@
|
||||
<?php
|
||||
|
||||
use Laravel\Fortify\Features;
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Guard
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which authentication guard Fortify will use while
|
||||
| authenticating users. This value should correspond with one of your
|
||||
| guards that is already present in your "auth" configuration file.
|
||||
|
|
||||
*/
|
||||
|
||||
'guard' => 'web',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Password Broker
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which password broker Fortify can use when a user
|
||||
| is resetting their password. This configured value should match one
|
||||
| of your password brokers setup in your "auth" configuration file.
|
||||
|
|
||||
*/
|
||||
|
||||
'passwords' => 'users',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Username / Email
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value defines which model attribute should be considered as your
|
||||
| application's "username" field. Typically, this might be the email
|
||||
| address of the users but you are free to change this value here.
|
||||
|
|
||||
| Out of the box, Fortify expects forgot password and reset password
|
||||
| requests to have a field named 'email'. If the application uses
|
||||
| another name for the field you may define it below as needed.
|
||||
|
|
||||
*/
|
||||
|
||||
'username' => 'email',
|
||||
|
||||
'email' => 'email',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Lowercase Usernames
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| This value defines whether usernames should be lowercased before saving
|
||||
| them in the database, as some database system string fields are case
|
||||
| sensitive. You may disable this for your application if necessary.
|
||||
|
|
||||
*/
|
||||
|
||||
'lowercase_usernames' => true,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Home Path
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may configure the path where users will get redirected during
|
||||
| authentication or password reset when the operations are successful
|
||||
| and the user is authenticated. You are free to change this value.
|
||||
|
|
||||
*/
|
||||
|
||||
'home' => '/dashboard',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Routes Prefix / Subdomain
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which prefix Fortify will assign to all the routes
|
||||
| that it registers with the application. If necessary, you may change
|
||||
| subdomain under which all of the Fortify routes will be available.
|
||||
|
|
||||
*/
|
||||
|
||||
'prefix' => '',
|
||||
|
||||
'domain' => null,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Fortify Routes Middleware
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify which middleware Fortify will assign to the routes
|
||||
| that it registers with the application. If necessary, you may change
|
||||
| these middleware but typically this provided default is preferred.
|
||||
|
|
||||
*/
|
||||
|
||||
'middleware' => ['web'],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Rate Limiting
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| By default, Fortify will throttle logins to five requests per minute for
|
||||
| every email and IP address combination. However, if you would like to
|
||||
| specify a custom rate limiter to call then you may specify it here.
|
||||
|
|
||||
*/
|
||||
|
||||
'limiters' => [
|
||||
'login' => 'login',
|
||||
'two-factor' => 'two-factor',
|
||||
'passkeys' => 'passkeys',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Register View Routes
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Here you may specify if the routes returning views should be disabled as
|
||||
| you may not need them when building your own application. This may be
|
||||
| especially true if you're writing a custom single-page application.
|
||||
|
|
||||
*/
|
||||
|
||||
'views' => true,
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Passkeys
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| These settings configure Fortify's passkey (WebAuthn) support.
|
||||
|
|
||||
*/
|
||||
|
||||
'passkeys' => [
|
||||
'relying_party_id' => parse_url(config('app.url'), PHP_URL_HOST),
|
||||
'allowed_origins' => [config('app.url')],
|
||||
'user_handle_secret' => env('PASSKEYS_USER_HANDLE_SECRET', config('app.key')),
|
||||
'timeout' => 60000,
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Features
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Some of the Fortify features are optional. You may disable the features
|
||||
| by removing them from this array. You're free to only remove some of
|
||||
| these features, or you can even remove all of these if you need to.
|
||||
|
|
||||
*/
|
||||
|
||||
'features' => [
|
||||
// User creation is restricted to authorized administrators.
|
||||
Features::resetPasswords(),
|
||||
Features::emailVerification(),
|
||||
Features::twoFactorAuthentication([
|
||||
'confirm' => true,
|
||||
'confirmPassword' => true,
|
||||
// 'window' => 0
|
||||
]),
|
||||
Features::passkeys([
|
||||
'confirmPassword' => true,
|
||||
]),
|
||||
],
|
||||
|
||||
];
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user