Initial commit device-management
tests / ci (push) Has been cancelled

This commit is contained in:
Wian Drs
2026-08-25 09:42:00 +07:00
commit 1e80be180e
1159 changed files with 149545 additions and 0 deletions
@@ -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.');
}
}
@@ -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]);
}
}
+11
View File
@@ -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'),
],
]);
}
}
@@ -0,0 +1,134 @@
<?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();
$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'], 'remote_username' => $user->username, 'sync_status' => 'queued']));
});
$this->syncAssignments($assignments, $request->user()->tenant_id);
return to_route('device-users.index')->with('success', $this->syncResultMessage($assignments, 'User perangkat berhasil dibuat.'));
}
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();
$assignments = DB::transaction(function () use ($request, $data, $deviceAccessUser) {
$oldUsername = $deviceAccessUser->username;
$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);
$deviceAccessUser->assignments()->whereNull('remote_username')->update(['remote_username' => $oldUsername]);
$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();
});
$this->syncAssignments($assignments, $request->user()->tenant_id);
return to_route('device-users.index')->with('success', $this->syncResultMessage($assignments, 'Perubahan user berhasil disimpan.'));
}
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;
});
$this->syncAssignments($assignments, request()->user()->tenant_id);
return back()->with('success', $this->syncResultMessage($assignments, 'Penghapusan user perangkat diproses.'));
}
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 syncResultMessage($assignments, string $prefix): string
{
$remaining = DeviceUserAssignment::withoutGlobalScope('tenant')->whereIn('id', $assignments->pluck('id'))->get()->keyBy('id');
$statuses = $assignments->map(function ($assignment) use ($remaining) {
return $remaining->get($assignment->id)?->sync_status
?? ($assignment->desired_operation === 'delete' ? 'synced' : 'failed');
});
$pending = $statuses->filter(fn ($status) => in_array($status, ['pending', 'queued'], true))->count();
$synced = $statuses->where('synced')->count();
$failed = $statuses->filter(fn ($status) => in_array($status, ['failed', 'unsupported'], true))->count();
return $prefix." {$synced} perangkat langsung tersinkron"
.($pending ? ", {$pending} perangkat pending dan akan dicoba scheduler" : '')
.($failed ? ", {$failed} perangkat memerlukan pemeriksaan" : '').'.';
}
private function syncAssignments($assignments, int $tenantId): void
{
$assignments->each(function (DeviceUserAssignment $assignment) use ($tenantId): void {
$isSshOlt = in_array($assignment->device()->with('vendor:id,slug')->first()?->vendor?->slug, ['zte', 'hsgq'], true);
$isSshOlt
? SyncDeviceUser::dispatch($tenantId, $assignment->id)
: SyncDeviceUser::dispatchSync($tenantId, $assignment->id);
});
}
}
@@ -0,0 +1,61 @@
<?php
namespace App\Http\Controllers;
use App\Enums\SystemRole;
use App\Jobs\SyncDeviceBaseCredential;
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 {
$queuedOlt = ($device->vendor?->slug === 'zte' && $device->hasConnection('ssh'))
|| ($device->vendor?->slug === 'hsgq' && ($device->hasConnection('ssh') || $device->hasConnection('telnet')))
|| ($device->vendor?->slug === 'hisfocus' && ($device->hasConnection('ssh') || $device->hasConnection('telnet')));
if ($queuedOlt) {
$device->update(['base_sync_status' => 'queued', 'base_sync_error_code' => null, 'base_sync_message' => 'Aktivasi OLT menunggu queue worker.']);
SyncDeviceBaseCredential::dispatch($device->tenant_id, $device->id);
return back()->with('success', 'Aktivasi OLT dimasukkan ke queue. Status akan diperbarui setelah proses SSH selesai.');
}
$result = $service->activate($device->load('vendor'));
$info = $result['device_info'];
$deleted = count($result['cleanup']['deleted']);
$device->update([
'hostname' => $info['identity'] ?? $device->hostname,
'serial_number' => $info['serial-number'] ?? $device->serial_number,
'firmware_version' => $info['current-firmware'] ?? $device->firmware_version,
'software_version' => $info['version'] ?? $device->software_version,
'device_facts' => $info, 'status' => 'online', 'last_seen_at' => now(),
'activation_status' => 'active', 'activated_at' => now(),
'activation_message' => 'Base User RADIQ aktif'.($deleted > 0 ? "; {$deleted} user lama dihapus." : '.'),
'base_sync_status' => 'synced', 'base_sync_error_code' => null,
'base_sync_message' => 'Base User berhasil diverifikasi saat aktivasi.', 'base_synced_at' => now(),
]);
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]);
}
}
}
+172
View File
@@ -0,0 +1,172 @@
<?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\DeviceCredential;
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 Illuminate\Support\Facades\DB;
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);
$data = $request->validated();
$username = $data['initial_username'];
$password = $data['initial_password'] ?? '';
$enablePassword = $data['initial_enable_password'] ?? null;
unset($data['initial_username'], $data['initial_password'], $data['initial_enable_password']);
DB::transaction(function () use ($data, $username, $password, $enablePassword, $request): void {
$device = Device::create($data + ['tenant_id' => $request->user()->tenant_id, 'created_by' => $request->user()->id, 'updated_by' => $request->user()->id]);
DeviceCredential::create([
'tenant_id' => $request->user()->tenant_id, 'device_id' => $device->id,
'name' => 'Login Awal', 'username' => $username, 'password' => $password,
'enable_password' => $enablePassword,
'connection_type' => $device->connection_type, 'privilege_type' => 'master',
'is_master' => true, 'is_active' => true,
'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', 'slug']), '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.');
}
}
}
@@ -0,0 +1,56 @@
<?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']);
}
if (blank($data['enable_password'] ?? null)) {
unset($data['enable_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,23 @@
<?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]);
$isSshOlt = in_array($assignment->device()->with('vendor:id,slug')->first()?->vendor?->slug, ['zte', 'hsgq'], true);
$isSshOlt
? SyncDeviceUser::dispatch(request()->user()->tenant_id, $assignment->id)
: SyncDeviceUser::dispatchSync(request()->user()->tenant_id, $assignment->id);
return back()->with('success', 'Sinkronisasi ulang langsung dijalankan. Jika perangkat offline, proses akan dicoba kembali oleh scheduler.');
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers;
use App\Enums\SystemRole;
use App\Models\TenantDeviceSetting;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
class RevealTenantBasePasswordController extends Controller
{
public function __invoke(Request $request): JsonResponse
{
abort_unless($request->user()->hasRole(SystemRole::TenantAdmin->value), 403);
$data = $request->validate(['current_password' => ['required', 'string']]);
if (! Hash::check($data['current_password'], $request->user()->password)) {
throw ValidationException::withMessages(['current_password' => 'Password login tidak sesuai.']);
}
$setting = TenantDeviceSetting::where('tenant_id', $request->user()->tenant_id)->firstOrFail();
Log::notice('Tenant Admin revealed the base device password.', [
'tenant_id' => $request->user()->tenant_id,
'user_id' => $request->user()->id,
]);
return response()->json(['password' => $setting->base_password])
->header('Cache-Control', 'no-store, private')
->header('Pragma', 'no-cache');
}
}
@@ -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('/');
}
}
@@ -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();
}
}
@@ -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.');
}
}
@@ -0,0 +1,58 @@
<?php
namespace App\Http\Controllers;
use App\Enums\SystemRole;
use App\Jobs\SyncDeviceBaseCredential;
use App\Models\Device;
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']),
'devices' => Device::with('vendor:id,name')->orderBy('name')->get(['id', 'name', 'device_vendor_id', 'base_sync_status', 'base_sync_error_code', 'base_sync_message', 'base_synced_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']);
}
$oldUsername = $setting?->base_username;
$credentialChanged = ! $setting || $oldUsername !== $data['base_username'] || array_key_exists('base_password', $data);
TenantDeviceSetting::updateOrCreate(['tenant_id' => $request->user()->tenant_id], $data);
if ($credentialChanged) {
Device::where('tenant_id', $request->user()->tenant_id)->where('is_active', true)->get(['id', 'tenant_id'])->each(function (Device $device) use ($oldUsername): void {
$device->update(['base_sync_status' => 'queued', 'base_sync_error_code' => null, 'base_sync_message' => 'Perubahan Base User menunggu sinkronisasi.']);
SyncDeviceBaseCredential::dispatch($device->tenant_id, $device->id, $oldUsername);
});
}
return back()->with('success', $credentialChanged ? 'Base User tersimpan terenkripsi dan rotasi seluruh perangkat tenant dimasukkan ke queue.' : 'Pengaturan koneksi perangkat berhasil disimpan.');
}
}
@@ -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.");
}
}