@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'));
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
@@ -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' }],
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user