diff --git a/.env.example b/.env.example index dabf833..cddae2a 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,11 @@ APP_NAME="RADIQ NDM" DEPLOYMENT_MODE=managed_cloud RADIQ_LICENSE_SERVER_URL= RADIQ_LICENSE_PUBLIC_KEY= +PAYMENT_GATEWAY=duitku +DUITKU_SANDBOX=true +DUITKU_CA_BUNDLE= +DUITKU_MERCHANT_CODE=DS33765 +DUITKU_API_KEY=c53455cfd356fbf5e33c8651b247f790 APP_ENV=local APP_KEY= APP_DEBUG=true diff --git a/app/Actions/Fortify/CreateNewUser.php b/app/Actions/Fortify/CreateNewUser.php index 3c7c00c..bd66af9 100644 --- a/app/Actions/Fortify/CreateNewUser.php +++ b/app/Actions/Fortify/CreateNewUser.php @@ -4,8 +4,13 @@ namespace App\Actions\Fortify; use App\Concerns\PasswordValidationRules; use App\Concerns\ProfileValidationRules; +use App\Models\BillingOrder; +use App\Models\Tenant; use App\Models\User; +use App\Services\TenantProvisioningService; use Illuminate\Support\Facades\Validator; +use Illuminate\Support\Str; +use Illuminate\Validation\Rule; use Laravel\Fortify\Contracts\CreatesNewUsers; class CreateNewUser implements CreatesNewUsers @@ -17,17 +22,50 @@ class CreateNewUser implements CreatesNewUsers * * @param array $input */ + public function __construct(private readonly TenantProvisioningService $provisioning) {} + public function create(array $input): User { Validator::make($input, [ + 'tenant_name' => ['required', 'string', 'max:255'], ...$this->profileRules(), 'password' => $this->passwordRules(), + 'deployment_type' => ['required', Rule::in(['managed_cloud', 'self_hosted'])], + 'deployment_domain' => ['nullable', 'string', 'max:255'], + 'plan_code' => ['required', Rule::in(array_keys(config('billing.plans')))], ])->validate(); - return User::create([ - 'name' => $input['name'], - 'email' => $input['email'], - 'password' => $input['password'], + abort_unless(config('deployment.mode') === 'managed_cloud', 403); + + $baseSlug = Str::slug($input['tenant_name']) ?: 'tenant'; + $slug = $baseSlug; + $suffix = 2; + while (Tenant::where('slug', $slug)->exists()) { + $slug = $baseSlug.'-'.$suffix++; + } + + $tenant = $this->provisioning->createWithOwner([ + 'name' => $input['tenant_name'], + 'slug' => $slug, + 'owner_name' => $input['name'], + 'owner_email' => $input['email'], + 'owner_password' => $input['password'], + 'deployment_type' => $input['deployment_type'], + 'deployment_domain' => $input['deployment_domain'] ?? null, + 'plan_code' => $input['plan_code'], + 'email_verified' => false, + 'activate_license' => false, ]); + + $plan = config("billing.plans.{$input['plan_code']}"); + if ($plan['price'] > 0) { + $gateway = config('billing.default_gateway'); + $order = BillingOrder::withoutGlobalScope('tenant')->create([ + 'tenant_id' => $tenant->id, 'plan_code' => $input['plan_code'], 'gateway' => $gateway, + 'status' => 'pending', 'subtotal' => $plan['price'], 'tax' => 0, 'total' => $plan['price'], 'currency' => 'IDR', 'expires_at' => now()->addDay(), + ]); + } + + return User::withoutGlobalScope('tenant')->where('email', $input['email'])->firstOrFail(); } } diff --git a/app/Billing/Contracts/PaymentGateway.php b/app/Billing/Contracts/PaymentGateway.php new file mode 100644 index 0000000..19fbf02 --- /dev/null +++ b/app/Billing/Contracts/PaymentGateway.php @@ -0,0 +1,23 @@ + */ + public function paymentMethods(BillingOrder $order): array; + + /** @return array{external_id:?string,checkout_url:?string,metadata:array} */ + public function createCheckout(BillingOrder $order): array; + + /** @param array $payload */ + public function verifyWebhook(array $payload, array $headers = []): bool; + + /** @param array $payload */ + public function statusFromWebhook(array $payload): string; + + /** @param array $payload */ + public function externalIdFromWebhook(array $payload): ?string; +} diff --git a/app/Billing/Gateways/DuitkuPaymentGateway.php b/app/Billing/Gateways/DuitkuPaymentGateway.php new file mode 100644 index 0000000..5006260 --- /dev/null +++ b/app/Billing/Gateways/DuitkuPaymentGateway.php @@ -0,0 +1,112 @@ +credentials(); + $amount = (int) $order->total; + $dateTime = now('Asia/Jakarta')->format('Y-m-d H:i:s'); + $response = $this->http()->post( + $this->baseUrl().'/webapi/api/merchant/paymentmethod/getpaymentmethod', + [ + 'merchantcode' => $merchant, + 'amount' => $amount, + 'datetime' => $dateTime, + 'signature' => hash_hmac('sha256', $merchant.$amount.$dateTime, $key), + ] + ); + + if (! $response->successful() || ! is_array($response->json('paymentFee'))) { + throw new RuntimeException('PAYMENT_METHODS_REJECTED: '.($response->json('responseMessage') ?: 'Daftar kanal pembayaran Duitku tidak tersedia.')); + } + + return collect($response->json('paymentFee'))->map(fn (array $method): array => [ + 'code' => (string) $method['paymentMethod'], + 'name' => (string) $method['paymentName'], + 'fee' => (int) ($method['totalFee'] ?? 0), + ])->values()->all(); + } + + public function createCheckout(BillingOrder $order): array + { + [$merchant, $key] = $this->credentials(); + if (! $order->payment_method) { + throw new RuntimeException('PAYMENT_METHOD_REQUIRED: Pilih kanal pembayaran terlebih dahulu.'); + } + $externalId = 'RNDM-'.$order->uuid; + $user = $order->tenant->users()->where('is_active', true)->oldest()->firstOrFail(); + $amount = (int) $order->total; + $payload = [ + 'merchantCode' => $merchant, 'paymentAmount' => $amount, 'merchantOrderId' => $externalId, + 'paymentMethod' => $order->payment_method, + 'productDetails' => 'Lisensi bulanan RADIQ NDM '.config("billing.plans.{$order->plan_code}.name"), + 'email' => $user->email, 'customerVaName' => $user->name, + 'callbackUrl' => route('payments.webhook', ['gateway' => 'duitku']), + 'returnUrl' => route('checkout.return', $order->uuid), 'expiryPeriod' => 1440, + 'signature' => hash_hmac('sha256', $merchant.$externalId.$amount, $key), + ]; + $response = $this->http()->post($this->baseUrl().'/webapi/api/merchant/v2/inquiry', $payload); + if (! $response->successful() || ! $response->json('paymentUrl')) { + throw new RuntimeException('PAYMENT_GATEWAY_REJECTED: '.($response->json('Message') ?: $response->json('message') ?: 'Duitku tidak menghasilkan URL pembayaran.')); + } + + return ['external_id' => $externalId, 'checkout_url' => $response->json('paymentUrl'), 'metadata' => ['reference' => $response->json('reference')]]; + } + + public function verifyWebhook(array $payload, array $headers = []): bool + { + $merchant = (string) ($payload['merchantCode'] ?? ''); + $amount = (string) ($payload['amount'] ?? ''); + $orderId = (string) ($payload['merchantOrderId'] ?? ''); + $signature = (string) ($payload['signature'] ?? ''); + $expected = hash_hmac('sha256', $merchant.$amount.$orderId, (string) config('billing.duitku.api_key')); + + return $merchant !== '' && hash_equals((string) config('billing.duitku.merchant_code'), $merchant) && hash_equals($expected, $signature); + } + + public function statusFromWebhook(array $payload): string + { + return ($payload['resultCode'] ?? null) === '00' ? 'paid' : 'failed'; + } + + public function externalIdFromWebhook(array $payload): ?string + { + return isset($payload['merchantOrderId']) ? (string) $payload['merchantOrderId'] : null; + } + + /** @return array{string,string} */ + private function credentials(): array + { + $merchant = (string) config('billing.duitku.merchant_code'); + $key = (string) config('billing.duitku.api_key'); + if ($merchant === '' || $key === '') { + throw new RuntimeException('PAYMENT_GATEWAY_NOT_CONFIGURED: Merchant Code atau API Key Duitku belum diisi.'); + } + + return [$merchant, $key]; + } + + private function baseUrl(): string + { + return config('billing.duitku.sandbox') ? 'https://sandbox.duitku.com' : 'https://passport.duitku.com'; + } + + private function http(): PendingRequest + { + $request = Http::asJson()->acceptJson()->timeout(20); + $caBundle = config('billing.duitku.ca_bundle'); + + return is_string($caBundle) && $caBundle !== '' + ? $request->withOptions(['verify' => $caBundle]) + : $request; + } +} diff --git a/app/Billing/Gateways/ManualPaymentGateway.php b/app/Billing/Gateways/ManualPaymentGateway.php new file mode 100644 index 0000000..83b324d --- /dev/null +++ b/app/Billing/Gateways/ManualPaymentGateway.php @@ -0,0 +1,34 @@ + 'MANUAL-'.$order->uuid, 'checkout_url' => null, 'metadata' => ['instruction' => 'Hubungi RADIQ untuk instruksi pembayaran. Aktivasi dilakukan setelah pembayaran diverifikasi.']]; + } + + public function verifyWebhook(array $payload, array $headers = []): bool + { + return false; + } + + public function statusFromWebhook(array $payload): string + { + return 'pending'; + } + + public function externalIdFromWebhook(array $payload): ?string + { + return null; + } +} diff --git a/app/Billing/PaymentGatewayManager.php b/app/Billing/PaymentGatewayManager.php new file mode 100644 index 0000000..ae0f5f4 --- /dev/null +++ b/app/Billing/PaymentGatewayManager.php @@ -0,0 +1,20 @@ +user()->is_platform_admin, 403); + + return Inertia::render('administration/billing/index', ['orders' => BillingOrder::withoutGlobalScope('tenant')->with('tenant:id,name')->latest()->paginate(30)]); + } + + public function approve(Request $request, BillingOrder $order, BillingService $billing): RedirectResponse + { + abort_unless($request->user()->is_platform_admin, 403); + abort_unless($order->status === 'pending', 422, 'Order sudah diproses.'); + $billing->activate($order, $request->user()->id); + + return back()->with('success', 'Pembayaran dikonfirmasi dan lisensi diperpanjang satu bulan.'); + } +} diff --git a/app/Http/Controllers/Administration/TenantController.php b/app/Http/Controllers/Administration/TenantController.php index 0ca9dbb..c8731f5 100644 --- a/app/Http/Controllers/Administration/TenantController.php +++ b/app/Http/Controllers/Administration/TenantController.php @@ -7,6 +7,7 @@ use App\Http\Requests\Administration\StoreTenantRequest; use App\Http\Requests\Administration\UpdateTenantRequest; use App\Models\Tenant; use App\Models\User; +use App\Services\LicenseEntitlementService; use App\Services\TenantProvisioningService; use Illuminate\Http\RedirectResponse; use Inertia\Inertia; @@ -21,6 +22,7 @@ class TenantController extends Controller $tenants = Tenant::query() ->withCount('users') ->with('installations:id,tenant_id,deployment_type,domain,status,last_seen_at') + ->with(['licenses' => fn ($query) => $query->latest('created_at')]) ->orderBy('name') ->paginate(15) ->withQueryString(); @@ -32,12 +34,12 @@ class TenantController extends Controller { $this->authorize('create', Tenant::class); - return Inertia::render('administration/tenants/create'); + return Inertia::render('administration/tenants/create', ['plans' => config('billing.plans')]); } public function store(StoreTenantRequest $request, TenantProvisioningService $service): RedirectResponse { - $tenant = $service->createWithOwner($request->validated()); + $tenant = $service->createWithOwner($request->validated() + ['activate_license' => true, 'issued_by' => $request->user()->id]); return to_route('administration.tenants.edit', $tenant) ->with('success', 'Tenant dan Tenant Owner berhasil dibuat.'); @@ -57,6 +59,8 @@ class TenantController extends Controller 'tenant' => $tenant, 'users' => $users, 'installation' => $tenant->installations()->first(['id', 'deployment_type', 'domain', 'status', 'last_seen_at']), + 'license' => $tenant->licenses()->latest('created_at')->first(), + 'plans' => config('billing.plans'), ]); } @@ -71,6 +75,12 @@ class TenantController extends Controller 'domain' => $data['deployment_domain'] ?? null, ]); } + $currentLicense = $tenant->licenses()->latest('created_at')->first(); + if (! empty($data['plan_code']) && $currentLicense?->plan !== $data['plan_code']) { + $installation = $tenant->installations()->firstOrFail(); + $tenant->licenses()->whereIn('status', ['active', 'grace'])->update(['status' => 'revoked']); + app(LicenseEntitlementService::class)->provisionInitial($tenant, $installation, $data['plan_code'], true, $request->user()->id); + } return back()->with('success', 'Tenant berhasil diperbarui.'); } diff --git a/app/Http/Controllers/Administration/UserController.php b/app/Http/Controllers/Administration/UserController.php index 9d6b941..8b28dfb 100644 --- a/app/Http/Controllers/Administration/UserController.php +++ b/app/Http/Controllers/Administration/UserController.php @@ -9,6 +9,7 @@ use App\Http\Requests\Administration\StoreUserRequest; use App\Http\Requests\Administration\UpdateUserRequest; use App\Models\Tenant; use App\Models\User; +use App\Services\LicenseEntitlementService; use Illuminate\Http\RedirectResponse; use Inertia\Inertia; use Inertia\Response; @@ -33,11 +34,12 @@ class UserController extends Controller { abort_unless(request()->user()->can('user.create'), 403); - return Inertia::render('administration/users/create', ['tenant' => $tenant]); + return Inertia::render('administration/users/create', ['tenant' => $tenant, 'canManageRoles' => request()->user()->is_platform_admin]); } public function store(StoreUserRequest $request, Tenant $tenant): RedirectResponse { + app(LicenseEntitlementService::class)->assertCanAddUser($tenant->id); $data = $request->validated(); $user = User::create([ 'tenant_id' => $tenant->id, @@ -47,7 +49,8 @@ class UserController extends Controller 'is_active' => $data['is_active'] ?? true, ]); $user->forceFill(['email_verified_at' => now()])->save(); - $user->assignRole(SystemRole::TenantUser->value); + $role = $request->user()->is_platform_admin ? ($data['role'] ?? SystemRole::TenantUser->value) : SystemRole::TenantUser->value; + $user->assignRole($role); return to_route('administration.users.index', $tenant)->with('success', 'User berhasil dibuat.'); } @@ -60,6 +63,7 @@ class UserController extends Controller return Inertia::render('administration/users/edit', [ 'tenant' => $tenant, 'managedUser' => $user->load('roles:id,name'), + 'canManageRoles' => request()->user()->is_platform_admin, ]); } @@ -68,6 +72,9 @@ class UserController extends Controller $this->ensureManageableUser($tenant, $user); $data = $request->validated(); $user->update(['name' => $data['name'], 'email' => $data['email']]); + if ($request->user()->is_platform_admin && isset($data['role'])) { + $user->syncRoles([$data['role']]); + } return back()->with('success', 'User berhasil diperbarui.'); } diff --git a/app/Http/Controllers/BillingController.php b/app/Http/Controllers/BillingController.php new file mode 100644 index 0000000..6979fc9 --- /dev/null +++ b/app/Http/Controllers/BillingController.php @@ -0,0 +1,56 @@ +user()->hasRole(SystemRole::TenantAdmin->value), 403); + $installation = DeploymentInstallation::query()->firstOrFail(); + $license = $entitlements->current($request->user()->tenant_id); + + return Inertia::render('billing/index', [ + 'installation' => ['deployment_type' => $installation->deployment_type->value, 'status' => $installation->status], + 'license' => $license ? ['plan' => $license->plan, 'status' => $license->status->value, 'max_devices' => $license->max_devices, 'max_users' => $license->max_users, 'expires_at' => $license->expires_at?->toIso8601String(), 'grace_until' => $license->grace_until?->toIso8601String()] : null, + 'plans' => collect(config('billing.plans'))->filter(fn ($plan) => $plan['deployment'] === $installation->deployment_type->value)->map(fn ($plan, $code) => ['code' => $code, ...$plan])->values(), + 'orders' => BillingOrder::query()->latest()->limit(20)->get(['uuid', 'plan_code', 'gateway', 'status', 'total', 'currency', 'checkout_url', 'created_at', 'expires_at']), + 'pendingOrder' => BillingOrder::query()->where('status', 'pending')->where('expires_at', '>', now())->latest()->first(['uuid', 'plan_code', 'total', 'currency', 'expires_at']), + 'gateways' => [config('billing.default_gateway')], + ]); + } + + public function store(Request $request): RedirectResponse + { + abort_unless($request->user()->hasRole(SystemRole::TenantAdmin->value), 403); + $data = $request->validate([ + 'plan_code' => ['required', Rule::in(array_keys(config('billing.plans')))], + 'gateway' => ['required', Rule::in(array_keys(config('billing.gateways')))], + ]); + $plan = config("billing.plans.{$data['plan_code']}"); + $installation = DeploymentInstallation::query()->firstOrFail(); + abort_unless($plan['deployment'] === $installation->deployment_type->value, 422, 'Paket tidak sesuai dengan deployment tenant.'); + abort_if($plan['price'] === 0, 422, 'Paket gratis tidak membutuhkan checkout.'); + BillingOrder::query()->where('status', 'pending')->where('expires_at', '<=', now())->update(['status' => 'expired']); + $pending = BillingOrder::query()->where('status', 'pending')->where('expires_at', '>', now())->latest()->first(); + if ($pending) { + return to_route('checkout.show', $pending->uuid)->with('info', 'Selesaikan pembayaran yang masih pending sebelum membuat perpanjangan baru.'); + } + $order = BillingOrder::create([ + 'tenant_id' => $request->user()->tenant_id, 'plan_code' => $data['plan_code'], 'gateway' => $data['gateway'], + 'status' => 'pending', 'subtotal' => $plan['price'], 'tax' => 0, 'total' => $plan['price'], 'currency' => 'IDR', 'expires_at' => now()->addDay(), + ]); + + return to_route('checkout.show', $order->uuid); + } +} diff --git a/app/Http/Controllers/CheckoutController.php b/app/Http/Controllers/CheckoutController.php new file mode 100644 index 0000000..7e964df --- /dev/null +++ b/app/Http/Controllers/CheckoutController.php @@ -0,0 +1,70 @@ +render($order); + } + + public function returned(string $order): Response + { + return $this->render($order); + } + + public function retry(Request $request, string $order, PaymentGatewayManager $gateways): RedirectResponse + { + $billingOrder = BillingOrder::withoutGlobalScope('tenant')->where('uuid', $order)->where('status', 'pending')->firstOrFail(); + try { + $driver = $gateways->driver($billingOrder->gateway); + $methods = collect($driver->paymentMethods($billingOrder)); + if ($methods->isNotEmpty()) { + $data = $request->validate(['payment_method' => ['required', 'string']]); + abort_unless($methods->contains('code', $data['payment_method']), 422, 'Kanal pembayaran tidak tersedia.'); + $billingOrder->update(['payment_method' => $data['payment_method']]); + } + $billingOrder->update($driver->createCheckout($billingOrder->fresh())); + $billingOrder->update(['metadata' => collect($billingOrder->metadata ?? [])->except('gateway_error')->all()]); + } catch (Throwable $exception) { + report($exception); + + return back()->withErrors(['payment' => str($exception->getMessage())->after(':')->trim()->toString() ?: 'Gateway pembayaran belum siap.']); + } + + return redirect()->away($billingOrder->checkout_url); + } + + private function render(string $uuid): Response + { + $order = BillingOrder::withoutGlobalScope('tenant')->where('uuid', $uuid)->firstOrFail(); + $plan = config("billing.plans.{$order->plan_code}"); + $methods = []; + $gatewayError = null; + if ($order->status === 'pending' && ! $order->checkout_url) { + try { + $methods = app(PaymentGatewayManager::class)->driver($order->gateway)->paymentMethods($order); + } catch (Throwable $exception) { + report($exception); + $gatewayError = str($exception->getMessage())->after(':')->trim()->toString(); + } + } + + return Inertia::render('checkout/show', ['order' => [ + 'uuid' => $order->uuid, 'plan' => $plan['name'], 'status' => $order->status, + 'total' => $order->total, 'currency' => $order->currency, 'checkout_url' => $order->checkout_url, + 'expires_at' => $order->expires_at?->toIso8601String(), 'gateway' => $order->gateway, + 'payment_method' => $order->payment_method, 'payment_methods' => $methods, + 'gateway_error' => $gatewayError, + ]]); + } +} diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php index 1c055f6..263d86c 100644 --- a/app/Http/Controllers/DashboardController.php +++ b/app/Http/Controllers/DashboardController.php @@ -39,6 +39,8 @@ class DashboardController extends Controller return Inertia::render('dashboard', [ 'role' => $role, 'tenant' => $user->tenant?->only(['id', 'name', 'slug', 'is_active']), + 'installation' => DeploymentInstallation::query()->first()?->only(['deployment_type', 'domain', 'status', 'activated_at']), + 'license' => License::query()->latest('created_at')->first()?->only(['plan', 'status', 'max_devices', 'max_users', 'expires_at', 'grace_until']), 'stats' => [ 'users' => User::count(), 'activeUsers' => User::where('is_active', true)->count(), diff --git a/app/Http/Controllers/DeviceController.php b/app/Http/Controllers/DeviceController.php index 28d31e2..9a65d93 100644 --- a/app/Http/Controllers/DeviceController.php +++ b/app/Http/Controllers/DeviceController.php @@ -11,6 +11,7 @@ use App\Models\DeviceModel; use App\Models\DeviceType; use App\Models\DeviceVendor; use App\Models\TenantDevicePolicy; +use App\Services\LicenseEntitlementService; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -54,6 +55,7 @@ class DeviceController extends Controller public function store(StoreDeviceRequest $request): RedirectResponse { $this->authorizeCreate(); + app(LicenseEntitlementService::class)->assertCanAddDevice($request->user()->tenant_id); $this->validateModelCombination($request); $data = $request->validated(); $username = $data['initial_username']; diff --git a/app/Http/Controllers/PaymentWebhookController.php b/app/Http/Controllers/PaymentWebhookController.php new file mode 100644 index 0000000..f5bd69e --- /dev/null +++ b/app/Http/Controllers/PaymentWebhookController.php @@ -0,0 +1,30 @@ +driver($gateway); + $payload = $request->all(); + abort_unless($driver->verifyWebhook($payload, $request->headers->all()), 401, 'Invalid payment signature.'); + $externalId = $driver->externalIdFromWebhook($payload); + $order = BillingOrder::withoutGlobalScope('tenant')->where('gateway', $gateway)->where('external_id', $externalId)->firstOrFail(); + abort_unless((int) ($payload['amount'] ?? 0) === (int) $order->total, 422, 'Payment amount mismatch.'); + $status = $driver->statusFromWebhook($payload); + if ($status === 'paid') { + $billing->activate($order); + } elseif ($order->status === 'pending') { + $order->update(['status' => $status]); + } + + return response()->json(['success' => true]); + } +} diff --git a/app/Http/Middleware/EnsureTenantLicense.php b/app/Http/Middleware/EnsureTenantLicense.php new file mode 100644 index 0000000..f39c27c --- /dev/null +++ b/app/Http/Middleware/EnsureTenantLicense.php @@ -0,0 +1,33 @@ +user(); + if (! $user || $user->is_platform_admin || ! $user->tenant_id) { + return $next($request); + } + $license = License::withoutGlobalScope('tenant')->where('tenant_id', $user->tenant_id)->latest('created_at')->first(); + $allowed = $license && in_array($license->status->value, [LicenseStatus::Active->value, LicenseStatus::Grace->value], true) + && ($license->grace_until ?? $license->expires_at)->isFuture(); + if (! $allowed) { + Auth::guard('web')->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return to_route('login')->with('status', 'Lisensi tenant telah berakhir. Hubungi Master Admin RADIQ untuk perpanjangan.'); + } + + return $next($request); + } +} diff --git a/app/Http/Requests/Administration/StoreTenantRequest.php b/app/Http/Requests/Administration/StoreTenantRequest.php index 0bfa18c..bc7f1ab 100644 --- a/app/Http/Requests/Administration/StoreTenantRequest.php +++ b/app/Http/Requests/Administration/StoreTenantRequest.php @@ -32,6 +32,7 @@ class StoreTenantRequest extends FormRequest 'owner_password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()], 'deployment_type' => ['sometimes', 'in:managed_cloud,self_hosted'], 'deployment_domain' => ['nullable', 'string', 'max:255'], + 'plan_code' => ['sometimes', 'string', 'in:cloud_free,cloud_micro,cloud_isp,local_micro,local_isp'], ]; } } diff --git a/app/Http/Requests/Administration/StoreUserRequest.php b/app/Http/Requests/Administration/StoreUserRequest.php index 431ce7c..d0d7ad0 100644 --- a/app/Http/Requests/Administration/StoreUserRequest.php +++ b/app/Http/Requests/Administration/StoreUserRequest.php @@ -28,6 +28,7 @@ class StoreUserRequest extends FormRequest 'email' => ['required', 'email', 'max:255', 'unique:users,email'], 'password' => ['required', 'confirmed', Password::min(12)->mixedCase()->numbers()->symbols()], 'is_active' => ['sometimes', 'boolean'], + 'role' => ['sometimes', 'in:TENANT ADMIN,TENANT USER'], ]; } } diff --git a/app/Http/Requests/Administration/UpdateTenantRequest.php b/app/Http/Requests/Administration/UpdateTenantRequest.php index c49a6ba..bccffce 100644 --- a/app/Http/Requests/Administration/UpdateTenantRequest.php +++ b/app/Http/Requests/Administration/UpdateTenantRequest.php @@ -28,6 +28,7 @@ class UpdateTenantRequest extends FormRequest 'is_active' => ['required', 'boolean'], 'deployment_type' => ['sometimes', 'in:managed_cloud,self_hosted'], 'deployment_domain' => ['nullable', 'string', 'max:255'], + 'plan_code' => ['nullable', 'string', 'in:cloud_free,cloud_micro,cloud_isp,local_micro,local_isp'], ]; } } diff --git a/app/Http/Requests/Administration/UpdateUserRequest.php b/app/Http/Requests/Administration/UpdateUserRequest.php index 2215672..e8303fc 100644 --- a/app/Http/Requests/Administration/UpdateUserRequest.php +++ b/app/Http/Requests/Administration/UpdateUserRequest.php @@ -26,6 +26,7 @@ class UpdateUserRequest extends FormRequest return [ 'name' => ['required', 'string', 'max:255'], 'email' => ['required', 'email', 'max:255', Rule::unique('users', 'email')->ignore($this->route('user')->id)], + 'role' => ['sometimes', 'in:TENANT ADMIN,TENANT USER'], ]; } } diff --git a/app/Http/Responses/RegisterResponse.php b/app/Http/Responses/RegisterResponse.php new file mode 100644 index 0000000..18285dd --- /dev/null +++ b/app/Http/Responses/RegisterResponse.php @@ -0,0 +1,25 @@ +user(); + $order = $user?->tenant_id ? BillingOrder::withoutGlobalScope('tenant')->where('tenant_id', $user->tenant_id)->latest()->first() : null; + if ($order) { + Auth::guard('web')->logout(); + $request->session()->invalidate(); + $request->session()->regenerateToken(); + + return redirect()->route('checkout.show', $order->uuid); + } + + return redirect()->intended(config('fortify.home')); + } +} diff --git a/app/Jobs/SyncDeviceBaseCredential.php b/app/Jobs/SyncDeviceBaseCredential.php index 396de9c..d6286a2 100644 --- a/app/Jobs/SyncDeviceBaseCredential.php +++ b/app/Jobs/SyncDeviceBaseCredential.php @@ -15,6 +15,7 @@ use App\Network\Drivers\Hisfocus\HisfocusOltDriver; use App\Network\Drivers\Hsgq\HsgqOltDriver; use App\Network\Drivers\Mikrotik\MikrotikDriver; use App\Network\Drivers\Zte\ZteC3xxDriver; +use App\Services\LicenseEntitlementService; use App\Support\TenantContext; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; @@ -39,6 +40,11 @@ class SyncDeviceBaseCredential implements ShouldQueue $context->set($this->tenantId); setPermissionsTeamId($this->tenantId); try { + if (! app(LicenseEntitlementService::class)->allowsUse($this->tenantId)) { + Device::withoutGlobalScope('tenant')->whereKey($this->deviceId)->update(['base_sync_status' => 'pending', 'base_sync_error_code' => 'LICENSE_INACTIVE', 'base_sync_message' => 'Menunggu lisensi tenant aktif.']); + + return; + } $device = Device::with(['vendor', 'model', 'credentials'])->find($this->deviceId); $setting = TenantDeviceSetting::where('tenant_id', $this->tenantId)->first(); if (! $device || ! $setting) { diff --git a/app/Jobs/SyncDeviceUser.php b/app/Jobs/SyncDeviceUser.php index 0996f6b..fb8b74d 100644 --- a/app/Jobs/SyncDeviceUser.php +++ b/app/Jobs/SyncDeviceUser.php @@ -15,6 +15,7 @@ use App\Network\Drivers\Hisfocus\HisfocusOltDriver; use App\Network\Drivers\Hsgq\HsgqOltDriver; use App\Network\Drivers\Mikrotik\MikrotikDriver; use App\Network\Drivers\Zte\ZteC3xxDriver; +use App\Services\LicenseEntitlementService; use App\Support\TenantContext; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Queue\Queueable; @@ -33,6 +34,11 @@ class SyncDeviceUser implements ShouldQueue $context->set($this->tenantId); setPermissionsTeamId($this->tenantId); try { + if (! app(LicenseEntitlementService::class)->allowsUse($this->tenantId)) { + DeviceUserAssignment::withoutGlobalScope('tenant')->whereKey($this->assignmentId)->update(['sync_status' => 'pending', 'error_code' => 'LICENSE_INACTIVE', 'message' => 'Menunggu lisensi tenant aktif.']); + + return; + } $assignment = DeviceUserAssignment::with(['device.vendor', 'device.credentials', 'accessUser'])->find($this->assignmentId); if (! $assignment || $assignment->tenant_id !== $this->tenantId) { return; diff --git a/app/Models/BillingOrder.php b/app/Models/BillingOrder.php new file mode 100644 index 0000000..f57910e --- /dev/null +++ b/app/Models/BillingOrder.php @@ -0,0 +1,30 @@ + $order->uuid ??= (string) Str::uuid()); + } + + protected function casts(): array + { + return ['metadata' => 'array', 'expires_at' => 'immutable_datetime', 'paid_at' => 'immutable_datetime']; + } + + public function license(): BelongsTo + { + return $this->belongsTo(License::class); + } +} diff --git a/app/Providers/FortifyServiceProvider.php b/app/Providers/FortifyServiceProvider.php index de9ef02..956e612 100644 --- a/app/Providers/FortifyServiceProvider.php +++ b/app/Providers/FortifyServiceProvider.php @@ -4,7 +4,9 @@ namespace App\Providers; use App\Actions\Fortify\CreateNewUser; use App\Actions\Fortify\ResetUserPassword; +use App\Http\Responses\RegisterResponse; use App\Models\User; +use App\Services\LicenseEntitlementService; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Http\Request; use Illuminate\Support\Facades\Hash; @@ -13,6 +15,7 @@ use Illuminate\Support\ServiceProvider; use Illuminate\Support\Str; use Illuminate\Validation\Rules\Password; use Inertia\Inertia; +use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract; use Laravel\Fortify\Features; use Laravel\Fortify\Fortify; @@ -23,7 +26,7 @@ class FortifyServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->singleton(RegisterResponseContract::class, RegisterResponse::class); } /** @@ -44,7 +47,9 @@ class FortifyServiceProvider extends ServiceProvider 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) + $licenseValid = ! $user || $user->is_platform_admin || app(LicenseEntitlementService::class)->allowsUse($user->tenant_id); + + return $user && $user->is_active && $licenseValid && Hash::check($request->string('password'), $user->password) ? $user : null; }); @@ -60,9 +65,14 @@ class FortifyServiceProvider extends ServiceProvider { Fortify::loginView(fn (Request $request) => Inertia::render('auth/login', [ 'canResetPassword' => Features::enabled(Features::resetPasswords()), + 'canRegister' => Features::enabled(Features::registration()), 'status' => $request->session()->get('status'), ])); + Fortify::registerView(fn () => Inertia::render('auth/register', [ + 'passwordRules' => Password::defaults()->toPasswordRulesString(), + ])); + Fortify::resetPasswordView(fn (Request $request) => Inertia::render('auth/reset-password', [ 'email' => $request->email, 'token' => $request->route('token'), diff --git a/app/Services/BillingService.php b/app/Services/BillingService.php new file mode 100644 index 0000000..59b192b --- /dev/null +++ b/app/Services/BillingService.php @@ -0,0 +1,39 @@ +lockForUpdate()->findOrFail($order->id); + if ($order->status === 'paid' && $order->license) { + return $order->license; + } + $plan = config("billing.plans.{$order->plan_code}"); + abort_unless($plan, 422, 'Paket lisensi tidak tersedia.'); + $installation = DeploymentInstallation::withoutGlobalScope('tenant')->where('tenant_id', $order->tenant_id)->firstOrFail(); + $current = License::withoutGlobalScope('tenant')->where('tenant_id', $order->tenant_id) + ->whereIn('status', [LicenseStatus::Active->value, LicenseStatus::Grace->value])->latest('created_at')->first(); + $startsAt = $current && $current->plan !== 'cloud_free' && $current->expires_at->isFuture() ? $current->expires_at : now(); + $current?->update(['status' => LicenseStatus::Revoked]); + $license = License::withoutGlobalScope('tenant')->create([ + 'tenant_id' => $order->tenant_id, 'deployment_installation_id' => $installation->id, + 'license_key_hash' => hash('sha256', random_bytes(32)), 'plan' => $order->plan_code, + 'status' => LicenseStatus::Active, 'max_devices' => $plan['max_devices'], 'max_users' => $plan['max_users'], + 'features' => ['device_access_management'], 'starts_at' => $startsAt, 'expires_at' => $startsAt->addMonth(), + 'grace_until' => $startsAt->addMonth()->addDays(7), 'issued_by' => $issuerId, + ]); + $order->update(['license_id' => $license->id, 'status' => 'paid', 'paid_at' => now()]); + + return $license; + }); + } +} diff --git a/app/Services/LicenseEntitlementService.php b/app/Services/LicenseEntitlementService.php new file mode 100644 index 0000000..be74b44 --- /dev/null +++ b/app/Services/LicenseEntitlementService.php @@ -0,0 +1,77 @@ +current($tenantId); + + return $license !== null && ($license->grace_until ?? $license->expires_at)->isFuture(); + } + + public function provisionInitial(Tenant $tenant, DeploymentInstallation $installation, string $planCode, bool $activate = false, ?int $issuerId = null): License + { + $plan = config("billing.plans.{$planCode}"); + abort_unless($plan && $plan['deployment'] === $installation->deployment_type->value, 422, 'Paket tidak sesuai deployment tenant.'); + $isFree = $plan['price'] === 0; + $startsAt = now(); + + return License::withoutGlobalScope('tenant')->create([ + 'tenant_id' => $tenant->id, 'deployment_installation_id' => $installation->id, + 'license_key_hash' => hash('sha256', random_bytes(32)), 'plan' => $planCode, + 'status' => $activate || $isFree ? LicenseStatus::Active : LicenseStatus::Pending, + 'max_devices' => $plan['max_devices'], 'max_users' => $plan['max_users'], 'features' => ['device_access_management'], + 'starts_at' => $startsAt, 'expires_at' => $isFree ? $startsAt->copy()->addYears(10) : $startsAt->copy()->addMonth(), + 'grace_until' => $isFree ? null : $startsAt->copy()->addMonth()->addDays(7), + 'issued_by' => $issuerId, + ]); + } + + public function current(int $tenantId): ?License + { + $license = License::withoutGlobalScope('tenant')->where('tenant_id', $tenantId) + ->whereIn('status', [LicenseStatus::Active->value, LicenseStatus::Grace->value]) + ->latest('created_at')->first(); + if ($license) { + return $license; + } + if (License::withoutGlobalScope('tenant')->where('tenant_id', $tenantId)->exists()) { + return null; + } + $installation = DeploymentInstallation::withoutGlobalScope('tenant')->where('tenant_id', $tenantId)->first(); + if (! $installation || $installation->deployment_type->value !== 'managed_cloud') { + return null; + } + $plan = config('billing.plans.cloud_free'); + + return License::withoutGlobalScope('tenant')->create([ + 'tenant_id' => $tenantId, 'deployment_installation_id' => $installation->id, + 'license_key_hash' => hash('sha256', random_bytes(32)), 'plan' => 'cloud_free', + 'status' => LicenseStatus::Active, 'max_devices' => $plan['max_devices'], 'max_users' => $plan['max_users'], + 'features' => ['device_access_management'], 'starts_at' => now(), 'expires_at' => now()->addYears(10), + ]); + } + + public function assertCanAddDevice(int $tenantId): void + { + $license = $this->current($tenantId); + abort_unless($license, 402, 'Lisensi tenant belum aktif.'); + abort_if($license->max_devices !== null && Device::withoutGlobalScope('tenant')->where('tenant_id', $tenantId)->count() >= $license->max_devices, 422, 'Batas perangkat paket tercapai. Upgrade paket melalui menu Lisensi & Tagihan.'); + } + + public function assertCanAddUser(int $tenantId): void + { + $license = $this->current($tenantId); + abort_unless($license, 402, 'Lisensi tenant belum aktif.'); + abort_if($license->max_users !== null && User::withoutGlobalScope('tenant')->where('tenant_id', $tenantId)->count() >= $license->max_users, 422, 'Batas user aplikasi paket tercapai. Upgrade paket melalui menu Lisensi & Tagihan.'); + } +} diff --git a/app/Services/TenantProvisioningService.php b/app/Services/TenantProvisioningService.php index aa260e1..b827627 100644 --- a/app/Services/TenantProvisioningService.php +++ b/app/Services/TenantProvisioningService.php @@ -29,7 +29,7 @@ class TenantProvisioningService TenantDevicePolicy::create(['tenant_id' => $tenant->id]); app(TenantEnvelopeEncryption::class)->ensureKey($tenant->id); $deploymentType = $data['deployment_type'] ?? 'managed_cloud'; - DeploymentInstallation::create([ + $installation = DeploymentInstallation::create([ 'tenant_id' => $tenant->id, 'name' => $deploymentType === 'self_hosted' ? 'Server Tenant' : 'RADIQ Managed Cloud', 'deployment_type' => $deploymentType, @@ -38,6 +38,14 @@ class TenantProvisioningService 'status' => $deploymentType === 'managed_cloud' ? 'active' : 'pending', 'activated_at' => $deploymentType === 'managed_cloud' ? now() : null, ]); + $defaultPlan = $deploymentType === 'managed_cloud' ? 'cloud_free' : 'local_micro'; + app(LicenseEntitlementService::class)->provisionInitial( + $tenant, + $installation, + $data['plan_code'] ?? $defaultPlan, + (bool) ($data['activate_license'] ?? true), + $data['issued_by'] ?? null, + ); $this->context->set($tenant->id); setPermissionsTeamId($tenant->id); @@ -52,7 +60,7 @@ class TenantProvisioningService 'password' => $data['owner_password'], 'is_active' => true, ]); - $owner->forceFill(['email_verified_at' => now()])->save(); + $owner->forceFill(['email_verified_at' => ($data['email_verified'] ?? true) ? now() : null])->save(); $owner->assignRole($roles[SystemRole::TenantAdmin->value]); } finally { $this->context->clear(); diff --git a/bootstrap/app.php b/bootstrap/app.php index fa3ed79..d93342f 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -1,6 +1,7 @@ withMiddleware(function (Middleware $middleware): void { + $middleware->validateCsrfTokens(except: ['payments/*/callback']); $middleware->encryptCookies(except: ['appearance', 'sidebar_state']); $middleware->web(append: [ SetTenantContext::class, EnsureActiveUser::class, + EnsureTenantLicense::class, HandleAppearance::class, HandleInertiaRequests::class, AddLinkHeadersForPreloadedAssets::class, diff --git a/config/billing.php b/config/billing.php new file mode 100644 index 0000000..e177a9a --- /dev/null +++ b/config/billing.php @@ -0,0 +1,25 @@ + env('PAYMENT_GATEWAY', 'duitku'), + 'gateways' => [ + 'manual' => ManualPaymentGateway::class, + 'duitku' => DuitkuPaymentGateway::class, + ], + 'duitku' => [ + 'merchant_code' => env('DUITKU_MERCHANT_CODE'), + 'api_key' => env('DUITKU_API_KEY'), + 'sandbox' => env('DUITKU_SANDBOX', true), + 'ca_bundle' => env('DUITKU_CA_BUNDLE', ini_get('curl.cainfo') ?: null), + ], + 'plans' => [ + 'cloud_free' => ['name' => 'Cloud Free', 'deployment' => 'managed_cloud', 'price' => 0, 'max_devices' => 5, 'max_users' => 2], + 'cloud_micro' => ['name' => 'Cloud Micro', 'deployment' => 'managed_cloud', 'price' => 99000, 'max_devices' => 25, 'max_users' => 5], + 'cloud_isp' => ['name' => 'Cloud ISP', 'deployment' => 'managed_cloud', 'price' => 249000, 'max_devices' => 100, 'max_users' => 15], + 'local_micro' => ['name' => 'Local Micro', 'deployment' => 'self_hosted', 'price' => 69000, 'max_devices' => 50, 'max_users' => 5], + 'local_isp' => ['name' => 'Local ISP', 'deployment' => 'self_hosted', 'price' => 149000, 'max_devices' => 250, 'max_users' => 20], + ], +]; diff --git a/config/fortify.php b/config/fortify.php index 757a4d6..76147cc 100644 --- a/config/fortify.php +++ b/config/fortify.php @@ -161,7 +161,7 @@ return [ */ 'features' => [ - // User creation is restricted to authorized administrators. + ...(config('deployment.mode') === 'managed_cloud' ? [Features::registration()] : []), Features::resetPasswords(), Features::emailVerification(), Features::twoFactorAuthentication([ diff --git a/database/migrations/2026_08_25_000001_create_billing_orders_table.php b/database/migrations/2026_08_25_000001_create_billing_orders_table.php new file mode 100644 index 0000000..e0006ee --- /dev/null +++ b/database/migrations/2026_08_25_000001_create_billing_orders_table.php @@ -0,0 +1,37 @@ +id(); + $table->uuid('uuid')->unique(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('license_id')->nullable()->constrained()->nullOnDelete(); + $table->string('plan_code', 50); + $table->string('gateway', 50); + $table->string('external_id')->nullable()->index(); + $table->string('status', 30)->default('pending')->index(); + $table->unsignedBigInteger('subtotal'); + $table->unsignedBigInteger('tax')->default(0); + $table->unsignedBigInteger('total'); + $table->char('currency', 3)->default('IDR'); + $table->text('checkout_url')->nullable(); + $table->jsonb('metadata')->nullable(); + $table->timestampTz('expires_at')->nullable(); + $table->timestampTz('paid_at')->nullable(); + $table->timestampsTz(); + $table->index(['tenant_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('billing_orders'); + } +}; diff --git a/database/migrations/2026_08_25_000002_backfill_initial_tenant_licenses.php b/database/migrations/2026_08_25_000002_backfill_initial_tenant_licenses.php new file mode 100644 index 0000000..0086fa1 --- /dev/null +++ b/database/migrations/2026_08_25_000002_backfill_initial_tenant_licenses.php @@ -0,0 +1,23 @@ +whereDoesntHave('licenses')->each(function (Tenant $tenant): void { + $installation = DeploymentInstallation::withoutGlobalScope('tenant')->where('tenant_id', $tenant->id)->first(); + if (! $installation) { + return; + } + $plan = $installation->deployment_type->value === 'managed_cloud' ? 'cloud_free' : 'local_micro'; + app(LicenseEntitlementService::class)->provisionInitial($tenant, $installation, $plan, true); + }); + } + + public function down(): void {} +}; diff --git a/database/migrations/2026_08_25_000003_repair_legacy_tenant_installations.php b/database/migrations/2026_08_25_000003_repair_legacy_tenant_installations.php new file mode 100644 index 0000000..429993e --- /dev/null +++ b/database/migrations/2026_08_25_000003_repair_legacy_tenant_installations.php @@ -0,0 +1,28 @@ +each(function (Tenant $tenant): void { + $installation = DeploymentInstallation::withoutGlobalScope('tenant')->where('tenant_id', $tenant->id)->first(); + if (! $installation) { + $installation = DeploymentInstallation::withoutGlobalScope('tenant')->create([ + 'tenant_id' => $tenant->id, 'name' => 'RADIQ Managed Cloud', 'deployment_type' => 'managed_cloud', + 'instance_key_hash' => hash('sha256', random_bytes(32)), 'status' => 'active', 'activated_at' => now(), + ]); + } + if (! $tenant->licenses()->exists()) { + $plan = $installation->deployment_type->value === 'managed_cloud' ? 'cloud_free' : 'local_micro'; + app(LicenseEntitlementService::class)->provisionInitial($tenant, $installation, $plan, true); + } + }); + } + + public function down(): void {} +}; diff --git a/database/migrations/2026_08_25_000004_add_payment_method_to_billing_orders.php b/database/migrations/2026_08_25_000004_add_payment_method_to_billing_orders.php new file mode 100644 index 0000000..ce18c78 --- /dev/null +++ b/database/migrations/2026_08_25_000004_add_payment_method_to_billing_orders.php @@ -0,0 +1,22 @@ +string('payment_method', 20)->nullable()->after('gateway'); + }); + } + + public function down(): void + { + Schema::table('billing_orders', function (Blueprint $table): void { + $table->dropColumn('payment_method'); + }); + } +}; diff --git a/resources/css/app.css b/resources/css/app.css index 7015d58..38e2c19 100644 --- a/resources/css/app.css +++ b/resources/css/app.css @@ -141,3 +141,23 @@ @apply bg-background text-foreground; } } + +@layer components { + .public-page .text-slate-100, + .public-page .text-slate-200 { + @apply text-foreground; + } + + .public-page .text-slate-300, + .public-page .text-slate-400 { + @apply text-muted-foreground; + } + + .public-page .border-white\/10 { + @apply border-border; + } + + .public-page .bg-white\/5 { + @apply bg-muted/40; + } +} diff --git a/resources/js/app.tsx b/resources/js/app.tsx index 1e8616b..4398f52 100644 --- a/resources/js/app.tsx +++ b/resources/js/app.tsx @@ -12,7 +12,9 @@ createInertiaApp({ title: (title) => (title ? `${title} - ${appName}` : appName), layout: (name) => { switch (true) { - case name === 'welcome': + case name === 'welcome' || + name.startsWith('documentation/') || + name.startsWith('checkout/'): return null; case name.startsWith('auth/'): return AuthLayout; diff --git a/resources/js/components/app-sidebar.tsx b/resources/js/components/app-sidebar.tsx index deb013a..22bc463 100644 --- a/resources/js/components/app-sidebar.tsx +++ b/resources/js/components/app-sidebar.tsx @@ -1,6 +1,8 @@ import { Link, usePage } from '@inertiajs/react'; import { Building2, + BookOpen, + CreditCard, LayoutGrid, Network, Settings2, @@ -24,14 +26,19 @@ import type { NavItem } from '@/types'; export function AppSidebar() { const { auth } = usePage().props; + + if (!auth.user) { + return null; + } + + const permissions = auth.permissions ?? []; const mainNavItems: NavItem[] = [ { title: 'Dashboard', href: dashboard(), icon: LayoutGrid }, - ...(!auth.user.is_platform_admin && - auth.permissions.includes('device.view') + ...(!auth.user.is_platform_admin && permissions.includes('device.view') ? [{ title: 'Perangkat', href: '/devices', icon: Network }] : []), ...(!auth.user.is_platform_admin && - auth.permissions.includes('device.user.view') + permissions.includes('device.user.view') ? [ { title: 'Users Perangkat', @@ -40,8 +47,22 @@ export function AppSidebar() { }, ] : []), + { + title: 'Dokumentasi', + href: '/documentation/deployment', + icon: BookOpen, + }, + ...(!auth.user.is_platform_admin && permissions.includes('license.view') + ? [ + { + title: 'Lisensi & Tagihan', + href: '/billing', + icon: CreditCard, + }, + ] + : []), ...(!auth.user.is_platform_admin && - auth.permissions.includes('device.credential.create') + permissions.includes('device.credential.create') ? [ { title: 'Pengaturan Perangkat', @@ -57,11 +78,16 @@ export function AppSidebar() { href: '/administration/tenants', icon: Building2, }, + { + title: 'Order Lisensi', + href: '/administration/billing-orders', + icon: CreditCard, + }, ] : []), ...(!auth.user.is_platform_admin && auth.user.tenant_id && - auth.permissions.includes('user.view') + permissions.includes('user.view') ? [ { title: 'Users', diff --git a/resources/js/components/public-theme-toggle.tsx b/resources/js/components/public-theme-toggle.tsx new file mode 100644 index 0000000..114c312 --- /dev/null +++ b/resources/js/components/public-theme-toggle.tsx @@ -0,0 +1,19 @@ +import { Moon, Sun } from 'lucide-react'; +import { useAppearance } from '@/hooks/use-appearance'; + +export default function PublicThemeToggle() { + const { resolvedAppearance, updateAppearance } = useAppearance(); + const isDark = resolvedAppearance === 'dark'; + + return ( + + ); +} diff --git a/resources/js/pages/administration/billing/index.tsx b/resources/js/pages/administration/billing/index.tsx new file mode 100644 index 0000000..d11ae14 --- /dev/null +++ b/resources/js/pages/administration/billing/index.tsx @@ -0,0 +1,104 @@ +import { Form, Head } from '@inertiajs/react'; +import { Button } from '@/components/ui/button'; + +type Order = { + id: number; + uuid: string; + plan_code: string; + gateway: string; + status: string; + total: number; + currency: string; + created_at: string; + tenant: { name: string }; +}; + +export default function BillingOrders({ + orders, +}: { + orders: { data: Order[] }; +}) { + return ( + <> + +
+
+

Order Lisensi

+

+ Verifikasi pembayaran manual dan pantau checkout seluruh + tenant. +

+
+
+ + + + {[ + 'Tenant', + 'Paket', + 'Gateway', + 'Total', + 'Status', + 'Dibuat', + 'Aksi', + ].map((item) => ( + + ))} + + + + {orders.data.map((order) => ( + + + + + + + + + + ))} + +
+ {item} +
+ {order.tenant.name} + + {order.plan_code} + + {order.gateway} + + {new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: order.currency, + maximumFractionDigits: 0, + }).format(order.total)} + + {order.status} + + {new Date( + order.created_at, + ).toLocaleString('id-ID')} + + {order.status === 'pending' && ( +
+ +
+ )} +
+
+
+ + ); +} + +BillingOrders.layout = { + breadcrumbs: [ + { title: 'Order Lisensi', href: '/administration/billing-orders' }, + ], +}; diff --git a/resources/js/pages/administration/tenants/create.tsx b/resources/js/pages/administration/tenants/create.tsx index b29dbdc..192bd94 100644 --- a/resources/js/pages/administration/tenants/create.tsx +++ b/resources/js/pages/administration/tenants/create.tsx @@ -11,7 +11,14 @@ import { import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; -export default function TenantCreate() { +export default function TenantCreate({ + plans, +}: { + plans: Record< + string, + { name: string; price: number; max_devices: number; max_users: number } + >; +}) { return ( <> @@ -158,6 +165,34 @@ export default function TenantCreate() { message={errors.deployment_domain} /> +
+ + + +
diff --git a/resources/js/pages/administration/tenants/edit.tsx b/resources/js/pages/administration/tenants/edit.tsx index 6c42ae6..a01ede7 100644 --- a/resources/js/pages/administration/tenants/edit.tsx +++ b/resources/js/pages/administration/tenants/edit.tsx @@ -33,15 +33,29 @@ type Installation = { status: string; last_seen_at?: string; }; +type License = { + plan: string; + status: string; + max_devices: number; + max_users: number; + expires_at: string; +}; export default function TenantEdit({ tenant, users, installation, + license, + plans, }: { tenant: Tenant; users: User[]; installation: Installation | null; + license: License | null; + plans: Record< + string, + { name: string; price: number; max_devices: number; max_users: number } + >; }) { const { flash } = usePage().props as unknown as { flash: { success?: string }; @@ -150,6 +164,37 @@ export default function TenantEdit({ } />
+
+ + +

+ Status: {license?.status ?? 'belum ada'} + {license?.expires_at + ? ` · sampai ${new Date(license.expires_at).toLocaleDateString('id-ID')}` + : ''} +

+ +
-
- Role sistem:{' '} - - {managedUser.roles[0]?.name ?? - 'Tenant User'} - - . Role bersifat baku dan tidak dapat diubah. -
+ {canManageRoles ? ( +
+ + +
+ ) : ( +
+ Role sistem:{' '} + + {managedUser.roles[0]?.name ?? + 'Tenant User'} + + . Role bersifat baku dan tidak dapat + diubah. +
+ )}

- Contact your administrator to request access. + {canRegister ? ( + <> + Belum memiliki tenant?{' '} + + Daftar RADIQ NDM + + + ) : ( + 'Hubungi administrator untuk meminta akses.' + )}

)} diff --git a/resources/js/pages/auth/register.tsx b/resources/js/pages/auth/register.tsx new file mode 100644 index 0000000..b5301dc --- /dev/null +++ b/resources/js/pages/auth/register.tsx @@ -0,0 +1,205 @@ +import { Form, Head, Link } from '@inertiajs/react'; +import { Cloud, Server } from 'lucide-react'; +import { useState } from 'react'; +import InputError from '@/components/input-error'; +import PasswordInput from '@/components/password-input'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Spinner } from '@/components/ui/spinner'; + +type Deployment = 'managed_cloud' | 'self_hosted'; + +export default function Register({ passwordRules }: { passwordRules: string }) { + const [deployment, setDeployment] = useState('managed_cloud'); + const [plan, setPlan] = useState('cloud_free'); + + return ( + <> + +
+ {({ processing, errors }) => ( + <> +
+ + + +
+
+ {( + [ + [ + 'managed_cloud', + Cloud, + 'Cloud RADIQ', + 'Langsung gunakan aplikasi tanpa mengelola server.', + ], + [ + 'self_hosted', + Server, + 'Install Local', + 'Jalankan di VPS, server, atau komputer sendiri.', + ], + ] as const + ).map(([value, Icon, title, description]) => ( + + ))} + + +
+
+ + + +
+ {deployment === 'self_hosted' && ( +
+ + + +
+ )} +
+ + + +
+
+ + + +
+
+ + +

+ {passwordRules} +

+ +
+
+ + +
+ +

+ Sudah terdaftar?{' '} + + Login + +

+ + )} +
+ + ); +} + +Register.layout = { + title: 'Daftarkan Tenant RADIQ NDM', + description: 'Pilih Cloud RADIQ atau instalasi di server milik Anda.', +}; diff --git a/resources/js/pages/billing/index.tsx b/resources/js/pages/billing/index.tsx new file mode 100644 index 0000000..5c10983 --- /dev/null +++ b/resources/js/pages/billing/index.tsx @@ -0,0 +1,247 @@ +import { Form, Head, Link } from '@inertiajs/react'; +import { CheckCircle2, Clock3, CreditCard, Server } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; + +type Plan = { + code: string; + name: string; + deployment: string; + price: number; + max_devices: number; + max_users: number; +}; +type License = { + plan: string; + status: string; + max_devices: number | null; + max_users: number | null; + expires_at: string | null; + grace_until: string | null; +}; +type Order = { + uuid: string; + plan_code: string; + gateway: string; + status: string; + total: number; + currency: string; + checkout_url: string | null; + created_at: string; +}; + +const money = (value: number) => + new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: 'IDR', + maximumFractionDigits: 0, + }).format(value); + +export default function BillingIndex({ + installation, + license, + plans, + orders, + gateways, + pendingOrder, +}: { + installation: { deployment_type: string; status: string }; + license: License | null; + plans: Plan[]; + orders: Order[]; + gateways: string[]; + pendingOrder: Pick | null; +}) { + return ( + <> + +
+
+

+ RADIQ NDM · BILLING +

+

+ Lisensi & Tagihan +

+

+ Pilih paket, checkout, dan pantau masa berlaku lisensi + tenant. +

+
+ {pendingOrder && ( + + +
+

Pembayaran masih pending

+

{pendingOrder.plan_code} · {money(pendingOrder.total)}. Selesaikan order ini sebelum membuat perpanjangan baru.

+
+ +
+
+ )} +
+ + + + Paket aktif + + + +

+ {license?.plan ?? 'Belum aktif'} +

+

+ Status {license?.status ?? installation.status} +

+
+
+ + + + Batas perangkat + + + +

+ {license?.max_devices ?? '—'} +

+
+
+ + + + Berlaku sampai + + + +

+ {license?.plan === 'cloud_free' + ? 'Selama paket tersedia' + : license?.expires_at + ? new Date( + license.expires_at, + ).toLocaleDateString('id-ID') + : '—'} +

+
+
+
+
+

+ Pilih paket bulanan +

+
+ {plans.map((plan) => ( + + +
+ {plan.name} + {license?.plan === plan.code && ( + + )} +
+
+ +

+ {plan.price === 0 + ? 'Gratis' + : money(plan.price)} + + {plan.price > 0 && '/bulan'} + +

+
    +
  • ✓ {plan.max_devices} perangkat
  • +
  • + ✓ {plan.max_users} user aplikasi +
  • +
  • ✓ Users Perangkat tanpa batas
  • +
+ {plan.price > 0 && ( +
+ + + +
+ )} +
+
+ ))} +
+
+ + + Riwayat checkout + + + {orders.length === 0 ? ( +

+ Belum ada checkout. +

+ ) : ( +
+ {orders.map((order) => ( +
+
+

+ {order.plan_code} ·{' '} + {money(order.total)} +

+

+ {new Date( + order.created_at, + ).toLocaleString('id-ID')}{' '} + · {order.gateway} +

+
+ + + {order.status} + + {order.status === 'pending' && ( + + )} +
+ ))} +
+ )} +
+
+
+ + Checkout berada di Tenant Admin. Payment gateway dapat + diganti tanpa mengubah data order atau proses lisensi. +
+
+ + ); +} + +BillingIndex.layout = { + breadcrumbs: [{ title: 'Lisensi & Tagihan', href: '/billing' }], +}; diff --git a/resources/js/pages/checkout/show.tsx b/resources/js/pages/checkout/show.tsx new file mode 100644 index 0000000..a98bee0 --- /dev/null +++ b/resources/js/pages/checkout/show.tsx @@ -0,0 +1,146 @@ +import { Form, Head, Link, usePage } from '@inertiajs/react'; +import { CheckCircle2, Clock3, CreditCard, TriangleAlert } from 'lucide-react'; +import PublicThemeToggle from '@/components/public-theme-toggle'; + +type Order = { + uuid: string; + plan: string; + status: string; + total: number; + currency: string; + checkout_url: string | null; + gateway: string; + payment_method: string | null; + payment_methods: { code: string; name: string; fee: number }[]; + gateway_error: string | null; +}; + +export default function CheckoutShow({ order }: { order: Order }) { + const { errors } = usePage().props; + const paid = order.status === 'paid'; + const total = new Intl.NumberFormat('id-ID', { + style: 'currency', + currency: order.currency, + maximumFractionDigits: 0, + }).format(order.total); + + return ( + <> + +
+
+
+ + RADIQ NDM + RADIQ NDM + + +
+
+
+ {paid ? ( + + ) : ( + + )} +
+

+ Checkout lisensi bulanan +

+

+ {order.plan} +

+
+
+
+
+ Total pembayaran + {total} +
+
+ Gateway + {order.gateway} +
+
+ Status + {order.status} +
+
+ {paid ? ( +
+

+ Pembayaran diterima dan lisensi telah + diaktifkan. +

+ + Login Tenant Admin + +
+ ) : order.checkout_url ? ( +
+ + Lanjut Bayar dengan Duitku + +

+ + Status aktif setelah callback pembayaran + terverifikasi. +

+
+ ) : ( +
+ {order.gateway_error ? ( +

+ + {order.gateway_error} +

+ ) : ( +

+ Pilih kanal pembayaran Duitku. Order ini tetap dapat dilanjutkan tanpa membuat order baru. +

+ )} + {errors.payment && ( +

{String(errors.payment)}

+ )} +
+ {order.payment_methods.length > 0 && ( + + )} + +
+
+ )} +
+
+
+ + ); +} diff --git a/resources/js/pages/dashboard.tsx b/resources/js/pages/dashboard.tsx index e96745b..c52137e 100644 --- a/resources/js/pages/dashboard.tsx +++ b/resources/js/pages/dashboard.tsx @@ -21,6 +21,19 @@ type Props = { slug: string; is_active: boolean; } | null; + installation?: { + deployment_type: 'managed_cloud' | 'self_hosted'; + domain: string | null; + status: string; + activated_at: string | null; + } | null; + license?: { + plan: string; + status: string; + max_devices: number; + max_users: number; + expires_at: string | null; + } | null; stats: Record; }; @@ -30,7 +43,13 @@ const roleLabels = { 'TENANT USER': 'Tenant User', }; -export default function Dashboard({ role, tenant, stats }: Props) { +export default function Dashboard({ + role, + tenant, + installation, + license, + stats, +}: Props) { const { auth } = usePage().props; const isMaster = role === 'MASTER ADMIN'; const isTenantAdmin = role === 'TENANT ADMIN'; @@ -144,6 +163,42 @@ export default function Dashboard({ role, tenant, stats }: Props) { : 'Akses perangkat Anda mengikuti kebijakan yang ditetapkan oleh Tenant Admin.')} + {!isMaster && installation && ( + + + Deployment Tenant + + +
+

+ {installation.deployment_type === + 'managed_cloud' + ? 'Cloud RADIQ' + : 'Self-hosted / Local'} +

+

+ Status: {installation.status} + {installation.domain + ? ` · ${installation.domain}` + : ''} +

+

+ Paket: {license?.plan ?? 'belum ada'} · + Lisensi {license?.status ?? 'belum aktif'} + {license?.expires_at && + license.plan !== 'cloud_free' + ? ` · sampai ${new Date(license.expires_at).toLocaleDateString('id-ID')}` + : ''} +

+
+ +
+
+ )} ); diff --git a/resources/js/pages/devices/form.tsx b/resources/js/pages/devices/form.tsx index c44d906..0258508 100644 --- a/resources/js/pages/devices/form.tsx +++ b/resources/js/pages/devices/form.tsx @@ -8,14 +8,20 @@ import { Label } from '@/components/ui/label'; type Item = { id: number; name: string; slug?: string }; type Model = Item & { device_vendor_id: number; device_type_id: number }; type Props = { - device: (Record & { connection_ports?: Record }) | null; + device: + | (Record & { + connection_ports?: Record; + }) + | null; vendors: Item[]; types: Item[]; models: Model[]; }; export default function DeviceForm({ device, vendors, types, models }: Props) { const editing = !!device; - const initialVendor = vendors.find((vendor) => vendor.id === Number(device?.device_vendor_id)); + const initialVendor = vendors.find( + (vendor) => vendor.id === Number(device?.device_vendor_id), + ); const [vendorSlug, setVendorSlug] = useState(initialVendor?.slug ?? ''); const fields = [ ['name', 'Nama Perangkat'], @@ -25,12 +31,20 @@ export default function DeviceForm({ device, vendors, types, models }: Props) { const protocolProfiles: Record> = { mikrotik: [['routeros_api', 'Port RouterOS API', 8728]], zte: [['ssh', 'Port SSH', 22]], - hsgq: [['ssh', 'Port SSH', 22], ['telnet', 'Port Telnet', 23]], - hisfocus: [['telnet', 'Port Telnet', 23], ['web_http', 'Port WebGUI HTTP', 80]], + hsgq: [ + ['ssh', 'Port SSH', 22], + ['telnet', 'Port Telnet', 23], + ], + hisfocus: [ + ['telnet', 'Port Telnet', 23], + ['web_http', 'Port WebGUI HTTP', 80], + ], }; const flexibleProtocols: Array<[string, string, number]> = [ - ['ssh', 'Port SSH', 22], ['telnet', 'Port Telnet', 23], - ['web_http', 'Port WebGUI HTTP', 80], ['snmp', 'Port SNMP', 161], + ['ssh', 'Port SSH', 22], + ['telnet', 'Port Telnet', 23], + ['web_http', 'Port WebGUI HTTP', 80], + ['snmp', 'Port SNMP', 161], ['vendor_api', 'Port Vendor API', 0], ]; const protocols = protocolProfiles[vendorSlug] ?? flexibleProtocols; @@ -84,7 +98,11 @@ export default function DeviceForm({ device, vendors, types, models }: Props) { )} required onChange={(event) => { - const selected = vendors.find((vendor) => vendor.id === Number(event.target.value)); + const selected = vendors.find( + (vendor) => + vendor.id === + Number(event.target.value), + ); setVendorSlug(selected?.slug ?? ''); }} className="h-10 rounded-md border bg-background px-3" @@ -139,24 +157,57 @@ export default function DeviceForm({ device, vendors, types, models }: Props) {
-

Koneksi Management

-

Kosongkan port jika protokol tersebut tidak tersedia pada perangkat.

- +

+ Koneksi Management +

+

+ Kosongkan port jika protokol tersebut + tidak tersedia pada perangkat. +

+
- {protocols.map(([protocol, label, defaultPort]) => ( -
- - - -
- ))} + {protocols.map( + ([ + protocol, + label, + defaultPort, + ]) => ( +
+ + + +
+ ), + )}
@@ -173,26 +224,76 @@ export default function DeviceForm({ device, vendors, types, models }: Props) { <>
- - + +
- -

Boleh kosong untuk perangkat MikroTik yang masih memakai password kosong.

- + +

+ Boleh kosong untuk perangkat + MikroTik yang masih memakai + password kosong. +

+
- - -

Khusus perangkat CLI seperti ZTE C300/C320. Default pabrik tertentu adalah zxr10; isi sesuai kondisi perangkat.

- + + +

+ Khusus perangkat CLI seperti ZTE + C300/C320. Default pabrik + tertentu adalah zxr10; isi + sesuai kondisi perangkat. +

+
diff --git a/resources/js/pages/devices/index.tsx b/resources/js/pages/devices/index.tsx index 0e727f9..3df85c0 100644 --- a/resources/js/pages/devices/index.tsx +++ b/resources/js/pages/devices/index.tsx @@ -166,7 +166,7 @@ export default function DeviceIndex({
- +
@@ -202,10 +202,25 @@ export default function DeviceIndex({ 'Model belum ditentukan'} -
-
{device.management_address}
+
+
+ {device.management_address} +
- {Object.entries(device.connection_ports ?? { [device.connection_type]: device.management_port }).map(([protocol, port]) => `${protocol}:${port}`).join(' · ')} + {Object.entries( + device.connection_ports ?? { + [device.connection_type]: + device.management_port, + }, + ) + .map( + ([ + protocol, + port, + ]) => + `${protocol}:${port}`, + ) + .join(' · ')}
diff --git a/resources/js/pages/devices/settings.tsx b/resources/js/pages/devices/settings.tsx index 1645f2f..184a9be 100644 --- a/resources/js/pages/devices/settings.tsx +++ b/resources/js/pages/devices/settings.tsx @@ -48,11 +48,14 @@ export default function DeviceSettings({ const [revealing, setRevealing] = useState(false); const hideTimer = useRef | null>(null); - useEffect(() => () => { - if (hideTimer.current) { -clearTimeout(hideTimer.current); -} - }, []); + useEffect( + () => () => { + if (hideTimer.current) { + clearTimeout(hideTimer.current); + } + }, + [], + ); const revealBasePassword = async () => { setRevealing(true); @@ -60,29 +63,47 @@ clearTimeout(hideTimer.current); setRevealedPassword(''); try { - const csrf = document.querySelector('meta[name="csrf-token"]')?.content ?? ''; + const csrf = + document.querySelector( + 'meta[name="csrf-token"]', + )?.content ?? ''; const response = await fetch('/device-settings/reveal-password', { method: 'POST', credentials: 'same-origin', - headers: { 'Content-Type': 'application/json', Accept: 'application/json', 'X-CSRF-TOKEN': csrf }, + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + 'X-CSRF-TOKEN': csrf, + }, body: JSON.stringify({ current_password: loginPassword }), }); const result = await response.json(); if (!response.ok) { - throw new Error(result.errors?.current_password?.[0] ?? result.message ?? 'Password tidak dapat ditampilkan.'); + throw new Error( + result.errors?.current_password?.[0] ?? + result.message ?? + 'Password tidak dapat ditampilkan.', + ); } setRevealedPassword(result.password); setLoginPassword(''); if (hideTimer.current) { -clearTimeout(hideTimer.current); -} + clearTimeout(hideTimer.current); + } - hideTimer.current = setTimeout(() => setRevealedPassword(''), 30000); + hideTimer.current = setTimeout( + () => setRevealedPassword(''), + 30000, + ); } catch (error) { - setRevealError(error instanceof Error ? error.message : 'Password tidak dapat ditampilkan.'); + setRevealError( + error instanceof Error + ? error.message + : 'Password tidak dapat ditampilkan.', + ); } finally { setRevealing(false); } @@ -106,9 +127,7 @@ clearTimeout(hideTimer.current); {({ processing, errors }) => ( - - Template Base User Tenant - + Template Base User Tenant Password disimpan terenkripsi dan tidak pernah ditampilkan kembali. @@ -200,31 +219,58 @@ clearTimeout(hideTimer.current);
Group yang dibuat:{' '} RADIQ-READ, RADIQ-WRITE, dan RADIQ-NOC tanpa - permission policy, serta - group bawaan full untuk akun provisioning + permission policy, serta group + bawaan full untuk akun provisioning terpusat.
{hasPassword && (
Lihat Base Password -

Masukkan password login Tenant Admin. Password perangkat akan disembunyikan otomatis setelah 30 detik.

+

+ Masukkan password login Tenant + Admin. Password perangkat akan + disembunyikan otomatis setelah + 30 detik. +

setLoginPassword(event.target.value)} + onChange={(event) => + setLoginPassword( + event.target.value, + ) + } placeholder="Password login Tenant Admin" autoComplete="current-password" /> -
- {revealError &&

{revealError}

} + {revealError && ( +

+ {revealError} +

+ )} {revealedPassword && ( - + )}
)} @@ -241,21 +287,36 @@ clearTimeout(hideTimer.current); Status Rotasi Base User - Perangkat offline akan dicoba kembali oleh scheduler. - Vendor tanpa driver ditandai unsupported. + Perangkat offline akan dicoba kembali oleh + scheduler. Vendor tanpa driver ditandai unsupported. {devices.map((device) => ( -
+
-
{device.name}
-
{device.vendor.name} · {device.base_sync_message ?? 'Belum pernah disinkronkan'}
+
+ {device.name} +
+
+ {device.vendor.name} ·{' '} + {device.base_sync_message ?? + 'Belum pernah disinkronkan'} +
- {device.base_sync_status} + + {device.base_sync_status} +
))} - {devices.length === 0 &&

Belum ada perangkat.

} + {devices.length === 0 && ( +

+ Belum ada perangkat. +

+ )}
`${protocol}: ${port}`).join(' · ')], + [ + 'Koneksi', + Object.entries( + device.connection_ports ?? { + [device.connection_type]: device.management_port, + }, + ) + .map(([protocol, port]) => `${protocol}: ${port}`) + .join(' · '), + ], ['Hostname', device.hostname ?? '-'], ['Serial Number', device.serial_number ?? '-'], ['Versi RouterOS', device.software_version ?? '-'], @@ -89,50 +98,48 @@ export default function DeviceShow({ device, credentials }: Props) { )}
- {['mikrotik', 'zte', 'hsgq', 'hisfocus'].includes(device.vendor.slug) && ( - - - - Aktivasi {device.vendor.name} - - - -
-

- Status:{' '} - - {device.activation_status} - + {['mikrotik', 'zte', 'hsgq', 'hisfocus'].includes( + device.vendor.slug, + ) && ( + + + Aktivasi {device.vendor.name} + + +

+

+ Status:{' '} + + {device.activation_status} + +

+

+ {device.activation_message ?? + 'Aktivasi akan menguji koneksi dan membuat Base User management dengan aman.'} +

+ {errors.activation && ( +

+ {errors.activation}

-

- {device.activation_message ?? - 'Aktivasi akan menguji koneksi dan membuat Base User management dengan aman.'} -

- {errors.activation && ( -

- {errors.activation} -

- )} -
- {auth.permissions.includes( - 'device.connect', - ) && ( - - {({ processing }) => ( - - )} - )} - - - )} +
+ {auth.permissions.includes('device.connect') && ( +
+ {({ processing }) => ( + + )} +
+ )} +
+
+ )} Informasi diff --git a/resources/js/pages/documentation/deployment.tsx b/resources/js/pages/documentation/deployment.tsx new file mode 100644 index 0000000..39b3800 --- /dev/null +++ b/resources/js/pages/documentation/deployment.tsx @@ -0,0 +1,555 @@ +import { Head, Link } from '@inertiajs/react'; +import { + AlertTriangle, + ArrowRight, + Cloud, + Database, + Download, + KeyRound, + Network, + Server, + ShieldCheck, + Terminal, + Users, +} from 'lucide-react'; +import PublicThemeToggle from '@/components/public-theme-toggle'; + +const Code = ({ children }: { children: string }) => ( +
+        {children}
+    
+); + +const Step = ({ + number, + children, +}: { + number: number; + children: React.ReactNode; +}) => ( +
  • + + {number} + +
    {children}
    +
  • +); + +const vendorRows = [ + [ + 'MikroTik RouterOS 6/7', + 'RouterOS API', + '8728 / 8729', + 'Port API wajib diisi. TLS mengikuti pengaturan tenant.', + ], + [ + 'OLT ZTE C300/C320', + 'SSH', + '22', + 'Mendukung login, enable, dan privilege perangkat. Telnet tidak dimatikan oleh RADIQ.', + ], + [ + 'OLT HSGQ', + 'SSH + Telnet', + '22 + 23', + 'Port dapat dibuat custom. Perintah tertentu dijalankan melalui Telnet.', + ], + [ + 'OLT Hisfocus/Hioso', + 'Telnet + WebGUI HTTP', + '23 + 80', + 'Port custom didukung, misalnya Telnet 8183 dan WebGUI 8182.', + ], +]; + +export default function DeploymentDocumentation() { + return ( + <> + +
    +
    +
    + + RADIQ NDM +
    +

    RADIQ NDM

    +

    + Dokumentasi aplikasi +

    +
    + + +
    + +
    +
    +

    + PANDUAN RESMI APLIKASI +

    +

    + Menjalankan RADIQ NDM di Cloud atau server milik + tenant. +

    +

    + RADIQ NDM memusatkan pengelolaan akun akses + perangkat jaringan. Tenant Admin dapat membuat, + mengubah, menonaktifkan, dan menghapus Users + Perangkat pada banyak perangkat tanpa membuka + perangkat satu per satu. +

    +
    + +
    + +
    +

    + 1. Pilih lokasi deployment +

    +
    +
    + +

    + Cloud RADIQ +

    +

    + Tenant langsung menggunakan server yang + dikelola RADIQ. Cocok jika tidak ingin + memelihara PHP, PostgreSQL, web server, + worker, backup aplikasi, dan pembaruan + sistem sendiri. +

    +

    + Saat registrasi pilih{' '} + + Cloud RADIQ + + . Instalasi dibuat dengan status aktif. +

    +
    +
    + +

    + Self-hosted / Local +

    +

    + Aplikasi dan database berjalan di VPS, + server, komputer, atau laptop tenant. Tenant + tetap tercatat dan lisensinya tetap dikelola + Master Admin RADIQ. +

    +

    + Saat registrasi pilih{' '} + + Install Local + + . Instalasi berstatus pending sampai + pemasangan dan lisensi selesai. +

    +
    +
    +
    + +
    +

    + 2. Persyaratan self-hosted +

    +
    + {[ + [ + Terminal, + 'PHP 8.3', + 'Ekstensi Laravel, PDO PostgreSQL, OpenSSL, cURL, Mbstring, XML, dan ZIP.', + ], + [ + Database, + 'PostgreSQL', + 'Database dan user khusus RADIQ NDM dengan akses ke database tersebut.', + ], + [ + Server, + 'Web & build tools', + 'Nginx/Apache/IIS, Composer, Node.js dan NPM.', + ], + [ + Network, + 'Akses jaringan', + 'Server harus dapat menjangkau IP dan port management perangkat.', + ], + ].map(([Icon, title, text]) => { + const ItemIcon = Icon as typeof Server; + + return ( +
    + +

    + {title as string} +

    +

    + {text as string} +

    +
    + ); + })} +
    +
    + + Document root web server wajib menunjuk ke folder{' '} + public, bukan ke root project. +
    +
    + +
    +
    +

    + 3. Instalasi self-hosted +

    + + Unduh paket + +
    +
    +

    + Konfigurasi minimum file .env +

    + {`APP_NAME="RADIQ NDM" +APP_ENV=production +APP_DEBUG=false +APP_URL=https://ndm.domain-tenant.id +DEPLOYMENT_MODE=self_hosted + +DB_CONNECTION=pgsql +DB_HOST=127.0.0.1 +DB_PORT=5432 +DB_DATABASE=radiq_ndm +DB_USERNAME=radiq_ndm +DB_PASSWORD=PASSWORD_DATABASE + +QUEUE_CONNECTION=database +CACHE_STORE=database +SESSION_DRIVER=database +RADIQ_LICENSE_SERVER_URL=https://alamat-control-plane-radiq +RADIQ_LICENSE_PUBLIC_KEY=PUBLIC_KEY_DARI_RADIQ`} +

    + URL server lisensi dan public key diberikan + Master Admin RADIQ. Private signing key tidak + boleh berada pada server tenant. +

    +
    +
    +
    +

    + Windows +

    +
      + + Ekstrak paket dan buka PowerShell pada + folder aplikasi. + + + Jalankan installer dengan PHP 8.3: + {`powershell -ExecutionPolicy Bypass -File scripts/install-self-hosted.ps1 -Php C:\\php83\\php.exe`} + + + Installer membuat .env, + lalu berhenti agar koneksi database + dapat diisi. Ketik LANJUT{' '} + setelah selesai. + + + Isi identitas ISP dan Tenant Admin. + Password minimal 12 karakter dengan + huruf besar/kecil, angka, dan simbol. + +
    +
    +
    +

    Linux

    +
      + + Ekstrak paket dan berikan akses folder + serta storage kepada user + service. + + + Jalankan installer: + {`PHP_BIN=/usr/bin/php8.3 bash scripts/install-self-hosted.sh`} + + + Lengkapi .env, lalu ketik{' '} + LANJUT. Installer + menjalankan Composer, migration, seeder, + pembuatan tenant, build frontend, dan + optimasi. + + + Konfigurasikan Nginx/Apache dengan HTTPS + dan document root ke folder{' '} + public. + +
    +
    +
    +
    + +
    +

    + 4. Proses background yang wajib hidup +

    +

    + CRUD Users Perangkat dikirim ke queue. Jika + perangkat offline, assignment menjadi pending dan + scheduler akan mencoba kembali. Jalankan kedua + proses sebagai Windows Service, Task Scheduler, + systemd, atau Supervisor. +

    + {`# Worker sinkronisasi perangkat +php artisan queue:work --queue=default --tries=1 --timeout=60 + +# Scheduler pekerjaan pending dan tugas berkala +php artisan schedule:work`} +

    + Pada Windows, ganti php dengan{' '} + C:\php83\php.exe. Setelah deployment + jalankan php artisan optimize:clear dan{' '} + php artisan queue:restart. +

    +
    + +
    +

    + 5. Konfigurasi koneksi perangkat +

    +
    + + + + {[ + 'Vendor', + 'Protokol', + 'Port default', + 'Catatan', + ].map((title) => ( + + ))} + + + + {vendorRows.map((row) => ( + + {row.map((cell) => ( + + ))} + + ))} + +
    + {title} +
    + {cell} +
    +
    +
    + Aturan port: form perangkat berubah + mengikuti vendor. Isi hanya port protokol yang + tersedia. Port kosong berarti protokol tersebut + tidak digunakan. Firewall server RADIQ harus dapat + mencapai management network. +
    +
    + +
    +
    + +

    + 6. Cara kerja Users Perangkat +

    +
      + + Tenant Admin mengatur Base User dan Base + Password. + + + Tambahkan perangkat dengan credential awal, + lalu Cek & Aktifkan. + + + Buat Users Perangkat, pilih perangkat tujuan + dan group akses. + + + Perangkat online langsung disinkronkan; + perangkat offline berstatus pending. + +
    +

    + Role aplikasi tetap: Master Admin, Tenant Admin, + dan Tenant User. Tenant Admin hanya mengelola + Tenant User miliknya. +

    +
    +
    + +

    + 7. Credential dan encryption key +

    +
    +

    + Password perangkat dienkripsi dengan tenant + encryption key. Tenant key dibungkus kembali + oleh application key server. +

    +

    + Tenant Admin dapat membuat key otomatis atau + manual. Rotasi key mengenkripsi ulang + credential yang tersimpan. +

    +

    + Melihat Base Password wajib memasukkan + password login Tenant Admin dan dibatasi + rate limit. +

    +
    +
    + Kehilangan APP_KEY atau encryption key tanpa + backup yang benar dapat membuat credential tidak + dapat dipulihkan. +
    +
    +
    + +
    +

    + 8. Backup dan pemeliharaan +

    +
    + {[ + [ + 'Database PostgreSQL', + 'Backup seluruh database termasuk tenant, assignment, queue, dan credential terenkripsi.', + ], + [ + 'File rahasia', + 'Backup .env dan APP_KEY secara terenkripsi, terpisah dari database dan dengan akses terbatas.', + ], + [ + 'Uji pemulihan', + 'Lakukan restore drill pada server terisolasi. Backup yang belum diuji belum dapat dianggap aman.', + ], + ].map(([title, text]) => ( +
    + +

    + {title} +

    +

    + {text} +

    +
    + ))} +
    + {`# Contoh backup PostgreSQL +pg_dump -Fc -U radiq_ndm -d radiq_ndm -f radiq_ndm.backup + +# Maintenance setelah update aplikasi +php artisan migrate --force +php artisan optimize +php artisan queue:restart`} +
    + +
    +

    + 9. Migrasi Cloud ke Local +

    +
    + + Versi saat ini belum menyediakan ekspor–impor tenant + otomatis. Migrasi harus dijadwalkan bersama Master + Admin agar credential terenkripsi dan encryption key + dipindahkan dengan aman. +
    +
      + {[ + 'Tenant Admin mengajukan migrasi dan jadwal maintenance.', + 'Master Admin menyiapkan instalasi self-hosted dan lisensi tenant.', + 'Pasang RADIQ NDM local dengan APP_KEY baru, tetapi jangan aktifkan worker perangkat.', + 'Pindahkan data tenant dan re-enkripsi secret melalui prosedur migrasi; jangan mengekspor password sebagai plaintext.', + 'Verifikasi user, perangkat, Users Perangkat, assignment, port vendor, dan status lisensi.', + 'Hentikan worker Cloud sebelum menyalakan worker Local agar perintah tidak berjalan dua kali.', + 'Uji satu perangkat per vendor, kemudian selesaikan cutover.', + ].map((item, index) => ( + + {item} + + ))} +
    +
    + +
    +

    + Mulai dengan deployment yang sesuai +

    +

    + Cloud cocok untuk penggunaan cepat. Self-hosted + cocok ketika management perangkat hanya dapat + diakses dari jaringan internal tenant. +

    + + Daftar Tenant + +
    +
    +
    + + ); +} diff --git a/resources/js/pages/welcome.tsx b/resources/js/pages/welcome.tsx index cf89be5..4975320 100644 --- a/resources/js/pages/welcome.tsx +++ b/resources/js/pages/welcome.tsx @@ -1,5 +1,6 @@ import { Head, Link, usePage } from '@inertiajs/react'; -import { Cloud, Network, Server, ShieldCheck } from 'lucide-react'; +import { BookOpen, Cloud, Network, Server, ShieldCheck } from 'lucide-react'; +import PublicThemeToggle from '@/components/public-theme-toggle'; import { dashboard, login } from '@/routes'; export default function Welcome() { @@ -30,7 +31,7 @@ export default function Welcome() { return ( <> -
    +
    @@ -43,17 +44,34 @@ export default function Welcome() {
    RADIQ NDM
    -
    +
    Network Device Management
    - - {auth.user ? 'Dashboard' : 'Login'} - +
    @@ -63,12 +81,27 @@ export default function Welcome() {

    Kelola akses perangkat jaringan dengan aman.

    -

    +

    Saat karyawan berpindah tugas atau keluar, akun perangkat dapat dinonaktifkan secara terpusat tanpa membuka setiap router, OLT, switch, atau access point satu per satu.

    +
    + + Mulai & Pilih Deployment + + + Panduan + Instalasi & Migrasi + +
    (

    {feature.title}

    -

    +

    {feature.text}

    ))}
    + +
    ); } + +const plans = [ + { + name: 'Cloud Free', + price: 'Gratis', + deployment: 'Cloud RADIQ', + devices: '5 perangkat', + users: '2 user aplikasi', + featured: false, + }, + { + name: 'Cloud Micro', + price: 'Rp99.000', + deployment: 'Cloud RADIQ', + devices: '25 perangkat', + users: '5 user aplikasi', + featured: false, + }, + { + name: 'Cloud ISP', + price: 'Rp249.000', + deployment: 'Cloud RADIQ', + devices: '100 perangkat', + users: '15 user aplikasi', + featured: true, + }, + { + name: 'Local Micro', + price: 'Rp69.000', + deployment: 'Self-hosted', + devices: '50 perangkat', + users: '5 user aplikasi', + featured: false, + }, + { + name: 'Local ISP', + price: 'Rp149.000', + deployment: 'Self-hosted', + devices: '250 perangkat', + users: '20 user aplikasi', + featured: false, + }, +]; + +function Pricing() { + return ( +
    +
    +

    + HARGA EARLY ACCESS +

    +

    + Lisensi bulanan yang ramah untuk ISP lokal +

    +

    + Tidak ada kontrak tahunan wajib. User perangkat tidak + dihitung sebagai user aplikasi. +

    +
    +
    + {plans.map((plan) => ( +
    + {plan.featured && ( + + Paling sesuai ISP + + )} +

    + {plan.deployment} +

    +

    {plan.name}

    +

    + {plan.price} + {plan.price !== 'Gratis' && ( + + /bulan + + )} +

    +
      +
    • ✓ Hingga {plan.devices}
    • +
    • ✓ Hingga {plan.users}
    • +
    • ✓ Users Perangkat tanpa batas
    • +
    • ✓ Update aplikasi
    • +
    • ✓ Dukungan dasar
    • +
    + + Pilih paket + +
    + ))} +
    +

    + Harga belum termasuk PPN, VPS/server tenant, instalasi khusus, + atau migrasi manual. Harga dapat dievaluasi seiring penambahan + fitur dengan pemberitahuan sebelumnya. +

    +
    + ); +} + +function Roadmap() { + return ( +
    +
    +

    + ROADMAP · BELUM TERMASUK FITUR AKTIF +

    +

    + RADIQ NDM akan tumbuh bersama kebutuhan operator jaringan. +

    +

    + Harga early-access saat ini mencerminkan aplikasi yang masih + sekitar 30% dari visi produk. Tahap berikutnya direncanakan + mencakup monitoring terpusat, topologi perangkat, dashboard + NOC, alert status perangkat, histori performa, backup + konfigurasi, dan dukungan vendor tambahan. +

    +
    +
    + ); +} diff --git a/routes/console.php b/routes/console.php index 5c7ea6d..bdfb024 100644 --- a/routes/console.php +++ b/routes/console.php @@ -1,9 +1,12 @@ comment(Inspiring::quote()); })->purpose('Display an inspiring quote'); +Schedule::call(function (): void { + License::withoutGlobalScope('tenant')->where('status', LicenseStatus::Active->value)->where('expires_at', '<=', now())->get()->each(fn (License $license) => $license->update(['status' => $license->grace_until?->isFuture() ? LicenseStatus::Grace : LicenseStatus::Expired])); + License::withoutGlobalScope('tenant')->where('status', LicenseStatus::Grace->value)->where('grace_until', '<=', now())->update(['status' => LicenseStatus::Expired]); +})->everyMinute()->name('update-license-statuses')->withoutOverlapping(); + Schedule::call(function (): void { DeviceUserAssignment::withoutGlobalScope('tenant')->where('sync_status', 'pending')->orderBy('last_attempted_at')->limit(100)->get()->each(function (DeviceUserAssignment $assignment): void { + if (! app(LicenseEntitlementService::class)->allowsUse($assignment->tenant_id)) { + return; + } if (DeviceUserAssignment::withoutGlobalScope('tenant')->whereKey($assignment->id)->where('sync_status', 'pending')->update(['sync_status' => 'queued'])) { SyncDeviceUser::dispatch($assignment->tenant_id, $assignment->id); } @@ -22,6 +33,9 @@ Schedule::call(function (): void { Schedule::call(function (): void { Device::withoutGlobalScope('tenant')->where('base_sync_status', 'pending')->orderBy('base_sync_attempted_at')->limit(100)->get()->each(function (Device $device): void { + if (! app(LicenseEntitlementService::class)->allowsUse($device->tenant_id)) { + return; + } if (Device::withoutGlobalScope('tenant')->whereKey($device->id)->where('base_sync_status', 'pending')->update(['base_sync_status' => 'queued'])) { SyncDeviceBaseCredential::dispatch($device->tenant_id, $device->id); } diff --git a/routes/web.php b/routes/web.php index 2e5210e..bcdb523 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,13 +1,17 @@ name('home'); +Route::inertia('/documentation/deployment', 'documentation/deployment')->name('documentation.deployment'); +Route::get('/checkout/{order}', [CheckoutController::class, 'show'])->name('checkout.show'); +Route::get('/checkout/{order}/return', [CheckoutController::class, 'returned'])->name('checkout.return'); +Route::post('/checkout/{order}/retry', [CheckoutController::class, 'retry'])->middleware('throttle:5,1')->name('checkout.retry'); +Route::post('/payments/{gateway}/callback', PaymentWebhookController::class)->middleware('throttle:120,1')->name('payments.webhook'); +Route::get('/downloads/self-hosted', function () { + $package = collect(glob(base_path('dist/radiq-ndm-self-hosted-*.zip')))->sortDesc()->first(); + abort_unless($package && is_file($package), 404, 'Paket self-hosted belum tersedia.'); + + return response()->download($package, basename($package)); +})->middleware('throttle:10,1')->name('downloads.self-hosted'); Route::middleware(['auth', 'verified'])->group(function () { Route::get('dashboard', DashboardController::class)->name('dashboard'); + Route::get('billing', [BillingController::class, 'index'])->name('billing.index'); + Route::post('billing/checkout', [BillingController::class, 'store'])->middleware('throttle:5,1')->name('billing.checkout'); Route::resource('devices', DeviceController::class); Route::resource('devices.credentials', DeviceCredentialController::class)->only(['store', 'update', 'destroy']); Route::resource('device-users', DeviceAccessUserController::class)->parameters(['device-users' => 'deviceAccessUser'])->except('show'); @@ -30,6 +47,8 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::post('devices/{device}/activate', DeviceActivationController::class)->middleware('throttle:6,1')->name('devices.activate'); Route::prefix('administration')->name('administration.')->group(function () { + Route::get('billing-orders', [BillingOrderController::class, 'index'])->name('billing-orders.index'); + Route::patch('billing-orders/{order}/approve', [BillingOrderController::class, 'approve'])->name('billing-orders.approve'); Route::resource('tenants', TenantController::class)->except('show'); Route::prefix('tenants/{tenant}')->middleware('admin.tenant')->scopeBindings()->group(function () { Route::resource('users', UserController::class)->except('show'); diff --git a/tests/Feature/Auth/RegistrationTest.php b/tests/Feature/Auth/RegistrationTest.php index 4aa36b8..d4a3bf0 100644 --- a/tests/Feature/Auth/RegistrationTest.php +++ b/tests/Feature/Auth/RegistrationTest.php @@ -2,6 +2,7 @@ namespace Tests\Feature\Auth; +use App\Models\Tenant; use Illuminate\Foundation\Testing\RefreshDatabase; use Laravel\Fortify\Features; use Tests\TestCase; @@ -27,13 +28,24 @@ class RegistrationTest extends TestCase public function test_new_users_can_register() { $response = $this->post(route('register.store'), [ + 'tenant_name' => 'ISP Test Network', 'name' => 'Test User', 'email' => 'test@example.com', - 'password' => 'password', - 'password_confirmation' => 'password', + 'password' => 'Strong!Password123', + 'password_confirmation' => 'Strong!Password123', + 'deployment_type' => 'self_hosted', + 'deployment_domain' => 'ndm.test.example', ]); $this->assertAuthenticated(); $response->assertRedirect(route('dashboard', absolute: false)); + $tenant = Tenant::where('slug', 'isp-test-network')->firstOrFail(); + $this->assertDatabaseHas('users', ['tenant_id' => $tenant->id, 'email' => 'test@example.com']); + $this->assertDatabaseHas('deployment_installations', [ + 'tenant_id' => $tenant->id, + 'deployment_type' => 'self_hosted', + 'domain' => 'ndm.test.example', + 'status' => 'pending', + ]); } }