update lisensi prosess
tests / ci (push) Has been cancelled

This commit is contained in:
Wian Drs
2026-08-25 13:43:55 +07:00
parent 1e80be180e
commit 60946ddf37
58 changed files with 2903 additions and 165 deletions
+5
View File
@@ -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
+42 -4
View File
@@ -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<string, string> $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();
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Billing\Contracts;
use App\Models\BillingOrder;
interface PaymentGateway
{
/** @return array<int,array{code:string,name:string,fee:int}> */
public function paymentMethods(BillingOrder $order): array;
/** @return array{external_id:?string,checkout_url:?string,metadata:array<string,mixed>} */
public function createCheckout(BillingOrder $order): array;
/** @param array<string,mixed> $payload */
public function verifyWebhook(array $payload, array $headers = []): bool;
/** @param array<string,mixed> $payload */
public function statusFromWebhook(array $payload): string;
/** @param array<string,mixed> $payload */
public function externalIdFromWebhook(array $payload): ?string;
}
@@ -0,0 +1,112 @@
<?php
namespace App\Billing\Gateways;
use App\Billing\Contracts\PaymentGateway;
use App\Models\BillingOrder;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class DuitkuPaymentGateway implements PaymentGateway
{
public function paymentMethods(BillingOrder $order): array
{
[$merchant, $key] = $this->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;
}
}
@@ -0,0 +1,34 @@
<?php
namespace App\Billing\Gateways;
use App\Billing\Contracts\PaymentGateway;
use App\Models\BillingOrder;
class ManualPaymentGateway implements PaymentGateway
{
public function paymentMethods(BillingOrder $order): array
{
return [];
}
public function createCheckout(BillingOrder $order): array
{
return ['external_id' => '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;
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Billing;
use App\Billing\Contracts\PaymentGateway;
use InvalidArgumentException;
class PaymentGatewayManager
{
public function driver(?string $name = null): PaymentGateway
{
$name ??= config('billing.default_gateway');
$class = config("billing.gateways.{$name}");
if (! $class || ! is_a($class, PaymentGateway::class, true)) {
throw new InvalidArgumentException("Payment gateway [{$name}] tidak tersedia.");
}
return app($class);
}
}
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers\Administration;
use App\Http\Controllers\Controller;
use App\Models\BillingOrder;
use App\Services\BillingService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class BillingOrderController extends Controller
{
public function index(Request $request): Response
{
abort_unless($request->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.');
}
}
@@ -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.');
}
@@ -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.');
}
@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers;
use App\Enums\SystemRole;
use App\Models\BillingOrder;
use App\Models\DeploymentInstallation;
use App\Services\LicenseEntitlementService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;
use Inertia\Response;
class BillingController extends Controller
{
public function index(Request $request, LicenseEntitlementService $entitlements): Response
{
abort_unless($request->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);
}
}
@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers;
use App\Billing\PaymentGatewayManager;
use App\Models\BillingOrder;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
use Throwable;
class CheckoutController extends Controller
{
public function show(string $order): Response
{
return $this->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,
]]);
}
}
@@ -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(),
@@ -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'];
@@ -0,0 +1,30 @@
<?php
namespace App\Http\Controllers;
use App\Billing\PaymentGatewayManager;
use App\Models\BillingOrder;
use App\Services\BillingService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PaymentWebhookController extends Controller
{
public function __invoke(string $gateway, Request $request, PaymentGatewayManager $gateways, BillingService $billing): JsonResponse
{
$driver = $gateways->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]);
}
}
@@ -0,0 +1,33 @@
<?php
namespace App\Http\Middleware;
use App\Enums\LicenseStatus;
use App\Models\License;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class EnsureTenantLicense
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->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);
}
}
@@ -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'],
];
}
}
@@ -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'],
];
}
}
@@ -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'],
];
}
}
@@ -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'],
];
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Http\Responses;
use App\Models\BillingOrder;
use Illuminate\Support\Facades\Auth;
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
class RegisterResponse implements RegisterResponseContract
{
public function toResponse($request)
{
$user = $request->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'));
}
}
+6
View File
@@ -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) {
+6
View File
@@ -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;
+30
View File
@@ -0,0 +1,30 @@
<?php
namespace App\Models;
use App\Models\Concerns\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class BillingOrder extends Model
{
use BelongsToTenant;
protected $fillable = ['tenant_id', 'license_id', 'plan_code', 'gateway', 'payment_method', 'external_id', 'status', 'subtotal', 'tax', 'total', 'currency', 'checkout_url', 'metadata', 'expires_at', 'paid_at'];
protected static function booted(): void
{
static::creating(fn (self $order) => $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);
}
}
+12 -2
View File
@@ -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'),
+39
View File
@@ -0,0 +1,39 @@
<?php
namespace App\Services;
use App\Enums\LicenseStatus;
use App\Models\BillingOrder;
use App\Models\DeploymentInstallation;
use App\Models\License;
use Illuminate\Support\Facades\DB;
class BillingService
{
public function activate(BillingOrder $order, ?int $issuerId = null): License
{
return DB::transaction(function () use ($order, $issuerId): License {
$order = BillingOrder::withoutGlobalScope('tenant')->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;
});
}
}
@@ -0,0 +1,77 @@
<?php
namespace App\Services;
use App\Enums\LicenseStatus;
use App\Models\DeploymentInstallation;
use App\Models\Device;
use App\Models\License;
use App\Models\Tenant;
use App\Models\User;
class LicenseEntitlementService
{
public function allowsUse(int $tenantId): bool
{
$license = $this->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.');
}
}
+10 -2
View File
@@ -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();
+3
View File
@@ -1,6 +1,7 @@
<?php
use App\Http\Middleware\EnsureActiveUser;
use App\Http\Middleware\EnsureTenantLicense;
use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests;
use App\Http\Middleware\SelectAdministrationTenant;
@@ -19,11 +20,13 @@ return Application::configure(basePath: dirname(__DIR__))
health: '/up',
)
->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,
+25
View File
@@ -0,0 +1,25 @@
<?php
use App\Billing\Gateways\DuitkuPaymentGateway;
use App\Billing\Gateways\ManualPaymentGateway;
return [
'default_gateway' => 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],
],
];
+1 -1
View File
@@ -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([
@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('billing_orders', function (Blueprint $table): void {
$table->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');
}
};
@@ -0,0 +1,23 @@
<?php
use App\Models\DeploymentInstallation;
use App\Models\Tenant;
use App\Services\LicenseEntitlementService;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
Tenant::query()->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 {}
};
@@ -0,0 +1,28 @@
<?php
use App\Models\DeploymentInstallation;
use App\Models\Tenant;
use App\Services\LicenseEntitlementService;
use Illuminate\Database\Migrations\Migration;
return new class extends Migration
{
public function up(): void
{
Tenant::query()->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 {}
};
@@ -0,0 +1,22 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('billing_orders', function (Blueprint $table): void {
$table->string('payment_method', 20)->nullable()->after('gateway');
});
}
public function down(): void
{
Schema::table('billing_orders', function (Blueprint $table): void {
$table->dropColumn('payment_method');
});
}
};
+20
View File
@@ -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;
}
}
+3 -1
View File
@@ -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;
+31 -5
View File
@@ -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',
@@ -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 (
<button
type="button"
onClick={() => updateAppearance(isDark ? 'light' : 'dark')}
className="inline-flex size-10 items-center justify-center rounded-lg border border-border bg-background text-foreground transition hover:bg-muted"
aria-label={isDark ? 'Gunakan mode terang' : 'Gunakan mode gelap'}
title={isDark ? 'Mode terang' : 'Mode gelap'}
>
{isDark ? <Sun className="size-4" /> : <Moon className="size-4" />}
</button>
);
}
@@ -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 (
<>
<Head title="Order Lisensi" />
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6">
<div>
<h1 className="text-2xl font-semibold">Order Lisensi</h1>
<p className="text-sm text-muted-foreground">
Verifikasi pembayaran manual dan pantau checkout seluruh
tenant.
</p>
</div>
<div className="overflow-x-auto rounded-xl border">
<table className="w-full min-w-[760px] text-sm">
<thead className="bg-muted/50 text-left">
<tr>
{[
'Tenant',
'Paket',
'Gateway',
'Total',
'Status',
'Dibuat',
'Aksi',
].map((item) => (
<th key={item} className="px-4 py-3">
{item}
</th>
))}
</tr>
</thead>
<tbody>
{orders.data.map((order) => (
<tr key={order.uuid} className="border-t">
<td className="px-4 py-3 font-medium">
{order.tenant.name}
</td>
<td className="px-4 py-3">
{order.plan_code}
</td>
<td className="px-4 py-3">
{order.gateway}
</td>
<td className="px-4 py-3">
{new Intl.NumberFormat('id-ID', {
style: 'currency',
currency: order.currency,
maximumFractionDigits: 0,
}).format(order.total)}
</td>
<td className="px-4 py-3">
{order.status}
</td>
<td className="px-4 py-3">
{new Date(
order.created_at,
).toLocaleString('id-ID')}
</td>
<td className="px-4 py-3">
{order.status === 'pending' && (
<Form
action={`/administration/billing-orders/${order.id}/approve`}
method="patch"
>
<Button size="sm">
Konfirmasi Bayar
</Button>
</Form>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</>
);
}
BillingOrders.layout = {
breadcrumbs: [
{ title: 'Order Lisensi', href: '/administration/billing-orders' },
],
};
@@ -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 (
<>
<Head title="Tambah Tenant" />
@@ -158,6 +165,34 @@ export default function TenantCreate() {
message={errors.deployment_domain}
/>
</div>
<div className="grid gap-2 md:col-span-2">
<Label>Paket Lisensi</Label>
<select
name="plan_code"
defaultValue="cloud_free"
className="h-10 rounded-md border bg-background px-3"
>
{Object.entries(plans).map(
([code, plan]) => (
<option
key={code}
value={code}
>
{plan.name} ·{' '}
{plan.price === 0
? 'Gratis'
: `Rp${plan.price.toLocaleString('id-ID')}/bulan`}{' '}
· {plan.max_devices}{' '}
perangkat ·{' '}
{plan.max_users} user
</option>
),
)}
</select>
<InputError
message={errors.plan_code}
/>
</div>
</CardContent>
</Card>
<div className="flex justify-end gap-3">
@@ -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({
}
/>
</div>
<div className="grid gap-2 md:col-span-2">
<Label>Paket Lisensi Aktif</Label>
<select
name="plan_code"
defaultValue={
license?.plan ?? 'cloud_free'
}
className="h-10 rounded-md border bg-background px-3"
>
{Object.entries(plans).map(
([code, plan]) => (
<option key={code} value={code}>
{plan.name} ·{' '}
{plan.price === 0
? 'Gratis'
: `Rp${plan.price.toLocaleString('id-ID')}/bulan`}{' '}
· {plan.max_devices}{' '}
perangkat · {plan.max_users}{' '}
user
</option>
),
)}
</select>
<p className="text-xs text-muted-foreground">
Status: {license?.status ?? 'belum ada'}
{license?.expires_at
? ` · sampai ${new Date(license.expires_at).toLocaleDateString('id-ID')}`
: ''}
</p>
<InputError message={errors.plan_code} />
</div>
<div className="flex gap-3 md:col-span-2">
<Button disabled={processing}>
Simpan Perubahan
@@ -17,6 +17,7 @@ type Tenant = {
domain?: string;
status: string;
}[];
licenses: { plan: string; status: string; expires_at: string }[];
};
type PageLink = { url: string | null; label: string; active: boolean };
type Props = { tenants: { data: Tenant[]; links: PageLink[]; total: number } };
@@ -62,6 +63,7 @@ export default function TenantIndex({ tenants }: Props) {
<th className="py-3">Tenant</th>
<th>Status</th>
<th>Instalasi</th>
<th>Paket</th>
<th>Users</th>
<th>Dibuat</th>
<th className="text-right">Aksi</th>
@@ -94,6 +96,16 @@ export default function TenantIndex({ tenants }: Props) {
: 'Nonaktif'}
</Badge>
</td>
<td>
<div className="font-medium">
{tenant.licenses[0]?.plan ??
'-'}
</div>
<div className="text-xs text-muted-foreground">
{tenant.licenses[0]?.status ??
'belum ada'}
</div>
</td>
<td>
<div className="font-medium">
{tenant.installations[0]
@@ -139,7 +151,7 @@ export default function TenantIndex({ tenants }: Props) {
{tenants.data.length === 0 && (
<tr>
<td
colSpan={6}
colSpan={7}
className="py-12 text-center text-muted-foreground"
>
Belum ada tenant. Buat tenant
@@ -8,8 +8,9 @@ import { Label } from '@/components/ui/label';
type Props = {
tenant: { id: number; name: string };
canManageRoles: boolean;
};
export default function UserCreate({ tenant }: Props) {
export default function UserCreate({ tenant, canManageRoles }: Props) {
const base = `/administration/tenants/${tenant.id}/users`;
return (
@@ -57,10 +58,28 @@ export default function UserCreate({ tenant }: Props) {
required
/>
</div>
<div className="rounded-lg border bg-muted/40 px-4 py-3 text-sm md:col-span-2">
Akun baru otomatis menggunakan role baku{' '}
<strong>Tenant User</strong>.
</div>
{canManageRoles ? (
<div className="grid gap-2 md:col-span-2">
<Label>Role</Label>
<select
name="role"
defaultValue="TENANT USER"
className="h-10 rounded-md border bg-background px-3"
>
<option value="TENANT ADMIN">
Tenant Admin
</option>
<option value="TENANT USER">
Tenant User
</option>
</select>
</div>
) : (
<div className="rounded-lg border bg-muted/40 px-4 py-3 text-sm md:col-span-2">
Akun baru otomatis menggunakan role baku{' '}
<strong>Tenant User</strong>.
</div>
)}
<div className="flex justify-end gap-3 md:col-span-2">
<Button variant="outline" asChild>
<Link href={base}>Batal</Link>
@@ -16,8 +16,13 @@ type Role = { id: number; name: string };
type Props = {
tenant: { id: number; name: string };
managedUser: { id: number; name: string; email: string; roles: Role[] };
canManageRoles: boolean;
};
export default function UserEdit({ tenant, managedUser }: Props) {
export default function UserEdit({
tenant,
managedUser,
canManageRoles,
}: Props) {
const base = `/administration/tenants/${tenant.id}/users`;
const { flash } = usePage().props as unknown as {
flash: { success?: string };
@@ -65,14 +70,36 @@ export default function UserEdit({ tenant, managedUser }: Props) {
/>
<InputError message={errors.email} />
</div>
<div className="rounded-lg border bg-muted/40 px-4 py-3 text-sm md:col-span-2">
Role sistem:{' '}
<strong>
{managedUser.roles[0]?.name ??
'Tenant User'}
</strong>
. Role bersifat baku dan tidak dapat diubah.
</div>
{canManageRoles ? (
<div className="grid gap-2 md:col-span-2">
<Label>Role</Label>
<select
name="role"
defaultValue={
managedUser.roles[0]?.name ??
'TENANT USER'
}
className="h-10 rounded-md border bg-background px-3"
>
<option value="TENANT ADMIN">
Tenant Admin
</option>
<option value="TENANT USER">
Tenant User
</option>
</select>
</div>
) : (
<div className="rounded-lg border bg-muted/40 px-4 py-3 text-sm md:col-span-2">
Role sistem:{' '}
<strong>
{managedUser.roles[0]?.name ??
'Tenant User'}
</strong>
. Role bersifat baku dan tidak dapat
diubah.
</div>
)}
<div className="flex justify-end gap-3 md:col-span-2">
<Button variant="outline" asChild>
<Link href={base}>Kembali</Link>
+16 -2
View File
@@ -14,9 +14,14 @@ import { request } from '@/routes/password';
type Props = {
status?: string;
canResetPassword: boolean;
canRegister: boolean;
};
export default function Login({ status, canResetPassword }: Props) {
export default function Login({
status,
canResetPassword,
canRegister,
}: Props) {
return (
<>
<Head title="Log in" />
@@ -92,7 +97,16 @@ export default function Login({ status, canResetPassword }: Props) {
</div>
<p className="text-center text-sm text-muted-foreground">
Contact your administrator to request access.
{canRegister ? (
<>
Belum memiliki tenant?{' '}
<TextLink href="/register">
Daftar RADIQ NDM
</TextLink>
</>
) : (
'Hubungi administrator untuk meminta akses.'
)}
</p>
</>
)}
+205
View File
@@ -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<Deployment>('managed_cloud');
const [plan, setPlan] = useState('cloud_free');
return (
<>
<Head title="Daftar Tenant" />
<Form
action="/register"
method="post"
className="flex flex-col gap-5"
>
{({ processing, errors }) => (
<>
<div className="grid gap-2">
<Label htmlFor="tenant_name">
Nama ISP / Perusahaan
</Label>
<Input
id="tenant_name"
name="tenant_name"
required
autoFocus
placeholder="Contoh: ISP Mekarmanah"
/>
<InputError message={errors.tenant_name} />
</div>
<div className="grid grid-cols-2 gap-3">
{(
[
[
'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]) => (
<button
key={value}
type="button"
onClick={() => {
setDeployment(value);
setPlan(
value === 'managed_cloud'
? 'cloud_free'
: 'local_micro',
);
}}
className={`rounded-xl border p-3 text-left transition ${deployment === value ? 'border-primary bg-primary/5 ring-1 ring-primary' : 'hover:bg-muted'}`}
>
<Icon className="mb-2 size-5 text-primary" />
<span className="block text-sm font-semibold">
{title}
</span>
<span className="mt-1 block text-xs text-muted-foreground">
{description}
</span>
</button>
))}
<input
type="hidden"
name="deployment_type"
value={deployment}
/>
<InputError message={errors.deployment_type} />
</div>
<div className="grid gap-2">
<Label htmlFor="plan_code">Paket lisensi</Label>
<select
id="plan_code"
name="plan_code"
value={plan}
onChange={(event) =>
setPlan(event.target.value)
}
className="h-10 rounded-md border bg-background px-3"
>
{deployment === 'managed_cloud' ? (
<>
<option value="cloud_free">
Cloud Free · Gratis · 5 perangkat ·
2 user
</option>
<option value="cloud_micro">
Cloud Micro · Rp99.000/bulan
</option>
<option value="cloud_isp">
Cloud ISP · Rp249.000/bulan
</option>
</>
) : (
<>
<option value="local_micro">
Local Micro · Rp69.000/bulan
</option>
<option value="local_isp">
Local ISP · Rp149.000/bulan
</option>
</>
)}
</select>
<InputError message={errors.plan_code} />
</div>
{deployment === 'self_hosted' && (
<div className="grid gap-2">
<Label htmlFor="deployment_domain">
Domain / alamat server (opsional)
</Label>
<Input
id="deployment_domain"
name="deployment_domain"
placeholder="ndm.isp-anda.co.id"
/>
<InputError
message={errors.deployment_domain}
/>
</div>
)}
<div className="grid gap-2">
<Label htmlFor="name">Nama Tenant Admin</Label>
<Input
id="name"
name="name"
required
autoComplete="name"
/>
<InputError message={errors.name} />
</div>
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
name="email"
type="email"
required
autoComplete="email"
/>
<InputError message={errors.email} />
</div>
<div className="grid gap-2">
<Label htmlFor="password">Password</Label>
<PasswordInput
id="password"
name="password"
required
autoComplete="new-password"
/>
<p className="text-xs text-muted-foreground">
{passwordRules}
</p>
<InputError message={errors.password} />
</div>
<div className="grid gap-2">
<Label htmlFor="password_confirmation">
Konfirmasi Password
</Label>
<PasswordInput
id="password_confirmation"
name="password_confirmation"
required
autoComplete="new-password"
/>
</div>
<Button disabled={processing}>
{processing && <Spinner />} Buat Tenant
</Button>
<p className="text-center text-sm text-muted-foreground">
Sudah terdaftar?{' '}
<Link
href="/login"
className="font-medium text-primary hover:underline"
>
Login
</Link>
</p>
</>
)}
</Form>
</>
);
}
Register.layout = {
title: 'Daftarkan Tenant RADIQ NDM',
description: 'Pilih Cloud RADIQ atau instalasi di server milik Anda.',
};
+247
View File
@@ -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<Order, 'uuid' | 'plan_code' | 'total' | 'currency'> | null;
}) {
return (
<>
<Head title="Lisensi & Tagihan" />
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6">
<div>
<p className="text-sm font-medium text-primary">
RADIQ NDM · BILLING
</p>
<h1 className="mt-1 text-2xl font-semibold">
Lisensi & Tagihan
</h1>
<p className="mt-1 text-sm text-muted-foreground">
Pilih paket, checkout, dan pantau masa berlaku lisensi
tenant.
</p>
</div>
{pendingOrder && (
<Card className="border-amber-400">
<CardContent className="flex flex-col justify-between gap-3 pt-6 md:flex-row md:items-center">
<div>
<p className="font-semibold">Pembayaran masih pending</p>
<p className="text-sm text-muted-foreground">{pendingOrder.plan_code} · {money(pendingOrder.total)}. Selesaikan order ini sebelum membuat perpanjangan baru.</p>
</div>
<Button asChild><Link href={`/checkout/${pendingOrder.uuid}`}>Lanjutkan Pembayaran</Link></Button>
</CardContent>
</Card>
)}
<div className="grid gap-4 md:grid-cols-3">
<Card>
<CardHeader>
<CardTitle className="text-sm">
Paket aktif
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-semibold">
{license?.plan ?? 'Belum aktif'}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Status {license?.status ?? installation.status}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">
Batas perangkat
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-semibold">
{license?.max_devices ?? '—'}
</p>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-sm">
Berlaku sampai
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-xl font-semibold">
{license?.plan === 'cloud_free'
? 'Selama paket tersedia'
: license?.expires_at
? new Date(
license.expires_at,
).toLocaleDateString('id-ID')
: '—'}
</p>
</CardContent>
</Card>
</div>
<section>
<h2 className="text-lg font-semibold">
Pilih paket bulanan
</h2>
<div className="mt-4 grid gap-4 md:grid-cols-2 xl:grid-cols-3">
{plans.map((plan) => (
<Card
key={plan.code}
className={
license?.plan === plan.code
? 'border-primary'
: ''
}
>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>{plan.name}</CardTitle>
{license?.plan === plan.code && (
<CheckCircle2 className="size-5 text-emerald-500" />
)}
</div>
</CardHeader>
<CardContent>
<p className="text-2xl font-bold">
{plan.price === 0
? 'Gratis'
: money(plan.price)}
<span className="text-sm font-normal text-muted-foreground">
{plan.price > 0 && '/bulan'}
</span>
</p>
<ul className="mt-4 space-y-2 text-sm text-muted-foreground">
<li>✓ {plan.max_devices} perangkat</li>
<li>
✓ {plan.max_users} user aplikasi
</li>
<li>✓ Users Perangkat tanpa batas</li>
</ul>
{plan.price > 0 && (
<Form
action="/billing/checkout"
method="post"
className="mt-5"
>
<input
type="hidden"
name="plan_code"
value={plan.code}
/>
<input
type="hidden"
name="gateway"
value={gateways[0] ?? 'manual'}
/>
<Button className="w-full" disabled={Boolean(pendingOrder)}>
<CreditCard className="size-4" />{' '}
Checkout / Perpanjang
</Button>
</Form>
)}
</CardContent>
</Card>
))}
</div>
</section>
<Card>
<CardHeader>
<CardTitle>Riwayat checkout</CardTitle>
</CardHeader>
<CardContent>
{orders.length === 0 ? (
<p className="text-sm text-muted-foreground">
Belum ada checkout.
</p>
) : (
<div className="space-y-3">
{orders.map((order) => (
<div
key={order.uuid}
className="flex flex-col justify-between gap-2 rounded-lg border p-3 text-sm md:flex-row md:items-center"
>
<div>
<p className="font-medium">
{order.plan_code} ·{' '}
{money(order.total)}
</p>
<p className="text-xs text-muted-foreground">
{new Date(
order.created_at,
).toLocaleString('id-ID')}{' '}
· {order.gateway}
</p>
</div>
<span className="inline-flex items-center gap-2">
<Clock3 className="size-4" />
{order.status}
</span>
{order.status === 'pending' && (
<Button asChild size="sm" variant="outline"><Link href={`/checkout/${order.uuid}`}>Lanjutkan</Link></Button>
)}
</div>
))}
</div>
)}
</CardContent>
</Card>
<div className="rounded-xl border bg-muted/40 p-4 text-sm text-muted-foreground">
<Server className="mr-2 inline size-4" />
Checkout berada di Tenant Admin. Payment gateway dapat
diganti tanpa mengubah data order atau proses lisensi.
</div>
</div>
</>
);
}
BillingIndex.layout = {
breadcrumbs: [{ title: 'Lisensi & Tagihan', href: '/billing' }],
};
+146
View File
@@ -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 (
<>
<Head title="Checkout Lisensi" />
<main className="min-h-screen bg-background px-5 py-8 text-foreground">
<div className="mx-auto max-w-xl">
<header className="mb-10 flex items-center justify-between">
<Link href="/" className="flex items-center gap-3">
<img
src="/images/radiq-ndm-logo.png"
alt="RADIQ NDM"
className="size-12 object-contain"
/>
<span className="font-semibold">RADIQ NDM</span>
</Link>
<PublicThemeToggle />
</header>
<div className="rounded-2xl border bg-card p-6 shadow-sm">
<div className="flex items-center gap-3">
{paid ? (
<CheckCircle2 className="size-7 text-emerald-500" />
) : (
<CreditCard className="size-7 text-sky-500" />
)}
<div>
<p className="text-sm text-muted-foreground">
Checkout lisensi bulanan
</p>
<h1 className="text-2xl font-bold">
{order.plan}
</h1>
</div>
</div>
<div className="mt-6 rounded-xl bg-muted/50 p-4">
<div className="flex justify-between">
<span>Total pembayaran</span>
<strong>{total}</strong>
</div>
<div className="mt-2 flex justify-between text-sm text-muted-foreground">
<span>Gateway</span>
<span>{order.gateway}</span>
</div>
<div className="mt-2 flex justify-between text-sm text-muted-foreground">
<span>Status</span>
<span>{order.status}</span>
</div>
</div>
{paid ? (
<div className="mt-6">
<p className="text-sm text-emerald-600">
Pembayaran diterima dan lisensi telah
diaktifkan.
</p>
<Link
href="/login"
className="mt-4 block rounded-lg bg-sky-500 px-4 py-3 text-center font-semibold text-slate-950"
>
Login Tenant Admin
</Link>
</div>
) : order.checkout_url ? (
<div className="mt-6">
<a
href={order.checkout_url}
className="block rounded-lg bg-sky-500 px-4 py-3 text-center font-semibold text-slate-950"
>
Lanjut Bayar dengan Duitku
</a>
<p className="mt-3 text-center text-xs text-muted-foreground">
<Clock3 className="mr-1 inline size-3" />
Status aktif setelah callback pembayaran
terverifikasi.
</p>
</div>
) : (
<div className="mt-6 rounded-lg border p-4 text-sm">
{order.gateway_error ? (
<p className="text-destructive">
<TriangleAlert className="mr-2 inline size-4" />
{order.gateway_error}
</p>
) : (
<p className="text-muted-foreground">
Pilih kanal pembayaran Duitku. Order ini tetap dapat dilanjutkan tanpa membuat order baru.
</p>
)}
{errors.payment && (
<p className="mt-2 text-destructive">{String(errors.payment)}</p>
)}
<Form
action={`/checkout/${order.uuid}/retry`}
method="post"
className="mt-3"
>
{order.payment_methods.length > 0 && (
<select
name="payment_method"
defaultValue={order.payment_method ?? ''}
required
className="mb-3 w-full rounded-md border bg-background px-3 py-2"
>
<option value="" disabled>Pilih kanal pembayaran</option>
{order.payment_methods.map((method) => (
<option key={method.code} value={method.code}>
{method.name}{method.fee > 0 ? ` (biaya ${new Intl.NumberFormat('id-ID').format(method.fee)})` : ''}
</option>
))}
</select>
)}
<button disabled={order.payment_methods.length === 0} className="w-full rounded-md bg-sky-500 px-3 py-2 font-semibold text-slate-950 disabled:opacity-50">
Buat dan Lanjutkan Pembayaran
</button>
</Form>
</div>
)}
</div>
</div>
</main>
</>
);
}
+56 -1
View File
@@ -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<string, number | boolean>;
};
@@ -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.')}
</CardContent>
</Card>
{!isMaster && installation && (
<Card>
<CardHeader>
<CardTitle>Deployment Tenant</CardTitle>
</CardHeader>
<CardContent className="flex flex-col justify-between gap-4 text-sm md:flex-row md:items-center">
<div>
<p className="font-medium">
{installation.deployment_type ===
'managed_cloud'
? 'Cloud RADIQ'
: 'Self-hosted / Local'}
</p>
<p className="mt-1 text-muted-foreground">
Status: {installation.status}
{installation.domain
? ` · ${installation.domain}`
: ''}
</p>
<p className="mt-1 text-muted-foreground">
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')}`
: ''}
</p>
</div>
<Button variant="outline" asChild>
<Link href="/documentation/deployment">
Panduan Instalasi & Migrasi
</Link>
</Button>
</CardContent>
</Card>
)}
</div>
</>
);
+137 -36
View File
@@ -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<string, unknown> & { connection_ports?: Record<string, number> }) | null;
device:
| (Record<string, unknown> & {
connection_ports?: Record<string, number>;
})
| 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<string, Array<[string, string, number]>> = {
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) {
</select>
</div>
<div className="rounded-lg border p-4 md:col-span-2">
<h3 className="font-medium">Koneksi Management</h3>
<p className="mb-4 text-sm text-muted-foreground">Kosongkan port jika protokol tersebut tidak tersedia pada perangkat.</p>
<InputError message={errors.connection_ports} />
<h3 className="font-medium">
Koneksi Management
</h3>
<p className="mb-4 text-sm text-muted-foreground">
Kosongkan port jika protokol tersebut
tidak tersedia pada perangkat.
</p>
<InputError
message={errors.connection_ports}
/>
<div className="grid gap-4 md:grid-cols-2">
{protocols.map(([protocol, label, defaultPort]) => (
<div key={`${vendorSlug}-${protocol}`} className="grid gap-2">
<Label>{label}</Label>
<Input
name={`connection_ports[${protocol}]`}
type="number"
min={1}
max={65535}
placeholder={defaultPort ? String(defaultPort) : 'Port custom'}
defaultValue={device?.connection_ports?.[protocol] ?? ''}
/>
<InputError message={errors[`connection_ports.${protocol}`]} />
</div>
))}
{protocols.map(
([
protocol,
label,
defaultPort,
]) => (
<div
key={`${vendorSlug}-${protocol}`}
className="grid gap-2"
>
<Label>{label}</Label>
<Input
name={`connection_ports[${protocol}]`}
type="number"
min={1}
max={65535}
placeholder={
defaultPort
? String(
defaultPort,
)
: 'Port custom'
}
defaultValue={
device
?.connection_ports?.[
protocol
] ?? ''
}
/>
<InputError
message={
errors[
`connection_ports.${protocol}`
]
}
/>
</div>
),
)}
</div>
</div>
<div className="grid gap-2 md:col-span-2">
@@ -173,26 +224,76 @@ export default function DeviceForm({ device, vendors, types, models }: Props) {
<>
<div className="grid gap-2">
<Label>Username Awal</Label>
<Input name="initial_username" required autoComplete="off" />
<InputError message={errors.initial_username} />
<Input
name="initial_username"
required
autoComplete="off"
/>
<InputError
message={
errors.initial_username
}
/>
</div>
<div className="grid gap-2">
<Label>Password Awal</Label>
<Input name="initial_password" type="password" autoComplete="new-password" />
<p className="text-xs text-muted-foreground">Boleh kosong untuk perangkat MikroTik yang masih memakai password kosong.</p>
<InputError message={errors.initial_password} />
<Input
name="initial_password"
type="password"
autoComplete="new-password"
/>
<p className="text-xs text-muted-foreground">
Boleh kosong untuk perangkat
MikroTik yang masih memakai
password kosong.
</p>
<InputError
message={
errors.initial_password
}
/>
</div>
<div className="grid gap-2 md:col-span-2">
<Label>Enable Password (opsional)</Label>
<Input name="initial_enable_password" type="password" autoComplete="new-password" />
<p className="text-xs text-muted-foreground">Khusus perangkat CLI seperti ZTE C300/C320. Default pabrik tertentu adalah zxr10; isi sesuai kondisi perangkat.</p>
<InputError message={errors.initial_enable_password} />
<Label>
Enable Password (opsional)
</Label>
<Input
name="initial_enable_password"
type="password"
autoComplete="new-password"
/>
<p className="text-xs text-muted-foreground">
Khusus perangkat CLI seperti ZTE
C300/C320. Default pabrik
tertentu adalah zxr10; isi
sesuai kondisi perangkat.
</p>
<InputError
message={
errors.initial_enable_password
}
/>
</div>
<label className="flex items-start gap-3 rounded-lg border p-4 md:col-span-2">
<input type="checkbox" name="remove_legacy_users_on_activation" value="1" className="mt-1" />
<input
type="checkbox"
name="remove_legacy_users_on_activation"
value="1"
className="mt-1"
/>
<span>
<span className="block font-medium">Hapus user lama setelah koneksi pertama</span>
<span className="block text-sm text-muted-foreground">Dijalankan hanya setelah Base User RADIQ berhasil dibuat dan diuji login. User grup full tetap dipertahankan untuk mencegah perangkat terkunci.</span>
<span className="block font-medium">
Hapus user lama setelah
koneksi pertama
</span>
<span className="block text-sm text-muted-foreground">
Dijalankan hanya setelah
Base User RADIQ berhasil
dibuat dan diuji login. User
grup full tetap
dipertahankan untuk mencegah
perangkat terkunci.
</span>
</span>
</label>
</>
+19 -4
View File
@@ -166,7 +166,7 @@ export default function DeviceIndex({
<Button variant="outline">Filter</Button>
</form>
<div className="overflow-x-auto">
<table className="w-full min-w-[980px] table-fixed text-sm [&_td]:align-middle [&_td]:p-3 [&_th]:p-3">
<table className="w-full min-w-[980px] table-fixed text-sm [&_td]:p-3 [&_td]:align-middle [&_th]:p-3">
<colgroup>
<col className="w-[20%]" />
<col className="w-[18%]" />
@@ -202,10 +202,25 @@ export default function DeviceIndex({
'Model belum ditentukan'}
</div>
</td>
<td className="break-words p-3">
<div>{device.management_address}</div>
<td className="p-3 break-words">
<div>
{device.management_address}
</div>
<div className="text-xs text-muted-foreground">
{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(' · ')}
</div>
</td>
<td>
+91 -30
View File
@@ -48,11 +48,14 @@ export default function DeviceSettings({
const [revealing, setRevealing] = useState(false);
const hideTimer = useRef<ReturnType<typeof setTimeout> | 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<HTMLMetaElement>('meta[name="csrf-token"]')?.content ?? '';
const csrf =
document.querySelector<HTMLMetaElement>(
'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 }) => (
<Card>
<CardHeader>
<CardTitle>
Template Base User Tenant
</CardTitle>
<CardTitle>Template Base User Tenant</CardTitle>
<CardDescription>
Password disimpan terenkripsi dan tidak
pernah ditampilkan kembali.
@@ -200,31 +219,58 @@ clearTimeout(hideTimer.current);
<div className="rounded-lg border bg-muted/40 p-4 text-sm md:col-span-2">
<strong>Group yang dibuat:</strong>{' '}
RADIQ-READ, RADIQ-WRITE, dan RADIQ-NOC tanpa
permission <code>policy</code>, serta
group bawaan full untuk akun provisioning
permission <code>policy</code>, serta group
bawaan full untuk akun provisioning
terpusat.
</div>
{hasPassword && (
<div className="grid gap-3 rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-950 md:col-span-2">
<div>
<strong>Lihat Base Password</strong>
<p>Masukkan password login Tenant Admin. Password perangkat akan disembunyikan otomatis setelah 30 detik.</p>
<p>
Masukkan password login Tenant
Admin. Password perangkat akan
disembunyikan otomatis setelah
30 detik.
</p>
</div>
<div className="flex flex-col gap-2 md:flex-row">
<Input
type="password"
value={loginPassword}
onChange={(event) => setLoginPassword(event.target.value)}
onChange={(event) =>
setLoginPassword(
event.target.value,
)
}
placeholder="Password login Tenant Admin"
autoComplete="current-password"
/>
<Button type="button" variant="outline" disabled={revealing || !loginPassword} onClick={revealBasePassword}>
{revealing ? 'Memverifikasi...' : 'Lihat Password'}
<Button
type="button"
variant="outline"
disabled={
revealing || !loginPassword
}
onClick={revealBasePassword}
>
{revealing
? 'Memverifikasi...'
: 'Lihat Password'}
</Button>
</div>
{revealError && <p className="text-destructive">{revealError}</p>}
{revealError && (
<p className="text-destructive">
{revealError}
</p>
)}
{revealedPassword && (
<Input value={revealedPassword} readOnly className="font-mono" aria-label="Base Password" />
<Input
value={revealedPassword}
readOnly
className="font-mono"
aria-label="Base Password"
/>
)}
</div>
)}
@@ -241,21 +287,36 @@ clearTimeout(hideTimer.current);
<CardHeader>
<CardTitle>Status Rotasi Base User</CardTitle>
<CardDescription>
Perangkat offline akan dicoba kembali oleh scheduler.
Vendor tanpa driver ditandai unsupported.
Perangkat offline akan dicoba kembali oleh
scheduler. Vendor tanpa driver ditandai unsupported.
</CardDescription>
</CardHeader>
<CardContent className="space-y-2">
{devices.map((device) => (
<div key={device.id} className="flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3 text-sm">
<div
key={device.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border p-3 text-sm"
>
<div>
<div className="font-medium">{device.name}</div>
<div className="text-xs text-muted-foreground">{device.vendor.name} · {device.base_sync_message ?? 'Belum pernah disinkronkan'}</div>
<div className="font-medium">
{device.name}
</div>
<div className="text-xs text-muted-foreground">
{device.vendor.name} ·{' '}
{device.base_sync_message ??
'Belum pernah disinkronkan'}
</div>
</div>
<span className="rounded-full bg-muted px-2 py-1 text-xs uppercase">{device.base_sync_status}</span>
<span className="rounded-full bg-muted px-2 py-1 text-xs uppercase">
{device.base_sync_status}
</span>
</div>
))}
{devices.length === 0 && <p className="py-4 text-center text-sm text-muted-foreground">Belum ada perangkat.</p>}
{devices.length === 0 && (
<p className="py-4 text-center text-sm text-muted-foreground">
Belum ada perangkat.
</p>
)}
</CardContent>
</Card>
<Form
+50 -43
View File
@@ -47,7 +47,16 @@ export default function DeviceShow({ device, credentials }: Props) {
['Tipe', device.type.name],
['Model', device.model?.name ?? '-'],
['Alamat Management', device.management_address],
['Koneksi', Object.entries(device.connection_ports ?? { [device.connection_type]: device.management_port }).map(([protocol, port]) => `${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) {
</Button>
)}
</div>
{['mikrotik', 'zte', 'hsgq', 'hisfocus'].includes(device.vendor.slug) && (
<Card>
<CardHeader>
<CardTitle>
Aktivasi {device.vendor.name}
</CardTitle>
</CardHeader>
<CardContent className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="text-sm">
<p>
Status:{' '}
<strong className="uppercase">
{device.activation_status}
</strong>
{['mikrotik', 'zte', 'hsgq', 'hisfocus'].includes(
device.vendor.slug,
) && (
<Card>
<CardHeader>
<CardTitle>Aktivasi {device.vendor.name}</CardTitle>
</CardHeader>
<CardContent className="flex flex-col justify-between gap-4 md:flex-row md:items-center">
<div className="text-sm">
<p>
Status:{' '}
<strong className="uppercase">
{device.activation_status}
</strong>
</p>
<p className="text-muted-foreground">
{device.activation_message ??
'Aktivasi akan menguji koneksi dan membuat Base User management dengan aman.'}
</p>
{errors.activation && (
<p className="mt-2 text-destructive">
{errors.activation}
</p>
<p className="text-muted-foreground">
{device.activation_message ??
'Aktivasi akan menguji koneksi dan membuat Base User management dengan aman.'}
</p>
{errors.activation && (
<p className="mt-2 text-destructive">
{errors.activation}
</p>
)}
</div>
{auth.permissions.includes(
'device.connect',
) && (
<Form
action={`/devices/${device.id}/activate`}
method="post"
>
{({ processing }) => (
<Button disabled={processing}>
{processing
? 'Mengaktifkan...'
: 'Cek & Aktifkan Perangkat'}
</Button>
)}
</Form>
)}
</CardContent>
</Card>
)}
</div>
{auth.permissions.includes('device.connect') && (
<Form
action={`/devices/${device.id}/activate`}
method="post"
>
{({ processing }) => (
<Button disabled={processing}>
{processing
? 'Mengaktifkan...'
: 'Cek & Aktifkan Perangkat'}
</Button>
)}
</Form>
)}
</CardContent>
</Card>
)}
<Card>
<CardHeader>
<CardTitle>Informasi</CardTitle>
@@ -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 }) => (
<pre className="mt-3 overflow-x-auto rounded-xl border border-white/10 bg-black/40 p-4 text-xs leading-6 text-sky-100">
<code>{children}</code>
</pre>
);
const Step = ({
number,
children,
}: {
number: number;
children: React.ReactNode;
}) => (
<li className="flex gap-3 text-sm leading-6 text-slate-300">
<span className="flex size-7 shrink-0 items-center justify-center rounded-full bg-sky-500/15 text-xs font-semibold text-sky-300">
{number}
</span>
<div>{children}</div>
</li>
);
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 (
<>
<Head title="Dokumentasi RADIQ NDM" />
<main className="public-page min-h-screen bg-background text-foreground transition-colors">
<div className="mx-auto max-w-6xl px-5 py-8 md:px-8">
<header className="mb-12 flex flex-wrap items-center justify-between gap-4">
<Link href="/" className="flex items-center gap-3">
<img
src="/images/radiq-ndm-logo.png"
className="size-12 object-contain"
alt="RADIQ NDM"
/>
<div>
<p className="font-semibold">RADIQ NDM</p>
<p className="text-xs text-sky-300">
Dokumentasi aplikasi
</p>
</div>
</Link>
<nav className="flex items-center gap-2">
<PublicThemeToggle />
<Link href="/login" className="px-4 py-2 text-sm">
Login
</Link>
<Link
href="/register"
className="rounded-lg bg-sky-500 px-4 py-2 text-sm font-semibold text-slate-950"
>
Daftar Tenant
</Link>
</nav>
</header>
<section className="grid gap-8 lg:grid-cols-[1fr_280px]">
<div>
<p className="text-sm font-semibold text-sky-400">
PANDUAN RESMI APLIKASI
</p>
<h1 className="mt-3 max-w-3xl text-4xl font-bold tracking-tight md:text-5xl">
Menjalankan RADIQ NDM di Cloud atau server milik
tenant.
</h1>
<p className="mt-5 max-w-3xl leading-7 text-slate-300">
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.
</p>
</div>
<aside className="rounded-2xl border border-white/10 bg-white/5 p-5 text-sm">
<p className="font-semibold">Isi dokumentasi</p>
<div className="mt-3 grid gap-2 text-slate-400">
{[
'Pilih deployment',
'Persyaratan server',
'Instalasi Windows/Linux',
'Worker wajib',
'Konfigurasi perangkat',
'Keamanan dan backup',
'Migrasi Cloud ke Local',
].map((item) => (
<span key={item}>• {item}</span>
))}
</div>
</aside>
</section>
<section className="mt-14">
<h2 className="text-2xl font-semibold">
1. Pilih lokasi deployment
</h2>
<div className="mt-5 grid gap-5 md:grid-cols-2">
<article className="rounded-2xl border border-sky-400/25 bg-sky-400/5 p-6">
<Cloud className="size-7 text-sky-400" />
<h3 className="mt-4 text-xl font-semibold">
Cloud RADIQ
</h3>
<p className="mt-2 text-sm leading-6 text-slate-300">
Tenant langsung menggunakan server yang
dikelola RADIQ. Cocok jika tidak ingin
memelihara PHP, PostgreSQL, web server,
worker, backup aplikasi, dan pembaruan
sistem sendiri.
</p>
<p className="mt-4 text-xs text-slate-400">
Saat registrasi pilih{' '}
<strong className="text-slate-200">
Cloud RADIQ
</strong>
. Instalasi dibuat dengan status aktif.
</p>
</article>
<article className="rounded-2xl border border-white/10 bg-white/5 p-6">
<Server className="size-7 text-sky-400" />
<h3 className="mt-4 text-xl font-semibold">
Self-hosted / Local
</h3>
<p className="mt-2 text-sm leading-6 text-slate-300">
Aplikasi dan database berjalan di VPS,
server, komputer, atau laptop tenant. Tenant
tetap tercatat dan lisensinya tetap dikelola
Master Admin RADIQ.
</p>
<p className="mt-4 text-xs text-slate-400">
Saat registrasi pilih{' '}
<strong className="text-slate-200">
Install Local
</strong>
. Instalasi berstatus pending sampai
pemasangan dan lisensi selesai.
</p>
</article>
</div>
</section>
<section className="mt-14">
<h2 className="text-2xl font-semibold">
2. Persyaratan self-hosted
</h2>
<div className="mt-5 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[
[
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 (
<article
key={title as string}
className="rounded-xl border border-white/10 p-5"
>
<ItemIcon className="size-5 text-sky-400" />
<h3 className="mt-3 font-semibold">
{title as string}
</h3>
<p className="mt-2 text-xs leading-5 text-slate-400">
{text as string}
</p>
</article>
);
})}
</div>
<div className="mt-5 rounded-xl border border-amber-400/20 bg-amber-400/5 p-4 text-sm leading-6 text-amber-100">
<AlertTriangle className="mr-2 inline size-4" />
Document root web server wajib menunjuk ke folder{' '}
<code>public</code>, bukan ke root project.
</div>
</section>
<section className="mt-14">
<div className="flex flex-wrap items-center justify-between gap-4">
<h2 className="text-2xl font-semibold">
3. Instalasi self-hosted
</h2>
<a
href="/downloads/self-hosted"
className="inline-flex items-center gap-2 rounded-lg border border-sky-400/30 px-4 py-2 text-sm font-semibold text-sky-300 hover:bg-sky-400/10"
>
<Download className="size-4" /> Unduh paket
</a>
</div>
<div className="mt-5 rounded-2xl border border-white/10 p-6">
<h3 className="font-semibold">
Konfigurasi minimum file .env
</h3>
<Code>{`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`}</Code>
<p className="mt-3 text-xs leading-5 text-slate-400">
URL server lisensi dan public key diberikan
Master Admin RADIQ. Private signing key tidak
boleh berada pada server tenant.
</p>
</div>
<div className="mt-5 grid gap-5 lg:grid-cols-2">
<article className="rounded-2xl border border-white/10 p-6">
<h3 className="text-lg font-semibold">
Windows
</h3>
<ol className="mt-5 space-y-4">
<Step number={1}>
Ekstrak paket dan buka PowerShell pada
folder aplikasi.
</Step>
<Step number={2}>
Jalankan installer dengan PHP 8.3:
<Code>{`powershell -ExecutionPolicy Bypass -File scripts/install-self-hosted.ps1 -Php C:\\php83\\php.exe`}</Code>
</Step>
<Step number={3}>
Installer membuat <code>.env</code>,
lalu berhenti agar koneksi database
dapat diisi. Ketik <code>LANJUT</code>{' '}
setelah selesai.
</Step>
<Step number={4}>
Isi identitas ISP dan Tenant Admin.
Password minimal 12 karakter dengan
huruf besar/kecil, angka, dan simbol.
</Step>
</ol>
</article>
<article className="rounded-2xl border border-white/10 p-6">
<h3 className="text-lg font-semibold">Linux</h3>
<ol className="mt-5 space-y-4">
<Step number={1}>
Ekstrak paket dan berikan akses folder
serta <code>storage</code> kepada user
service.
</Step>
<Step number={2}>
Jalankan installer:
<Code>{`PHP_BIN=/usr/bin/php8.3 bash scripts/install-self-hosted.sh`}</Code>
</Step>
<Step number={3}>
Lengkapi <code>.env</code>, lalu ketik{' '}
<code>LANJUT</code>. Installer
menjalankan Composer, migration, seeder,
pembuatan tenant, build frontend, dan
optimasi.
</Step>
<Step number={4}>
Konfigurasikan Nginx/Apache dengan HTTPS
dan document root ke folder{' '}
<code>public</code>.
</Step>
</ol>
</article>
</div>
</section>
<section className="mt-14">
<h2 className="text-2xl font-semibold">
4. Proses background yang wajib hidup
</h2>
<p className="mt-3 text-sm leading-6 text-slate-300">
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.
</p>
<Code>{`# Worker sinkronisasi perangkat
php artisan queue:work --queue=default --tries=1 --timeout=60
# Scheduler pekerjaan pending dan tugas berkala
php artisan schedule:work`}</Code>
<p className="mt-3 text-xs text-slate-400">
Pada Windows, ganti <code>php</code> dengan{' '}
<code>C:\php83\php.exe</code>. Setelah deployment
jalankan <code>php artisan optimize:clear</code> dan{' '}
<code>php artisan queue:restart</code>.
</p>
</section>
<section className="mt-14">
<h2 className="text-2xl font-semibold">
5. Konfigurasi koneksi perangkat
</h2>
<div className="mt-5 overflow-x-auto rounded-2xl border border-white/10">
<table className="w-full min-w-[760px] text-left text-sm">
<thead className="bg-white/5 text-slate-200">
<tr>
{[
'Vendor',
'Protokol',
'Port default',
'Catatan',
].map((title) => (
<th
key={title}
className="px-4 py-3 font-semibold"
>
{title}
</th>
))}
</tr>
</thead>
<tbody>
{vendorRows.map((row) => (
<tr
key={row[0]}
className="border-t border-white/10"
>
{row.map((cell) => (
<td
key={cell}
className="px-4 py-3 align-top leading-6 text-slate-400"
>
{cell}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
<div className="mt-5 rounded-xl border border-white/10 p-5 text-sm leading-6 text-slate-300">
<strong>Aturan port:</strong> 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.
</div>
</section>
<section className="mt-14 grid gap-5 lg:grid-cols-2">
<article className="rounded-2xl border border-white/10 p-6">
<Users className="size-6 text-sky-400" />
<h2 className="mt-4 text-xl font-semibold">
6. Cara kerja Users Perangkat
</h2>
<ol className="mt-5 space-y-3">
<Step number={1}>
Tenant Admin mengatur Base User dan Base
Password.
</Step>
<Step number={2}>
Tambahkan perangkat dengan credential awal,
lalu Cek & Aktifkan.
</Step>
<Step number={3}>
Buat Users Perangkat, pilih perangkat tujuan
dan group akses.
</Step>
<Step number={4}>
Perangkat online langsung disinkronkan;
perangkat offline berstatus pending.
</Step>
</ol>
<p className="mt-4 text-xs leading-5 text-slate-400">
Role aplikasi tetap: Master Admin, Tenant Admin,
dan Tenant User. Tenant Admin hanya mengelola
Tenant User miliknya.
</p>
</article>
<article className="rounded-2xl border border-white/10 p-6">
<KeyRound className="size-6 text-sky-400" />
<h2 className="mt-4 text-xl font-semibold">
7. Credential dan encryption key
</h2>
<div className="mt-4 space-y-3 text-sm leading-6 text-slate-300">
<p>
Password perangkat dienkripsi dengan tenant
encryption key. Tenant key dibungkus kembali
oleh application key server.
</p>
<p>
Tenant Admin dapat membuat key otomatis atau
manual. Rotasi key mengenkripsi ulang
credential yang tersimpan.
</p>
<p>
Melihat Base Password wajib memasukkan
password login Tenant Admin dan dibatasi
rate limit.
</p>
</div>
<div className="mt-4 rounded-lg bg-red-400/5 p-3 text-xs leading-5 text-red-100">
Kehilangan APP_KEY atau encryption key tanpa
backup yang benar dapat membuat credential tidak
dapat dipulihkan.
</div>
</article>
</section>
<section className="mt-14">
<h2 className="text-2xl font-semibold">
8. Backup dan pemeliharaan
</h2>
<div className="mt-5 grid gap-4 md:grid-cols-3">
{[
[
'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]) => (
<article
key={title}
className="rounded-xl border border-white/10 p-5"
>
<ShieldCheck className="size-5 text-emerald-400" />
<h3 className="mt-3 font-semibold">
{title}
</h3>
<p className="mt-2 text-xs leading-5 text-slate-400">
{text}
</p>
</article>
))}
</div>
<Code>{`# 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`}</Code>
</section>
<section className="mt-14">
<h2 className="text-2xl font-semibold">
9. Migrasi Cloud ke Local
</h2>
<div className="mt-4 rounded-xl border border-amber-400/20 bg-amber-400/5 p-4 text-sm leading-6 text-amber-100">
<AlertTriangle className="mr-2 inline size-4" />
Versi saat ini belum menyediakan ekspor–impor tenant
otomatis. Migrasi harus dijadwalkan bersama Master
Admin agar credential terenkripsi dan encryption key
dipindahkan dengan aman.
</div>
<ol className="mt-5 space-y-4">
{[
'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) => (
<Step key={item} number={index + 1}>
{item}
</Step>
))}
</ol>
</section>
<section className="mt-14 rounded-2xl bg-sky-500 p-7 text-slate-950">
<h2 className="text-2xl font-bold">
Mulai dengan deployment yang sesuai
</h2>
<p className="mt-2 max-w-2xl">
Cloud cocok untuk penggunaan cepat. Self-hosted
cocok ketika management perangkat hanya dapat
diakses dari jaringan internal tenant.
</p>
<Link
href="/register"
className="mt-5 inline-flex items-center gap-2 rounded-lg bg-slate-950 px-5 py-3 font-semibold text-white"
>
Daftar Tenant <ArrowRight className="size-4" />
</Link>
</section>
</div>
</main>
</>
);
}
+175 -12
View File
@@ -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 (
<>
<Head title="Network Device Management" />
<main className="min-h-screen bg-slate-950 text-white">
<main className="min-h-screen bg-background text-foreground transition-colors">
<div className="mx-auto flex min-h-screen max-w-6xl flex-col px-6">
<header className="flex items-center justify-between py-6">
<div className="flex items-center gap-3">
@@ -43,17 +44,34 @@ export default function Welcome() {
<div className="font-semibold tracking-wide">
RADIQ NDM
</div>
<div className="text-xs text-sky-300">
<div className="text-xs text-sky-600 dark:text-sky-300">
Network Device Management
</div>
</div>
</div>
<Link
href={auth.user ? dashboard() : login()}
className="rounded-lg bg-sky-500 px-5 py-2.5 text-sm font-semibold text-slate-950 hover:bg-sky-400"
>
{auth.user ? 'Dashboard' : 'Login'}
</Link>
<nav className="flex items-center gap-3">
<PublicThemeToggle />
<Link
href="/documentation/deployment"
className="hidden text-sm text-muted-foreground hover:text-foreground sm:inline"
>
Dokumentasi
</Link>
{!auth.user && (
<Link
href="/register"
className="rounded-lg border border-sky-500/40 px-4 py-2.5 text-sm font-semibold text-sky-600 hover:bg-sky-400/10 dark:text-sky-300"
>
Daftar Tenant
</Link>
)}
<Link
href={auth.user ? dashboard() : login()}
className="rounded-lg bg-sky-500 px-5 py-2.5 text-sm font-semibold text-slate-950 hover:bg-sky-400"
>
{auth.user ? 'Dashboard' : 'Login'}
</Link>
</nav>
</header>
<section className="grid flex-1 items-center gap-12 py-16 lg:grid-cols-2">
<div>
@@ -63,12 +81,27 @@ export default function Welcome() {
<h1 className="text-4xl font-bold tracking-tight sm:text-6xl">
Kelola akses perangkat jaringan dengan aman.
</h1>
<p className="mt-6 max-w-xl text-lg leading-8 text-slate-300">
<p className="mt-6 max-w-xl text-lg leading-8 text-muted-foreground">
Saat karyawan berpindah tugas atau keluar, akun
perangkat dapat dinonaktifkan secara terpusat
tanpa membuka setiap router, OLT, switch, atau
access point satu per satu.
</p>
<div className="mt-8 flex flex-wrap gap-3">
<Link
href="/register"
className="rounded-lg bg-sky-500 px-5 py-3 font-semibold text-slate-950 hover:bg-sky-400"
>
Mulai & Pilih Deployment
</Link>
<Link
href="/documentation/deployment"
className="inline-flex items-center gap-2 rounded-lg border border-border px-5 py-3 font-semibold hover:bg-muted"
>
<BookOpen className="size-4" /> Panduan
Instalasi & Migrasi
</Link>
</div>
</div>
<div className="flex justify-center">
<img
@@ -82,20 +115,150 @@ export default function Welcome() {
{features.map((feature) => (
<article
key={feature.title}
className="rounded-2xl border border-white/10 bg-white/5 p-5"
className="rounded-2xl border border-border bg-card p-5"
>
<feature.icon className="mb-4 size-6 text-sky-400" />
<h2 className="font-semibold">
{feature.title}
</h2>
<p className="mt-2 text-sm leading-6 text-slate-400">
<p className="mt-2 text-sm leading-6 text-muted-foreground">
{feature.text}
</p>
</article>
))}
</section>
<Pricing />
<Roadmap />
</div>
</main>
</>
);
}
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 (
<section id="harga" className="py-16">
<div className="mx-auto max-w-3xl text-center">
<p className="text-sm font-semibold text-sky-600 dark:text-sky-400">
HARGA EARLY ACCESS
</p>
<h2 className="mt-3 text-3xl font-bold">
Lisensi bulanan yang ramah untuk ISP lokal
</h2>
<p className="mt-3 text-muted-foreground">
Tidak ada kontrak tahunan wajib. User perangkat tidak
dihitung sebagai user aplikasi.
</p>
</div>
<div className="mt-9 grid gap-4 md:grid-cols-2 xl:grid-cols-5">
{plans.map((plan) => (
<article
key={plan.name}
className={`relative rounded-2xl border p-5 ${plan.featured ? 'border-sky-500 bg-sky-500/5 shadow-lg shadow-sky-500/10' : 'border-border bg-card'}`}
>
{plan.featured && (
<span className="absolute -top-3 left-4 rounded-full bg-sky-500 px-3 py-1 text-xs font-semibold text-slate-950">
Paling sesuai ISP
</span>
)}
<p className="text-sm font-semibold text-sky-600 dark:text-sky-400">
{plan.deployment}
</p>
<h3 className="mt-2 text-xl font-bold">{plan.name}</h3>
<p className="mt-4 text-2xl font-bold">
{plan.price}
{plan.price !== 'Gratis' && (
<span className="text-sm font-normal text-muted-foreground">
/bulan
</span>
)}
</p>
<ul className="mt-5 space-y-2 text-sm text-muted-foreground">
<li>✓ Hingga {plan.devices}</li>
<li>✓ Hingga {plan.users}</li>
<li>✓ Users Perangkat tanpa batas</li>
<li>✓ Update aplikasi</li>
<li>✓ Dukungan dasar</li>
</ul>
<Link
href="/register"
className="mt-6 block rounded-lg bg-sky-500 px-4 py-2.5 text-center text-sm font-semibold text-slate-950 hover:bg-sky-400"
>
Pilih paket
</Link>
</article>
))}
</div>
<p className="mt-5 text-center text-xs leading-5 text-muted-foreground">
Harga belum termasuk PPN, VPS/server tenant, instalasi khusus,
atau migrasi manual. Harga dapat dievaluasi seiring penambahan
fitur dengan pemberitahuan sebelumnya.
</p>
</section>
);
}
function Roadmap() {
return (
<section className="pb-16">
<div className="rounded-2xl border border-border bg-muted/40 p-7">
<p className="text-sm font-semibold text-sky-600 dark:text-sky-400">
ROADMAP · BELUM TERMASUK FITUR AKTIF
</p>
<h2 className="mt-2 text-2xl font-bold">
RADIQ NDM akan tumbuh bersama kebutuhan operator jaringan.
</h2>
<p className="mt-3 max-w-3xl text-sm leading-6 text-muted-foreground">
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.
</p>
</div>
</section>
);
}
+14
View File
@@ -1,9 +1,12 @@
<?php
use App\Enums\LicenseStatus;
use App\Jobs\SyncDeviceBaseCredential;
use App\Jobs\SyncDeviceUser;
use App\Models\Device;
use App\Models\DeviceUserAssignment;
use App\Models\License;
use App\Services\LicenseEntitlementService;
use Illuminate\Foundation\Inspiring;
use Illuminate\Support\Facades\Artisan;
use Illuminate\Support\Facades\Schedule;
@@ -12,8 +15,16 @@ Artisan::command('inspire', function () {
$this->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);
}
+19
View File
@@ -1,13 +1,17 @@
<?php
use App\Http\Controllers\Administration\BillingOrderController;
use App\Http\Controllers\Administration\TenantController;
use App\Http\Controllers\Administration\UserController;
use App\Http\Controllers\BillingController;
use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\DashboardController;
use App\Http\Controllers\DeviceAccessUserController;
use App\Http\Controllers\DeviceActivationController;
use App\Http\Controllers\DeviceController;
use App\Http\Controllers\DeviceCredentialController;
use App\Http\Controllers\DeviceUserSyncController;
use App\Http\Controllers\PaymentWebhookController;
use App\Http\Controllers\RevealTenantBasePasswordController;
use App\Http\Controllers\TenantDevicePolicyController;
use App\Http\Controllers\TenantDeviceSettingController;
@@ -15,9 +19,22 @@ use App\Http\Controllers\TenantEncryptionController;
use Illuminate\Support\Facades\Route;
Route::inertia('/', 'welcome')->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');
+14 -2
View File
@@ -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',
]);
}
}