@@ -0,0 +1,186 @@
|
||||
import { Form, Head, Link } from '@inertiajs/react';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
export default function TenantCreate() {
|
||||
return (
|
||||
<>
|
||||
<Head title="Tambah Tenant" />
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-6 p-4 md:p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Tambah Tenant</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tenant Owner pertama dibuat dalam transaksi yang sama.
|
||||
</p>
|
||||
</div>
|
||||
<Form
|
||||
action="/administration/tenants"
|
||||
method="post"
|
||||
resetOnSuccess
|
||||
className="space-y-6"
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Informasi Tenant</CardTitle>
|
||||
<CardDescription>
|
||||
Identitas organisasi pada platform.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-5 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">
|
||||
Nama Tenant
|
||||
</Label>
|
||||
<Input id="name" name="name" required />
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="slug">Slug</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
name="slug"
|
||||
required
|
||||
placeholder="isp-jakarta"
|
||||
/>
|
||||
<InputError message={errors.slug} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tenant Owner</CardTitle>
|
||||
<CardDescription>
|
||||
Akun administrator utama khusus tenant
|
||||
ini.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-5 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="owner_name">
|
||||
Nama Lengkap
|
||||
</Label>
|
||||
<Input
|
||||
id="owner_name"
|
||||
name="owner_name"
|
||||
required
|
||||
/>
|
||||
<InputError
|
||||
message={errors.owner_name}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="owner_email">
|
||||
Email
|
||||
</Label>
|
||||
<Input
|
||||
id="owner_email"
|
||||
name="owner_email"
|
||||
type="email"
|
||||
required
|
||||
/>
|
||||
<InputError
|
||||
message={errors.owner_email}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="owner_password">
|
||||
Password
|
||||
</Label>
|
||||
<Input
|
||||
id="owner_password"
|
||||
name="owner_password"
|
||||
type="password"
|
||||
required
|
||||
/>
|
||||
<InputError
|
||||
message={errors.owner_password}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="owner_password_confirmation">
|
||||
Konfirmasi Password
|
||||
</Label>
|
||||
<Input
|
||||
id="owner_password_confirmation"
|
||||
name="owner_password_confirmation"
|
||||
type="password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Lokasi Instalasi</CardTitle>
|
||||
<CardDescription>
|
||||
Tentukan apakah tenant bergabung di
|
||||
cloud RADIQ atau menjalankan server
|
||||
sendiri.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-5 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label>Deployment</Label>
|
||||
<select
|
||||
name="deployment_type"
|
||||
defaultValue="managed_cloud"
|
||||
className="h-10 rounded-md border bg-background px-3"
|
||||
>
|
||||
<option value="managed_cloud">
|
||||
Bergabung dengan RADIQ NDM Cloud
|
||||
</option>
|
||||
<option value="self_hosted">
|
||||
Server/VPS/Komputer Tenant
|
||||
</option>
|
||||
</select>
|
||||
<InputError
|
||||
message={errors.deployment_type}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Domain / Alamat Instalasi</Label>
|
||||
<Input
|
||||
name="deployment_domain"
|
||||
placeholder="ndm.isp-example.co.id"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.deployment_domain}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/administration/tenants">
|
||||
Batal
|
||||
</Link>
|
||||
</Button>
|
||||
<Button disabled={processing}>
|
||||
Buat Tenant
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
TenantCreate.layout = {
|
||||
breadcrumbs: [
|
||||
{ title: 'Tenants', href: '/administration/tenants' },
|
||||
{ title: 'Tambah', href: '/administration/tenants/create' },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,229 @@
|
||||
import { Form, Head, Link, usePage } from '@inertiajs/react';
|
||||
import InputError from '@/components/input-error';
|
||||
import { TenantAdminNav } from '@/components/tenant-admin-nav';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
type Tenant = {
|
||||
id: number;
|
||||
uuid: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
is_active: boolean;
|
||||
};
|
||||
type User = {
|
||||
uuid: string;
|
||||
name: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
type Installation = {
|
||||
deployment_type: 'managed_cloud' | 'self_hosted';
|
||||
domain?: string;
|
||||
status: string;
|
||||
last_seen_at?: string;
|
||||
};
|
||||
|
||||
export default function TenantEdit({
|
||||
tenant,
|
||||
users,
|
||||
installation,
|
||||
}: {
|
||||
tenant: Tenant;
|
||||
users: User[];
|
||||
installation: Installation | null;
|
||||
}) {
|
||||
const { flash } = usePage().props as unknown as {
|
||||
flash: { success?: string };
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title={`Kelola ${tenant.name}`} />
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{tenant.name}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
UUID: {tenant.uuid}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant={tenant.is_active ? 'default' : 'secondary'}>
|
||||
{tenant.is_active ? 'Aktif' : 'Nonaktif'}
|
||||
</Badge>
|
||||
</div>
|
||||
<TenantAdminNav tenantId={tenant.id} />
|
||||
{flash.success && (
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
{flash.success}
|
||||
</div>
|
||||
)}
|
||||
<Form
|
||||
action={`/administration/tenants/${tenant.id}`}
|
||||
method="put"
|
||||
className="space-y-6"
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Pengaturan Tenant</CardTitle>
|
||||
<CardDescription>
|
||||
Menonaktifkan tenant mempertahankan seluruh
|
||||
data.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-5 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Nama Tenant</Label>
|
||||
<Input
|
||||
id="name"
|
||||
name="name"
|
||||
defaultValue={tenant.name}
|
||||
required
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="slug">Slug</Label>
|
||||
<Input
|
||||
id="slug"
|
||||
name="slug"
|
||||
defaultValue={tenant.slug}
|
||||
required
|
||||
/>
|
||||
<InputError message={errors.slug} />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 md:col-span-2">
|
||||
<input
|
||||
type="hidden"
|
||||
name="is_active"
|
||||
value="0"
|
||||
/>
|
||||
<input
|
||||
id="is_active"
|
||||
name="is_active"
|
||||
type="checkbox"
|
||||
value="1"
|
||||
defaultChecked={tenant.is_active}
|
||||
className="size-4 rounded border"
|
||||
/>
|
||||
<Label htmlFor="is_active">
|
||||
Tenant aktif
|
||||
</Label>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Lokasi Instalasi</Label>
|
||||
<select
|
||||
name="deployment_type"
|
||||
defaultValue={
|
||||
installation?.deployment_type ??
|
||||
'managed_cloud'
|
||||
}
|
||||
className="h-10 rounded-md border bg-background px-3"
|
||||
>
|
||||
<option value="managed_cloud">
|
||||
RADIQ NDM Cloud
|
||||
</option>
|
||||
<option value="self_hosted">
|
||||
Server/VPS/Komputer Tenant
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Domain / Alamat Instalasi</Label>
|
||||
<Input
|
||||
name="deployment_domain"
|
||||
defaultValue={
|
||||
installation?.domain ?? ''
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 md:col-span-2">
|
||||
<Button disabled={processing}>
|
||||
Simpan Perubahan
|
||||
</Button>
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/administration/tenants">
|
||||
Kembali
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Form>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>User Tenant</CardTitle>
|
||||
<CardDescription>
|
||||
Daftar awal; manajemen user lengkap tersedia pada
|
||||
increment berikutnya.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-3">Nama</th>
|
||||
<th>Email</th>
|
||||
<th>Status</th>
|
||||
<th>Dibuat</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user) => (
|
||||
<tr
|
||||
key={user.uuid}
|
||||
className="border-b last:border-0"
|
||||
>
|
||||
<td className="py-3 font-medium">
|
||||
{user.name}
|
||||
</td>
|
||||
<td>{user.email}</td>
|
||||
<td>
|
||||
<Badge
|
||||
variant={
|
||||
user.is_active
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{user.is_active
|
||||
? 'Aktif'
|
||||
: 'Nonaktif'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td>
|
||||
{new Date(
|
||||
user.created_at,
|
||||
).toLocaleDateString('id-ID')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
TenantEdit.layout = {
|
||||
breadcrumbs: [
|
||||
{ title: 'Tenants', href: '/administration/tenants' },
|
||||
{ title: 'Kelola', href: '#' },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Head, Link, usePage } from '@inertiajs/react';
|
||||
import { Building2, Plus, Users } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
type Tenant = {
|
||||
id: number;
|
||||
uuid: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
is_active: boolean;
|
||||
users_count: number;
|
||||
created_at: string;
|
||||
installations: {
|
||||
deployment_type: string;
|
||||
domain?: string;
|
||||
status: string;
|
||||
}[];
|
||||
};
|
||||
type PageLink = { url: string | null; label: string; active: boolean };
|
||||
type Props = { tenants: { data: Tenant[]; links: PageLink[]; total: number } };
|
||||
|
||||
export default function TenantIndex({ tenants }: Props) {
|
||||
const { flash } = usePage().props as unknown as {
|
||||
flash: { success?: string };
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Tenants" />
|
||||
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6">
|
||||
<div className="flex flex-col justify-between gap-3 sm:flex-row sm:items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Tenants</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Kelola organisasi yang menggunakan platform.
|
||||
</p>
|
||||
</div>
|
||||
<Button asChild>
|
||||
<Link href="/administration/tenants/create">
|
||||
<Plus /> Tambah Tenant
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
{flash.success && (
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
{flash.success}
|
||||
</div>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Building2 className="size-5" /> {tenants.total}{' '}
|
||||
Tenant
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="w-full min-w-3xl text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-3">Tenant</th>
|
||||
<th>Status</th>
|
||||
<th>Instalasi</th>
|
||||
<th>Users</th>
|
||||
<th>Dibuat</th>
|
||||
<th className="text-right">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tenants.data.map((tenant) => (
|
||||
<tr
|
||||
key={tenant.uuid}
|
||||
className="border-b last:border-0"
|
||||
>
|
||||
<td className="py-4">
|
||||
<div className="font-medium">
|
||||
{tenant.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{tenant.slug}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<Badge
|
||||
variant={
|
||||
tenant.is_active
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{tenant.is_active
|
||||
? 'Aktif'
|
||||
: 'Nonaktif'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td>
|
||||
<div className="font-medium">
|
||||
{tenant.installations[0]
|
||||
?.deployment_type ===
|
||||
'self_hosted'
|
||||
? 'Server Tenant'
|
||||
: 'RADIQ Cloud'}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{tenant.installations[0]
|
||||
?.domain ??
|
||||
tenant.installations[0]
|
||||
?.status ??
|
||||
'-'}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Users className="size-4" />{' '}
|
||||
{tenant.users_count}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{new Date(
|
||||
tenant.created_at,
|
||||
).toLocaleDateString('id-ID')}
|
||||
</td>
|
||||
<td className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`/administration/tenants/${tenant.id}/edit`}
|
||||
>
|
||||
Kelola
|
||||
</Link>
|
||||
</Button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{tenants.data.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="py-12 text-center text-muted-foreground"
|
||||
>
|
||||
Belum ada tenant. Buat tenant
|
||||
pertama untuk mulai.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{tenants.links.map((link) => (
|
||||
<Button
|
||||
key={link.label}
|
||||
variant={
|
||||
link.active ? 'default' : 'outline'
|
||||
}
|
||||
size="sm"
|
||||
disabled={!link.url}
|
||||
asChild={!!link.url}
|
||||
>
|
||||
{link.url ? (
|
||||
<Link
|
||||
href={link.url}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: link.label,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: link.label,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
TenantIndex.layout = {
|
||||
breadcrumbs: [
|
||||
{ title: 'Administration', href: '/administration/tenants' },
|
||||
{ title: 'Tenants', href: '/administration/tenants' },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Form, Head, Link } from '@inertiajs/react';
|
||||
import InputError from '@/components/input-error';
|
||||
import { TenantAdminNav } from '@/components/tenant-admin-nav';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
type Props = {
|
||||
tenant: { id: number; name: string };
|
||||
};
|
||||
export default function UserCreate({ tenant }: Props) {
|
||||
const base = `/administration/tenants/${tenant.id}/users`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Tambah User" />
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-6 p-4 md:p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Tambah User</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{tenant.name}
|
||||
</p>
|
||||
</div>
|
||||
<TenantAdminNav tenantId={tenant.id} />
|
||||
<Form action={base} method="post">
|
||||
{({ processing, errors }) => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Informasi Akun</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-5 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label>Nama</Label>
|
||||
<Input name="name" required />
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Email</Label>
|
||||
<Input name="email" type="email" required />
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Password</Label>
|
||||
<Input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Konfirmasi Password</Label>
|
||||
<Input
|
||||
name="password_confirmation"
|
||||
type="password"
|
||||
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>
|
||||
<div className="flex justify-end gap-3 md:col-span-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={base}>Batal</Link>
|
||||
</Button>
|
||||
<Button disabled={processing}>
|
||||
Buat User
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
UserCreate.layout = {
|
||||
breadcrumbs: [
|
||||
{ title: 'Users', href: '#' },
|
||||
{ title: 'Tambah', href: '#' },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Form, Head, Link, usePage } from '@inertiajs/react';
|
||||
import InputError from '@/components/input-error';
|
||||
import { TenantAdminNav } from '@/components/tenant-admin-nav';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
type Role = { id: number; name: string };
|
||||
type Props = {
|
||||
tenant: { id: number; name: string };
|
||||
managedUser: { id: number; name: string; email: string; roles: Role[] };
|
||||
};
|
||||
export default function UserEdit({ tenant, managedUser }: Props) {
|
||||
const base = `/administration/tenants/${tenant.id}/users`;
|
||||
const { flash } = usePage().props as unknown as {
|
||||
flash: { success?: string };
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title={`Edit ${managedUser.name}`} />
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-6 p-4 md:p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Edit User</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{tenant.name}
|
||||
</p>
|
||||
</div>
|
||||
<TenantAdminNav tenantId={tenant.id} />
|
||||
{flash.success && (
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
{flash.success}
|
||||
</div>
|
||||
)}
|
||||
<Form action={`${base}/${managedUser.id}`} method="put">
|
||||
{({ processing, errors }) => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Profil dan Role</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-5 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label>Nama</Label>
|
||||
<Input
|
||||
name="name"
|
||||
defaultValue={managedUser.name}
|
||||
required
|
||||
/>
|
||||
<InputError message={errors.name} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Email</Label>
|
||||
<Input
|
||||
name="email"
|
||||
type="email"
|
||||
defaultValue={managedUser.email}
|
||||
required
|
||||
/>
|
||||
<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>
|
||||
<div className="flex justify-end gap-3 md:col-span-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href={base}>Kembali</Link>
|
||||
</Button>
|
||||
<Button disabled={processing}>
|
||||
Simpan
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Form>
|
||||
<Form
|
||||
action={`${base}/${managedUser.id}/password`}
|
||||
method="put"
|
||||
resetOnSuccess
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Reset Password</CardTitle>
|
||||
<CardDescription>
|
||||
Semua token API user akan dicabut.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-5 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label>Password Baru</Label>
|
||||
<Input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Konfirmasi Password</Label>
|
||||
<Input
|
||||
name="password_confirmation"
|
||||
type="password"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<Button disabled={processing}>
|
||||
Reset Password
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
UserEdit.layout = {
|
||||
breadcrumbs: [
|
||||
{ title: 'Users', href: '#' },
|
||||
{ title: 'Edit', href: '#' },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,167 @@
|
||||
import { Form, Head, Link, usePage } from '@inertiajs/react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { TenantAdminNav } from '@/components/tenant-admin-nav';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
type Tenant = { id: number; name: string };
|
||||
type Role = { id: number; name: string };
|
||||
type User = {
|
||||
id: number;
|
||||
uuid: string;
|
||||
name: string;
|
||||
email: string;
|
||||
is_active: boolean;
|
||||
roles: Role[];
|
||||
created_at: string;
|
||||
};
|
||||
type Props = { tenant: Tenant; users: { data: User[]; total: number } };
|
||||
|
||||
export default function UserIndex({ tenant, users }: Props) {
|
||||
const { auth, flash } = usePage().props as unknown as {
|
||||
auth: { user: { is_platform_admin: boolean }; permissions: string[] };
|
||||
flash: { success?: string };
|
||||
};
|
||||
const can = (permission: string) =>
|
||||
auth.user.is_platform_admin || auth.permissions.includes(permission);
|
||||
const base = `/administration/tenants/${tenant.id}/users`;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title={`Users · ${tenant.name}`} />
|
||||
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Users</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{tenant.name}
|
||||
</p>
|
||||
</div>
|
||||
{can('user.create') && (
|
||||
<Button asChild>
|
||||
<Link href={`${base}/create`}>
|
||||
<Plus /> Tambah User
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<TenantAdminNav tenantId={tenant.id} />
|
||||
{flash.success && (
|
||||
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||
{flash.success}
|
||||
</div>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{users.total} User</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="overflow-x-auto">
|
||||
<table className="w-full min-w-3xl text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="py-3">User</th>
|
||||
<th>Role</th>
|
||||
<th>Status</th>
|
||||
<th className="text-right">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.data.map((user) => (
|
||||
<tr
|
||||
key={user.uuid}
|
||||
className="border-b last:border-0"
|
||||
>
|
||||
<td className="py-3">
|
||||
<div className="font-medium">
|
||||
{user.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{user.roles.map((role) => (
|
||||
<Badge
|
||||
key={role.id}
|
||||
variant="secondary"
|
||||
>
|
||||
{role.name}
|
||||
</Badge>
|
||||
))}
|
||||
</td>
|
||||
<td>
|
||||
<Badge
|
||||
variant={
|
||||
user.is_active
|
||||
? 'default'
|
||||
: 'secondary'
|
||||
}
|
||||
>
|
||||
{user.is_active
|
||||
? 'Aktif'
|
||||
: 'Nonaktif'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="text-right">
|
||||
<div className="flex justify-end gap-2">
|
||||
{can('user.update') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`${base}/${user.id}/edit`}
|
||||
>
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{can('user.update') && (
|
||||
<Form
|
||||
action={`${base}/${user.id}/toggle-active`}
|
||||
method="patch"
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
>
|
||||
{user.is_active
|
||||
? 'Nonaktifkan'
|
||||
: 'Aktifkan'}
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
{can('user.delete') && (
|
||||
<Form
|
||||
action={`${base}/${user.id}`}
|
||||
method="delete"
|
||||
>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
>
|
||||
Hapus
|
||||
</Button>
|
||||
</Form>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
UserIndex.layout = {
|
||||
breadcrumbs: [
|
||||
{ title: 'Tenants', href: '/administration/tenants' },
|
||||
{ title: 'Users', href: '#' },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import {
|
||||
index as confirmOptions,
|
||||
store as confirmStore,
|
||||
} from '@/actions/Laravel/Passkeys/Http/Controllers/PasskeyConfirmationController';
|
||||
import InputError from '@/components/input-error';
|
||||
import PasskeyVerify from '@/components/passkey-verify';
|
||||
import PasswordInput from '@/components/password-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { store } from '@/routes/password/confirm';
|
||||
|
||||
export default function ConfirmPassword() {
|
||||
return (
|
||||
<>
|
||||
<Head title="Confirm password" />
|
||||
|
||||
<PasskeyVerify
|
||||
routes={{
|
||||
options: confirmOptions(),
|
||||
submit: confirmStore(),
|
||||
}}
|
||||
label="Confirm with passkey"
|
||||
loadingLabel="Confirming..."
|
||||
separator="Or confirm with password"
|
||||
/>
|
||||
|
||||
<Form {...store.form()} resetOnSuccess={['password']}>
|
||||
{({ processing, errors }) => (
|
||||
<div className="space-y-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={processing}
|
||||
data-test="confirm-password-button"
|
||||
>
|
||||
{processing && <Spinner />}
|
||||
Confirm password
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ConfirmPassword.layout = {
|
||||
title: 'Confirm password',
|
||||
description:
|
||||
'This is a secure area of the application. Please confirm your password before continuing.',
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
// Components
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { LoaderCircle } from 'lucide-react';
|
||||
import InputError from '@/components/input-error';
|
||||
import TextLink from '@/components/text-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { login } from '@/routes';
|
||||
import { email } from '@/routes/password';
|
||||
|
||||
export default function ForgotPassword({ status }: { status?: string }) {
|
||||
return (
|
||||
<>
|
||||
<Head title="Forgot password" />
|
||||
|
||||
{status && (
|
||||
<div className="mb-4 text-center text-sm font-medium text-green-600">
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
<Form {...email.form()}>
|
||||
{({ processing, errors }) => (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="off"
|
||||
autoFocus
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
|
||||
<div className="my-6 flex items-center justify-start">
|
||||
<Button
|
||||
className="w-full"
|
||||
disabled={processing}
|
||||
data-test="email-password-reset-link-button"
|
||||
>
|
||||
{processing && (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
)}
|
||||
Email password reset link
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<div className="space-x-1 text-center text-sm text-muted-foreground">
|
||||
<span>Or, return to</span>
|
||||
<TextLink href={login()}>log in</TextLink>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ForgotPassword.layout = {
|
||||
title: 'Forgot password',
|
||||
description: 'Enter your email to receive a password reset link',
|
||||
};
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import InputError from '@/components/input-error';
|
||||
import PasskeyVerify from '@/components/passkey-verify';
|
||||
import PasswordInput from '@/components/password-input';
|
||||
import TextLink from '@/components/text-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { store } from '@/routes/login';
|
||||
import { request } from '@/routes/password';
|
||||
|
||||
type Props = {
|
||||
status?: string;
|
||||
canResetPassword: boolean;
|
||||
};
|
||||
|
||||
export default function Login({ status, canResetPassword }: Props) {
|
||||
return (
|
||||
<>
|
||||
<Head title="Log in" />
|
||||
|
||||
<PasskeyVerify />
|
||||
|
||||
<Form
|
||||
{...store.form()}
|
||||
resetOnSuccess={['password']}
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<>
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
autoFocus
|
||||
tabIndex={1}
|
||||
autoComplete="email"
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
<InputError message={errors.email} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<div className="flex items-center">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
{canResetPassword && (
|
||||
<TextLink
|
||||
href={request()}
|
||||
className="ml-auto text-sm"
|
||||
tabIndex={5}
|
||||
>
|
||||
Forgot your password?
|
||||
</TextLink>
|
||||
)}
|
||||
</div>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
required
|
||||
tabIndex={2}
|
||||
autoComplete="current-password"
|
||||
placeholder="Password"
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
id="remember"
|
||||
name="remember"
|
||||
tabIndex={3}
|
||||
/>
|
||||
<Label htmlFor="remember">Remember me</Label>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-4 w-full"
|
||||
tabIndex={4}
|
||||
disabled={processing}
|
||||
data-test="login-button"
|
||||
>
|
||||
{processing && <Spinner />}
|
||||
Log in
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
Contact your administrator to request access.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
{status && (
|
||||
<div className="mb-4 text-center text-sm font-medium text-green-600">
|
||||
{status}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Login.layout = {
|
||||
title: 'Log in to your account',
|
||||
description: 'Enter your email and password below to log in',
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
import { Form, Head } from '@inertiajs/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';
|
||||
import { update } from '@/routes/password';
|
||||
|
||||
type Props = {
|
||||
token: string;
|
||||
email: string;
|
||||
passwordRules: string;
|
||||
};
|
||||
|
||||
export default function ResetPassword({ token, email, passwordRules }: Props) {
|
||||
return (
|
||||
<>
|
||||
<Head title="Reset password" />
|
||||
|
||||
<Form
|
||||
{...update.form()}
|
||||
transform={(data) => ({ ...data, token, email })}
|
||||
resetOnSuccess={['password', 'password_confirmation']}
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<div className="grid gap-6">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
value={email}
|
||||
className="mt-1 block w-full"
|
||||
readOnly
|
||||
/>
|
||||
<InputError
|
||||
message={errors.email}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<PasswordInput
|
||||
id="password"
|
||||
name="password"
|
||||
autoComplete="new-password"
|
||||
className="mt-1 block w-full"
|
||||
autoFocus
|
||||
placeholder="Password"
|
||||
passwordrules={passwordRules}
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password_confirmation">
|
||||
Confirm password
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="password_confirmation"
|
||||
name="password_confirmation"
|
||||
autoComplete="new-password"
|
||||
className="mt-1 block w-full"
|
||||
placeholder="Confirm password"
|
||||
passwordrules={passwordRules}
|
||||
/>
|
||||
<InputError
|
||||
message={errors.password_confirmation}
|
||||
className="mt-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="mt-4 w-full"
|
||||
disabled={processing}
|
||||
data-test="reset-password-button"
|
||||
>
|
||||
{processing && <Spinner />}
|
||||
Reset password
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
ResetPassword.layout = {
|
||||
title: 'Reset password',
|
||||
description: 'Please enter your new password below',
|
||||
};
|
||||
@@ -0,0 +1,133 @@
|
||||
import { Form, Head, setLayoutProps } from '@inertiajs/react';
|
||||
import { REGEXP_ONLY_DIGITS } from 'input-otp';
|
||||
import { useMemo, useState } from 'react';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
InputOTP,
|
||||
InputOTPGroup,
|
||||
InputOTPSlot,
|
||||
} from '@/components/ui/input-otp';
|
||||
import { OTP_MAX_LENGTH } from '@/hooks/use-two-factor-auth';
|
||||
import { store } from '@/routes/two-factor/login';
|
||||
|
||||
export default function TwoFactorChallenge() {
|
||||
const [showRecoveryInput, setShowRecoveryInput] = useState<boolean>(false);
|
||||
const [code, setCode] = useState<string>('');
|
||||
|
||||
const authConfigContent = useMemo<{
|
||||
title: string;
|
||||
description: string;
|
||||
toggleText: string;
|
||||
}>(() => {
|
||||
if (showRecoveryInput) {
|
||||
return {
|
||||
title: 'Recovery code',
|
||||
description:
|
||||
'Please confirm access to your account by entering one of your emergency recovery codes.',
|
||||
toggleText: 'login using an authentication code',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: 'Authentication code',
|
||||
description:
|
||||
'Enter the authentication code provided by your authenticator application.',
|
||||
toggleText: 'login using a recovery code',
|
||||
};
|
||||
}, [showRecoveryInput]);
|
||||
|
||||
setLayoutProps({
|
||||
title: authConfigContent.title,
|
||||
description: authConfigContent.description,
|
||||
});
|
||||
|
||||
const toggleRecoveryMode = (clearErrors: () => void): void => {
|
||||
setShowRecoveryInput(!showRecoveryInput);
|
||||
clearErrors();
|
||||
setCode('');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Two-factor authentication" />
|
||||
|
||||
<div className="space-y-6">
|
||||
<Form
|
||||
{...store.form()}
|
||||
className="space-y-4"
|
||||
resetOnError
|
||||
resetOnSuccess={!showRecoveryInput}
|
||||
>
|
||||
{({ errors, processing, clearErrors }) => (
|
||||
<>
|
||||
{showRecoveryInput ? (
|
||||
<>
|
||||
<Input
|
||||
name="recovery_code"
|
||||
type="text"
|
||||
placeholder="Enter recovery code"
|
||||
autoFocus={showRecoveryInput}
|
||||
required
|
||||
/>
|
||||
<InputError
|
||||
message={errors.recovery_code}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center space-y-3 text-center">
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<InputOTP
|
||||
name="code"
|
||||
maxLength={OTP_MAX_LENGTH}
|
||||
value={code}
|
||||
onChange={(value) => setCode(value)}
|
||||
disabled={processing}
|
||||
pattern={REGEXP_ONLY_DIGITS}
|
||||
autoFocus
|
||||
>
|
||||
<InputOTPGroup>
|
||||
{Array.from(
|
||||
{ length: OTP_MAX_LENGTH },
|
||||
(_, index) => (
|
||||
<InputOTPSlot
|
||||
key={index}
|
||||
index={index}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
</InputOTPGroup>
|
||||
</InputOTP>
|
||||
</div>
|
||||
<InputError message={errors.code} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={processing}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
|
||||
<div className="text-center text-sm text-muted-foreground">
|
||||
<span>or you can </span>
|
||||
<button
|
||||
type="button"
|
||||
className="cursor-pointer text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
|
||||
onClick={() =>
|
||||
toggleRecoveryMode(clearErrors)
|
||||
}
|
||||
>
|
||||
{authConfigContent.toggleText}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Components
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import TextLink from '@/components/text-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { logout } from '@/routes';
|
||||
import { send } from '@/routes/verification';
|
||||
|
||||
export default function VerifyEmail({ status }: { status?: string }) {
|
||||
return (
|
||||
<>
|
||||
<Head title="Email verification" />
|
||||
|
||||
{status === 'verification-link-sent' && (
|
||||
<div className="mb-4 text-center text-sm font-medium text-green-600">
|
||||
A new verification link has been sent to the email address
|
||||
you provided during registration.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Form {...send.form()} className="space-y-6 text-center">
|
||||
{({ processing }) => (
|
||||
<>
|
||||
<Button disabled={processing} variant="secondary">
|
||||
{processing && <Spinner />}
|
||||
Resend verification email
|
||||
</Button>
|
||||
|
||||
<TextLink
|
||||
href={logout()}
|
||||
className="mx-auto block text-sm"
|
||||
>
|
||||
Log out
|
||||
</TextLink>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
VerifyEmail.layout = {
|
||||
title: 'Email verification',
|
||||
description:
|
||||
'Please verify your email address by clicking on the link we just emailed to you.',
|
||||
};
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Head, Link, usePage } from '@inertiajs/react';
|
||||
import {
|
||||
Building2,
|
||||
CheckCircle2,
|
||||
KeyRound,
|
||||
Network,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
UserCheck,
|
||||
Users,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { dashboard } from '@/routes';
|
||||
|
||||
type Props = {
|
||||
role: 'MASTER ADMIN' | 'TENANT ADMIN' | 'TENANT USER';
|
||||
tenant: {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
is_active: boolean;
|
||||
} | null;
|
||||
stats: Record<string, number | boolean>;
|
||||
};
|
||||
|
||||
const roleLabels = {
|
||||
'MASTER ADMIN': 'Master Admin Platform',
|
||||
'TENANT ADMIN': 'Tenant Admin',
|
||||
'TENANT USER': 'Tenant User',
|
||||
};
|
||||
|
||||
export default function Dashboard({ role, tenant, stats }: Props) {
|
||||
const { auth } = usePage().props;
|
||||
const isMaster = role === 'MASTER ADMIN';
|
||||
const isTenantAdmin = role === 'TENANT ADMIN';
|
||||
const cards = isMaster
|
||||
? [
|
||||
{ label: 'Total Tenant', value: stats.tenants, icon: Building2 },
|
||||
{
|
||||
label: 'Tenant Aktif',
|
||||
value: stats.activeTenants,
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
{ label: 'User Tenant', value: stats.users, icon: Users },
|
||||
{
|
||||
label: 'Lisensi Aktif',
|
||||
value: stats.activeLicenses,
|
||||
icon: KeyRound,
|
||||
},
|
||||
{
|
||||
label: 'Instalasi Terdaftar',
|
||||
value: stats.installations,
|
||||
icon: Server,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{ label: 'Total User', value: stats.users, icon: Users },
|
||||
{
|
||||
label: 'User Aktif',
|
||||
value: stats.activeUsers,
|
||||
icon: UserCheck,
|
||||
},
|
||||
{ label: 'Total Perangkat', value: stats.devices, icon: Network },
|
||||
{
|
||||
label: 'Status Tenant',
|
||||
value: tenant?.is_active ? 'Aktif' : 'Nonaktif',
|
||||
icon: CheckCircle2,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Dashboard" />
|
||||
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6">
|
||||
<div className="flex flex-col justify-between gap-4 md:flex-row md:items-end">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-primary">
|
||||
RADIQ NDM · {roleLabels[role]}
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl font-semibold tracking-tight">
|
||||
Selamat datang, {auth.user.name}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
{isMaster
|
||||
? 'Pantau tenant, instalasi, dan lisensi platform.'
|
||||
: `${tenant?.name ?? 'Tenant'} · ruang kerja pengelolaan perangkat jaringan.`}
|
||||
</p>
|
||||
</div>
|
||||
{isMaster && (
|
||||
<Button asChild>
|
||||
<Link href="/administration/tenants">
|
||||
Kelola Tenant
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{isTenantAdmin && tenant && (
|
||||
<Button asChild>
|
||||
<Link
|
||||
href={`/administration/tenants/${tenant.id}/users`}
|
||||
>
|
||||
Kelola Tenant User
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||
{cards.map((card) => (
|
||||
<Card key={card.label}>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">
|
||||
{card.label}
|
||||
</CardTitle>
|
||||
<card.icon className="size-5 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-semibold">
|
||||
{String(card.value)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{isMaster
|
||||
? 'Kontrol Platform'
|
||||
: isTenantAdmin
|
||||
? 'Pengelolaan Tenant'
|
||||
: 'Akses Perangkat'}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="text-sm text-muted-foreground">
|
||||
{isMaster &&
|
||||
'Master Admin memiliki kendali tenant dan fondasi lisensi. Aktivasi serta perpanjangan lisensi akan tersedia pada tahap lisensi berikutnya.'}
|
||||
{isTenantAdmin &&
|
||||
'Anda adalah akun utama tenant. Anda dapat mengelola Tenant User dan nantinya menetapkan aturan penggunaan perangkat untuk mereka.'}
|
||||
{role === 'TENANT USER' &&
|
||||
(stats.canAddDevice
|
||||
? 'Anda diizinkan menambahkan dan mengelola perangkat sesuai kebijakan Tenant Admin. Modul perangkat akan tersedia pada tahap berikutnya.'
|
||||
: 'Akses perangkat Anda mengikuti kebijakan yang ditetapkan oleh Tenant Admin.')}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Dashboard.layout = { breadcrumbs: [{ title: 'Dashboard', href: dashboard() }] };
|
||||
@@ -0,0 +1,199 @@
|
||||
import { Form, Head, Link } from '@inertiajs/react';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
type Device = {
|
||||
id: number;
|
||||
name: string;
|
||||
status: string;
|
||||
activation_status: string;
|
||||
vendor: { name: string };
|
||||
};
|
||||
type Assignment = { device_id: number; group_name: string };
|
||||
type DeviceUser = {
|
||||
id: number;
|
||||
display_name: string;
|
||||
username: string;
|
||||
is_enabled: boolean;
|
||||
notes?: string;
|
||||
assignments: Assignment[];
|
||||
};
|
||||
type Props = { deviceUser: DeviceUser | null; devices: Device[] };
|
||||
export default function DeviceUserForm({ deviceUser, devices }: Props) {
|
||||
const edit = !!deviceUser;
|
||||
const selected = deviceUser?.assignments.map((a) => a.device_id) ?? [];
|
||||
const group = deviceUser?.assignments[0]?.group_name ?? 'RADIQ-NOC';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head
|
||||
title={edit ? 'Edit User Perangkat' : 'Tambah User Perangkat'}
|
||||
/>
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 p-4 md:p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{edit ? 'Edit' : 'Tambah'} User Perangkat
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Perubahan dikirim melalui queue. Perangkat offline akan
|
||||
berstatus pending.
|
||||
</p>
|
||||
</div>
|
||||
<Form
|
||||
action={
|
||||
edit
|
||||
? `/device-users/${deviceUser.id}`
|
||||
: '/device-users'
|
||||
}
|
||||
method={edit ? 'put' : 'post'}
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Identitas dan Target</CardTitle>
|
||||
<CardDescription>
|
||||
Password tidak pernah ditampilkan kembali
|
||||
atau dimasukkan dalam payload queue.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-5 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label>Nama</Label>
|
||||
<Input
|
||||
name="display_name"
|
||||
defaultValue={deviceUser?.display_name}
|
||||
required
|
||||
/>
|
||||
<InputError message={errors.display_name} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Username Perangkat</Label>
|
||||
<Input
|
||||
name="username"
|
||||
defaultValue={deviceUser?.username}
|
||||
required
|
||||
/>
|
||||
<InputError message={errors.username} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
{edit
|
||||
? 'Password Baru (kosongkan jika tetap)'
|
||||
: 'Password'}
|
||||
</Label>
|
||||
<Input
|
||||
name="password"
|
||||
type="password"
|
||||
required={!edit}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Konfirmasi Password</Label>
|
||||
<Input
|
||||
name="password_confirmation"
|
||||
type="password"
|
||||
required={!edit}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Group</Label>
|
||||
<select
|
||||
name="group_name"
|
||||
defaultValue={group}
|
||||
className="h-10 rounded-md border bg-background px-3"
|
||||
>
|
||||
<option value="RADIQ-READ">
|
||||
RADIQ-READ
|
||||
</option>
|
||||
<option value="RADIQ-WRITE">
|
||||
RADIQ-WRITE
|
||||
</option>
|
||||
<option value="RADIQ-NOC">
|
||||
RADIQ-NOC
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 self-end pb-2">
|
||||
<input
|
||||
type="hidden"
|
||||
name="is_enabled"
|
||||
value="0"
|
||||
/>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="is_enabled"
|
||||
value="1"
|
||||
defaultChecked={
|
||||
deviceUser?.is_enabled ?? true
|
||||
}
|
||||
/>
|
||||
User aktif di perangkat
|
||||
</label>
|
||||
<div className="md:col-span-2">
|
||||
<Label>Pilih Perangkat</Label>
|
||||
<div className="mt-2 grid max-h-80 gap-2 overflow-y-auto rounded-lg border p-3 md:grid-cols-2">
|
||||
{devices.map((device) => (
|
||||
<label
|
||||
key={device.id}
|
||||
className="flex items-center gap-3 rounded border p-3"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="device_ids[]"
|
||||
value={device.id}
|
||||
defaultChecked={selected.includes(
|
||||
device.id,
|
||||
)}
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium">
|
||||
{device.name}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{device.vendor.name} ·{' '}
|
||||
{device.status} ·{' '}
|
||||
{
|
||||
device.activation_status
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<InputError message={errors.device_ids} />
|
||||
</div>
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label>Catatan</Label>
|
||||
<textarea
|
||||
name="notes"
|
||||
defaultValue={deviceUser?.notes}
|
||||
className="min-h-20 rounded-md border bg-background p-3"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 md:col-span-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/device-users">Batal</Link>
|
||||
</Button>
|
||||
<Button disabled={processing}>
|
||||
Simpan & Sinkronkan
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { Head, Link, router, usePage } from '@inertiajs/react';
|
||||
import { Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
type Assignment = {
|
||||
id: number;
|
||||
group_name: string;
|
||||
sync_status: string;
|
||||
error_code?: string;
|
||||
message?: string;
|
||||
device: { id: number; name: string };
|
||||
};
|
||||
type DeviceUser = {
|
||||
id: number;
|
||||
display_name: string;
|
||||
username: string;
|
||||
is_enabled: boolean;
|
||||
assignments: Assignment[];
|
||||
};
|
||||
type Props = {
|
||||
deviceUsers: { data: DeviceUser[] };
|
||||
devices: { id: number; name: string }[];
|
||||
filters: { search?: string; device_id?: string };
|
||||
};
|
||||
const colors: Record<string, string> = {
|
||||
synced: 'bg-emerald-100 text-emerald-800',
|
||||
pending: 'bg-amber-100 text-amber-800',
|
||||
queued: 'bg-blue-100 text-blue-800',
|
||||
processing: 'bg-blue-100 text-blue-800',
|
||||
failed: 'bg-red-100 text-red-800',
|
||||
unsupported: 'bg-slate-200 text-slate-800',
|
||||
};
|
||||
export default function DeviceUsers({ deviceUsers, devices, filters }: Props) {
|
||||
const { auth } = usePage().props;
|
||||
const search = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
router.get(
|
||||
'/device-users',
|
||||
Object.fromEntries(new FormData(e.currentTarget)),
|
||||
{ preserveState: true },
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Users Perangkat" />
|
||||
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6">
|
||||
<div className="flex items-end justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
Users Perangkat
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Kelola satu akun pada banyak perangkat dan pantau
|
||||
status sinkronisasinya.
|
||||
</p>
|
||||
</div>
|
||||
{auth.permissions.includes('device.user.create') && (
|
||||
<Button asChild>
|
||||
<Link href="/device-users/create">
|
||||
<Plus className="size-4" />
|
||||
Tambah User Perangkat
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<form
|
||||
onSubmit={search}
|
||||
className="mb-5 grid gap-3 md:grid-cols-3"
|
||||
>
|
||||
<Input
|
||||
name="search"
|
||||
defaultValue={filters.search}
|
||||
placeholder="Cari nama atau username..."
|
||||
/>
|
||||
<select
|
||||
name="device_id"
|
||||
defaultValue={filters.device_id ?? ''}
|
||||
className="h-10 rounded-md border bg-background px-3"
|
||||
>
|
||||
<option value="">Semua perangkat</option>
|
||||
{devices.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{d.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Button variant="outline">Filter</Button>
|
||||
</form>
|
||||
<div className="space-y-4">
|
||||
{deviceUsers.data.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="rounded-lg border p-4"
|
||||
>
|
||||
<div className="flex flex-wrap justify-between gap-3">
|
||||
<div>
|
||||
<div className="font-semibold">
|
||||
{user.display_name}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{user.username} ·{' '}
|
||||
{user.is_enabled
|
||||
? 'Aktif'
|
||||
: 'Nonaktif'}{' '}
|
||||
· password terenkripsi
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{auth.permissions.includes(
|
||||
'device.user.update',
|
||||
) && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`/device-users/${user.id}/edit`}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
Edit
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{auth.permissions.includes(
|
||||
'device.user.delete',
|
||||
) && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
confirm(
|
||||
'Hapus user dari seluruh perangkat?',
|
||||
) &&
|
||||
router.delete(
|
||||
`/device-users/${user.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||||
{user.assignments.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="rounded border p-3 text-sm"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">
|
||||
{item.device.name}
|
||||
</span>
|
||||
<span
|
||||
className={`rounded-full px-2 py-1 text-xs ${colors[item.sync_status] ?? 'bg-muted'}`}
|
||||
>
|
||||
{item.sync_status}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{item.group_name}
|
||||
{item.error_code
|
||||
? ` · ${item.error_code}`
|
||||
: ''}
|
||||
</div>
|
||||
{['pending', 'failed'].includes(
|
||||
item.sync_status,
|
||||
) &&
|
||||
auth.permissions.includes(
|
||||
'device.user.update',
|
||||
) && (
|
||||
<Button
|
||||
className="mt-2"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
router.post(
|
||||
`/device-user-assignments/${item.id}/sync`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<RefreshCw className="size-3" />
|
||||
Coba Lagi
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{deviceUsers.data.length === 0 && (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
Belum ada user perangkat.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
import { Form, Head, Link } from '@inertiajs/react';
|
||||
import { useState } from 'react';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
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;
|
||||
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 [vendorSlug, setVendorSlug] = useState(initialVendor?.slug ?? '');
|
||||
const fields = [
|
||||
['name', 'Nama Perangkat'],
|
||||
['management_address', 'IP / Domain Management'],
|
||||
['location', 'Lokasi'],
|
||||
];
|
||||
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]],
|
||||
};
|
||||
const flexibleProtocols: Array<[string, string, number]> = [
|
||||
['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;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title={editing ? 'Edit Perangkat' : 'Tambah Perangkat'} />
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 p-4 md:p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{editing ? 'Edit' : 'Tambah'} Perangkat
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Data teknis lainnya akan diambil otomatis saat perangkat
|
||||
pertama kali terhubung.
|
||||
</p>
|
||||
</div>
|
||||
<Form
|
||||
action={editing ? `/devices/${device.id}` : '/devices'}
|
||||
method={editing ? 'put' : 'post'}
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Informasi Perangkat</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-5 md:grid-cols-2">
|
||||
{fields.map(([name, label]) => (
|
||||
<div key={name} className="grid gap-2">
|
||||
<Label>{label}</Label>
|
||||
<Input
|
||||
name={name}
|
||||
type="text"
|
||||
defaultValue={String(
|
||||
device?.[name] ?? '',
|
||||
)}
|
||||
required={[
|
||||
'name',
|
||||
'management_address',
|
||||
].includes(name)}
|
||||
/>
|
||||
<InputError message={errors[name]} />
|
||||
</div>
|
||||
))}
|
||||
<div className="grid gap-2">
|
||||
<Label>Vendor</Label>
|
||||
<select
|
||||
name="device_vendor_id"
|
||||
defaultValue={String(
|
||||
device?.device_vendor_id ?? '',
|
||||
)}
|
||||
required
|
||||
onChange={(event) => {
|
||||
const selected = vendors.find((vendor) => vendor.id === Number(event.target.value));
|
||||
setVendorSlug(selected?.slug ?? '');
|
||||
}}
|
||||
className="h-10 rounded-md border bg-background px-3"
|
||||
>
|
||||
<option value="">Pilih vendor</option>
|
||||
{vendors.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<InputError
|
||||
message={errors.device_vendor_id}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Tipe</Label>
|
||||
<select
|
||||
name="device_type_id"
|
||||
defaultValue={String(
|
||||
device?.device_type_id ?? '',
|
||||
)}
|
||||
required
|
||||
className="h-10 rounded-md border bg-background px-3"
|
||||
>
|
||||
<option value="">Pilih tipe</option>
|
||||
{types.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<InputError
|
||||
message={errors.device_type_id}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Model</Label>
|
||||
<select
|
||||
name="device_model_id"
|
||||
defaultValue={String(
|
||||
device?.device_model_id ?? '',
|
||||
)}
|
||||
className="h-10 rounded-md border bg-background px-3"
|
||||
>
|
||||
<option value="">Tanpa model</option>
|
||||
{models.map((x) => (
|
||||
<option key={x.id} value={x.id}>
|
||||
{x.name}
|
||||
</option>
|
||||
))}
|
||||
</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} />
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2 md:col-span-2">
|
||||
<Label>Catatan</Label>
|
||||
<textarea
|
||||
name="notes"
|
||||
defaultValue={String(
|
||||
device?.notes ?? '',
|
||||
)}
|
||||
className="min-h-24 rounded-md border bg-background p-3"
|
||||
/>
|
||||
</div>
|
||||
{!editing && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>Username Awal</Label>
|
||||
<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} />
|
||||
</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} />
|
||||
</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" />
|
||||
<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>
|
||||
</>
|
||||
)}
|
||||
<div className="flex justify-end gap-3 md:col-span-2">
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/devices">Batal</Link>
|
||||
</Button>
|
||||
<Button disabled={processing}>
|
||||
Simpan Perangkat
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { Form, Head, Link, router, usePage } from '@inertiajs/react';
|
||||
import { Eye, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
type Device = {
|
||||
id: number;
|
||||
name: string;
|
||||
management_address: string;
|
||||
management_port: number;
|
||||
connection_type: string;
|
||||
connection_ports?: Record<string, number>;
|
||||
status: string;
|
||||
location?: string;
|
||||
vendor: { name: string };
|
||||
type: { name: string };
|
||||
model?: { name: string };
|
||||
creator?: { name: string };
|
||||
user_assignments_count: number;
|
||||
};
|
||||
type Props = {
|
||||
devices: {
|
||||
data: Device[];
|
||||
links: { url: string | null; label: string; active: boolean }[];
|
||||
};
|
||||
vendors: { id: number; name: string }[];
|
||||
filters: { search?: string; vendor?: string; status?: string };
|
||||
policy: {
|
||||
tenant_user_can_create: boolean;
|
||||
tenant_user_can_update_own: boolean;
|
||||
tenant_user_can_delete_own: boolean;
|
||||
};
|
||||
isTenantAdmin: boolean;
|
||||
};
|
||||
|
||||
export default function DeviceIndex({
|
||||
devices,
|
||||
vendors,
|
||||
filters,
|
||||
policy,
|
||||
isTenantAdmin,
|
||||
}: Props) {
|
||||
const { auth } = usePage().props;
|
||||
const canCreate =
|
||||
auth.permissions.includes('device.create') &&
|
||||
(isTenantAdmin || policy.tenant_user_can_create);
|
||||
const search = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
router.get('/devices', Object.fromEntries(data), {
|
||||
preserveState: true,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Perangkat" />
|
||||
<div className="flex flex-1 flex-col gap-6 p-4 md:p-6">
|
||||
<div className="flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
Perangkat Jaringan
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Inventaris perangkat tenant yang terisolasi dan
|
||||
aman.
|
||||
</p>
|
||||
</div>
|
||||
{canCreate && (
|
||||
<Button asChild>
|
||||
<Link href="/devices/create">
|
||||
<Plus className="size-4" />
|
||||
Tambah Perangkat
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{isTenantAdmin && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
Aturan Tenant User
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form
|
||||
action="/device-policy"
|
||||
method="put"
|
||||
className="flex flex-wrap gap-5 text-sm"
|
||||
>
|
||||
{[
|
||||
[
|
||||
'tenant_user_can_create',
|
||||
'Boleh menambah perangkat',
|
||||
],
|
||||
[
|
||||
'tenant_user_can_update_own',
|
||||
'Boleh mengubah perangkat sendiri',
|
||||
],
|
||||
[
|
||||
'tenant_user_can_delete_own',
|
||||
'Boleh menghapus perangkat sendiri',
|
||||
],
|
||||
].map(([name, label]) => (
|
||||
<label
|
||||
key={name}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<input
|
||||
type="hidden"
|
||||
name={name}
|
||||
value="0"
|
||||
/>
|
||||
<input
|
||||
type="checkbox"
|
||||
name={name}
|
||||
value="1"
|
||||
defaultChecked={
|
||||
policy[
|
||||
name as keyof typeof policy
|
||||
]
|
||||
}
|
||||
/>
|
||||
{label}
|
||||
</label>
|
||||
))}
|
||||
<Button size="sm">Simpan Aturan</Button>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<form
|
||||
onSubmit={search}
|
||||
className="mb-5 grid gap-3 md:grid-cols-4"
|
||||
>
|
||||
<Input
|
||||
name="search"
|
||||
defaultValue={filters.search}
|
||||
placeholder="Cari nama, alamat, lokasi..."
|
||||
/>
|
||||
<select
|
||||
name="vendor"
|
||||
defaultValue={filters.vendor ?? ''}
|
||||
className="h-10 rounded-md border bg-background px-3"
|
||||
>
|
||||
<option value="">Semua vendor</option>
|
||||
{vendors.map((v) => (
|
||||
<option key={v.id} value={v.id}>
|
||||
{v.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
name="status"
|
||||
defaultValue={filters.status ?? ''}
|
||||
className="h-10 rounded-md border bg-background px-3"
|
||||
>
|
||||
<option value="">Semua status</option>
|
||||
<option value="online">Online</option>
|
||||
<option value="offline">Offline</option>
|
||||
<option value="unknown">Unknown</option>
|
||||
</select>
|
||||
<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">
|
||||
<colgroup>
|
||||
<col className="w-[20%]" />
|
||||
<col className="w-[18%]" />
|
||||
<col className="w-[16%]" />
|
||||
<col className="w-[14%]" />
|
||||
<col className="w-[10%]" />
|
||||
<col className="w-[10%]" />
|
||||
<col className="w-[12%]" />
|
||||
</colgroup>
|
||||
<thead>
|
||||
<tr className="border-b text-left text-muted-foreground">
|
||||
<th className="p-3">Perangkat</th>
|
||||
<th className="p-3">Alamat</th>
|
||||
<th className="p-3">User</th>
|
||||
<th className="p-3">Vendor / Tipe</th>
|
||||
<th className="p-3">Lokasi</th>
|
||||
<th className="p-3">Status</th>
|
||||
<th className="p-3 text-right">Aksi</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices.data.map((device) => (
|
||||
<tr
|
||||
key={device.id}
|
||||
className="border-b"
|
||||
>
|
||||
<td className="p-3">
|
||||
<div className="font-medium">
|
||||
{device.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{device.model?.name ??
|
||||
'Model belum ditentukan'}
|
||||
</div>
|
||||
</td>
|
||||
<td className="break-words p-3">
|
||||
<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(' · ')}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<Link
|
||||
className="font-medium text-primary hover:underline"
|
||||
href={`/device-users?device_id=${device.id}`}
|
||||
>
|
||||
{
|
||||
device.user_assignments_count
|
||||
}{' '}
|
||||
user
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
{device.vendor.name} ·{' '}
|
||||
{device.type.name}
|
||||
</td>
|
||||
<td>{device.location ?? '-'}</td>
|
||||
<td>
|
||||
<span className="rounded-full bg-muted px-2 py-1 text-xs uppercase">
|
||||
{device.status}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`/devices/${device.id}`}
|
||||
>
|
||||
<Eye className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
{auth.permissions.includes(
|
||||
'device.update',
|
||||
) && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`/devices/${device.id}/edit`}
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
{auth.permissions.includes(
|
||||
'device.delete',
|
||||
) && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
confirm(
|
||||
'Hapus perangkat ini?',
|
||||
) &&
|
||||
router.delete(
|
||||
`/devices/${device.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{devices.data.length === 0 && (
|
||||
<div className="py-12 text-center text-muted-foreground">
|
||||
Belum ada perangkat.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
type Props = {
|
||||
setting: {
|
||||
base_username: string;
|
||||
use_tls: boolean;
|
||||
verify_tls: boolean;
|
||||
connection_timeout: number;
|
||||
} | null;
|
||||
hasPassword: boolean;
|
||||
encryptionKey: {
|
||||
version: number;
|
||||
source: string;
|
||||
created_at: string;
|
||||
} | null;
|
||||
devices: {
|
||||
id: number;
|
||||
name: string;
|
||||
base_sync_status: string;
|
||||
base_sync_error_code?: string;
|
||||
base_sync_message?: string;
|
||||
base_synced_at?: string;
|
||||
vendor: { name: string };
|
||||
}[];
|
||||
};
|
||||
export default function DeviceSettings({
|
||||
setting,
|
||||
hasPassword,
|
||||
encryptionKey,
|
||||
devices,
|
||||
}: Props) {
|
||||
const [keyMode, setKeyMode] = useState('generated');
|
||||
const [loginPassword, setLoginPassword] = useState('');
|
||||
const [revealedPassword, setRevealedPassword] = useState('');
|
||||
const [revealError, setRevealError] = useState('');
|
||||
const [revealing, setRevealing] = useState(false);
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const revealBasePassword = async () => {
|
||||
setRevealing(true);
|
||||
setRevealError('');
|
||||
setRevealedPassword('');
|
||||
|
||||
try {
|
||||
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 },
|
||||
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.');
|
||||
}
|
||||
|
||||
setRevealedPassword(result.password);
|
||||
setLoginPassword('');
|
||||
|
||||
if (hideTimer.current) {
|
||||
clearTimeout(hideTimer.current);
|
||||
}
|
||||
|
||||
hideTimer.current = setTimeout(() => setRevealedPassword(''), 30000);
|
||||
} catch (error) {
|
||||
setRevealError(error instanceof Error ? error.message : 'Password tidak dapat ditampilkan.');
|
||||
} finally {
|
||||
setRevealing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Pengaturan Perangkat" />
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-1 flex-col gap-6 p-4 md:p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
Base User Perangkat
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Akun management terpusat untuk seluruh perangkat tenant.
|
||||
Perubahan username atau password akan disinkronkan ke
|
||||
semua perangkat yang aktif.
|
||||
</p>
|
||||
</div>
|
||||
<Form action="/device-settings" method="put">
|
||||
{({ processing, errors }) => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
Template Base User Tenant
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Password disimpan terenkripsi dan tidak
|
||||
pernah ditampilkan kembali.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-5 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label>Base Username</Label>
|
||||
<Input
|
||||
name="base_username"
|
||||
defaultValue={
|
||||
setting?.base_username ??
|
||||
'radiq-manager'
|
||||
}
|
||||
required
|
||||
/>
|
||||
<InputError
|
||||
message={errors.base_username}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Connection Timeout (detik)</Label>
|
||||
<Input
|
||||
name="connection_timeout"
|
||||
type="number"
|
||||
min="3"
|
||||
max="30"
|
||||
defaultValue={
|
||||
setting?.connection_timeout ?? 10
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
{hasPassword
|
||||
? 'Password Baru (kosongkan jika tetap)'
|
||||
: 'Base Password'}
|
||||
</Label>
|
||||
<Input
|
||||
name="base_password"
|
||||
type="password"
|
||||
required={!hasPassword}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.base_password}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Konfirmasi Password</Label>
|
||||
<Input
|
||||
name="base_password_confirmation"
|
||||
type="password"
|
||||
required={!hasPassword}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="hidden"
|
||||
name="use_tls"
|
||||
value="0"
|
||||
/>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="use_tls"
|
||||
value="1"
|
||||
defaultChecked={setting?.use_tls}
|
||||
/>
|
||||
Gunakan API-SSL/TLS (umumnya port 8729)
|
||||
</label>
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="hidden"
|
||||
name="verify_tls"
|
||||
value="0"
|
||||
/>
|
||||
<input
|
||||
type="checkbox"
|
||||
name="verify_tls"
|
||||
value="1"
|
||||
defaultChecked={
|
||||
setting?.verify_tls ?? true
|
||||
}
|
||||
/>
|
||||
Verifikasi sertifikat TLS
|
||||
</label>
|
||||
<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
|
||||
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>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 md:flex-row">
|
||||
<Input
|
||||
type="password"
|
||||
value={loginPassword}
|
||||
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>
|
||||
</div>
|
||||
{revealError && <p className="text-destructive">{revealError}</p>}
|
||||
{revealedPassword && (
|
||||
<Input value={revealedPassword} readOnly className="font-mono" aria-label="Base Password" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end md:col-span-2">
|
||||
<Button disabled={processing}>
|
||||
Simpan Pengaturan
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Form>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Status Rotasi Base User</CardTitle>
|
||||
<CardDescription>
|
||||
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>
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
{devices.length === 0 && <p className="py-4 text-center text-sm text-muted-foreground">Belum ada perangkat.</p>}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Form
|
||||
action="/device-settings/rotate-key"
|
||||
method="post"
|
||||
resetOnSuccess
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Encryption Key Tenant</CardTitle>
|
||||
<CardDescription>
|
||||
Key aktif versi{' '}
|
||||
{encryptionKey?.version ?? 1} (
|
||||
{encryptionKey?.source ?? 'generated'}).
|
||||
Rotasi mengenkripsi ulang seluruh password
|
||||
perangkat dan Base Password pada database.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-5 md:grid-cols-2">
|
||||
<div className="grid gap-2">
|
||||
<Label>Metode Key Baru</Label>
|
||||
<select
|
||||
name="mode"
|
||||
value={keyMode}
|
||||
onChange={(event) =>
|
||||
setKeyMode(event.target.value)
|
||||
}
|
||||
className="h-10 rounded-md border bg-background px-3"
|
||||
>
|
||||
<option value="generated">
|
||||
Generate otomatis (direkomendasikan)
|
||||
</option>
|
||||
<option value="manual">
|
||||
Masukkan manual
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Password Tenant Admin</Label>
|
||||
<Input
|
||||
name="current_password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.current_password}
|
||||
/>
|
||||
</div>
|
||||
{keyMode === 'manual' && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>
|
||||
Key Manual (minimal 32 karakter)
|
||||
</Label>
|
||||
<Input
|
||||
name="manual_key"
|
||||
type="password"
|
||||
minLength={32}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<InputError
|
||||
message={errors.manual_key}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Konfirmasi Key Manual</Label>
|
||||
<Input
|
||||
name="manual_key_confirmation"
|
||||
type="password"
|
||||
minLength={32}
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 md:col-span-2">
|
||||
Rotasi key tidak mengubah password yang
|
||||
berada di router/OLT. Perubahan password
|
||||
perangkat dilakukan melalui operasi rotasi
|
||||
credential terpisah dan harus diverifikasi
|
||||
login ulang.
|
||||
</div>
|
||||
<div className="flex justify-end md:col-span-2">
|
||||
<Button
|
||||
disabled={processing}
|
||||
variant="destructive"
|
||||
>
|
||||
Rotasi Encryption Key
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { Form, Head, Link, router, usePage } from '@inertiajs/react';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
|
||||
type Credential = {
|
||||
id: number;
|
||||
name: string;
|
||||
username: string;
|
||||
connection_type: string;
|
||||
privilege_type: string;
|
||||
is_master: boolean;
|
||||
is_active: boolean;
|
||||
};
|
||||
type Props = {
|
||||
device: {
|
||||
id: number;
|
||||
name: string;
|
||||
hostname?: string;
|
||||
management_address: string;
|
||||
management_port: number;
|
||||
connection_type: string;
|
||||
connection_ports?: Record<string, number>;
|
||||
status: string;
|
||||
location?: string;
|
||||
serial_number?: string;
|
||||
firmware_version?: string;
|
||||
software_version?: string;
|
||||
device_facts?: Record<string, string>;
|
||||
notes?: string;
|
||||
vendor: { name: string; slug: string };
|
||||
activation_status: string;
|
||||
activation_message?: string;
|
||||
type: { name: string };
|
||||
model?: { name: string };
|
||||
creator?: { name: string };
|
||||
};
|
||||
credentials: Credential[];
|
||||
};
|
||||
|
||||
export default function DeviceShow({ device, credentials }: Props) {
|
||||
const { auth, errors } = usePage().props;
|
||||
const rows = [
|
||||
['Vendor', device.vendor.name],
|
||||
['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(' · ')],
|
||||
['Hostname', device.hostname ?? '-'],
|
||||
['Serial Number', device.serial_number ?? '-'],
|
||||
['Versi RouterOS', device.software_version ?? '-'],
|
||||
['Firmware', device.firmware_version ?? '-'],
|
||||
[
|
||||
'Board / Model Terdeteksi',
|
||||
device.device_facts?.['board-name'] ??
|
||||
device.device_facts?.model ??
|
||||
'-',
|
||||
],
|
||||
['Arsitektur', device.device_facts?.['architecture-name'] ?? '-'],
|
||||
['CPU', device.device_facts?.cpu ?? '-'],
|
||||
['Jumlah CPU', device.device_facts?.['cpu-count'] ?? '-'],
|
||||
['Total Memori', device.device_facts?.['total-memory'] ?? '-'],
|
||||
['Uptime', device.device_facts?.uptime ?? '-'],
|
||||
['Lokasi', device.location ?? '-'],
|
||||
['Status', device.status],
|
||||
['Dibuat Oleh', device.creator?.name ?? '-'],
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title={device.name} />
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-1 flex-col gap-6 p-4 md:p-6">
|
||||
<div className="flex justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">
|
||||
{device.name}
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Detail inventaris perangkat
|
||||
</p>
|
||||
</div>
|
||||
{auth.permissions.includes('device.update') && (
|
||||
<Button asChild>
|
||||
<Link href={`/devices/${device.id}/edit`}>
|
||||
Edit Perangkat
|
||||
</Link>
|
||||
</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>
|
||||
</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>
|
||||
)}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Informasi</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-4 md:grid-cols-2">
|
||||
{rows.map(([label, value]) => (
|
||||
<div key={label} className="border-b pb-3">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{label}
|
||||
</div>
|
||||
<div className="font-medium">{value}</div>
|
||||
</div>
|
||||
))}
|
||||
{device.notes && (
|
||||
<div className="md:col-span-2">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Catatan
|
||||
</div>
|
||||
<p>{device.notes}</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
{auth.permissions.includes('device.credential.view') && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Credential Perangkat</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left">
|
||||
<th className="py-2">Nama</th>
|
||||
<th>Username</th>
|
||||
<th>Password</th>
|
||||
<th>Protokol</th>
|
||||
<th>Privilege</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{credentials.map((item) => (
|
||||
<tr
|
||||
key={item.id}
|
||||
className="border-b"
|
||||
>
|
||||
<td className="py-3">
|
||||
{item.name}
|
||||
{item.is_master && (
|
||||
<span className="ml-2 rounded bg-amber-100 px-2 py-1 text-xs text-amber-800">
|
||||
MASTER
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{item.username}</td>
|
||||
<td>••••••••••••</td>
|
||||
<td>{item.connection_type}</td>
|
||||
<td>{item.privilege_type}</td>
|
||||
<td className="text-right">
|
||||
{auth.permissions.includes(
|
||||
'device.credential.delete',
|
||||
) && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
onClick={() =>
|
||||
confirm(
|
||||
'Hapus credential ini?',
|
||||
) &&
|
||||
router.delete(
|
||||
`/devices/${device.id}/credentials/${item.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{credentials.length === 0 && (
|
||||
<p className="py-5 text-center text-muted-foreground">
|
||||
Belum ada credential.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{auth.permissions.includes(
|
||||
'device.credential.create',
|
||||
) && (
|
||||
<Form
|
||||
action={`/devices/${device.id}/credentials`}
|
||||
method="post"
|
||||
resetOnSuccess
|
||||
className="grid gap-4 border-t pt-5 md:grid-cols-3"
|
||||
>
|
||||
<div>
|
||||
<Label>Nama Credential</Label>
|
||||
<Input name="name" required />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Username</Label>
|
||||
<Input name="username" required />
|
||||
</div>
|
||||
<div>
|
||||
<Label>Password</Label>
|
||||
<Input
|
||||
name="password"
|
||||
type="password"
|
||||
required
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Enable Password (ZTE/CLI)</Label>
|
||||
<Input
|
||||
name="enable_password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Protokol</Label>
|
||||
<select
|
||||
name="connection_type"
|
||||
defaultValue={
|
||||
device.connection_type
|
||||
}
|
||||
className="h-10 w-full rounded-md border bg-background px-3"
|
||||
>
|
||||
<option value="routeros_api">
|
||||
RouterOS API
|
||||
</option>
|
||||
<option value="ssh">SSH</option>
|
||||
<option value="telnet">
|
||||
Telnet
|
||||
</option>
|
||||
<option value="snmp">SNMP</option>
|
||||
<option value="vendor_api">
|
||||
Vendor API
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>Privilege</Label>
|
||||
<select
|
||||
name="privilege_type"
|
||||
defaultValue="custom"
|
||||
className="h-10 w-full rounded-md border bg-background px-3"
|
||||
>
|
||||
<option value="master">
|
||||
Master
|
||||
</option>
|
||||
<option value="noc">NOC</option>
|
||||
<option value="technician">
|
||||
Technician
|
||||
</option>
|
||||
<option value="monitoring">
|
||||
Monitoring
|
||||
</option>
|
||||
<option value="custom">
|
||||
Custom
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button>Simpan Credential</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
<Button variant="outline" asChild>
|
||||
<Link href="/devices">Kembali ke Daftar</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Head } from '@inertiajs/react';
|
||||
import AppearanceTabs from '@/components/appearance-tabs';
|
||||
import Heading from '@/components/heading';
|
||||
import { edit as editAppearance } from '@/routes/appearance';
|
||||
|
||||
export default function Appearance() {
|
||||
return (
|
||||
<>
|
||||
<Head title="Appearance settings" />
|
||||
|
||||
<h1 className="sr-only">Appearance settings</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Heading
|
||||
variant="small"
|
||||
title="Appearance settings"
|
||||
description="Update the appearance settings for your account"
|
||||
/>
|
||||
<AppearanceTabs />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Appearance.layout = {
|
||||
breadcrumbs: [
|
||||
{
|
||||
title: 'Appearance settings',
|
||||
href: editAppearance(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Form, Head, usePage } from '@inertiajs/react';
|
||||
import { Link } from '@inertiajs/react';
|
||||
import ProfileController from '@/actions/App/Http/Controllers/Settings/ProfileController';
|
||||
import DeleteUser from '@/components/delete-user';
|
||||
import Heading from '@/components/heading';
|
||||
import InputError from '@/components/input-error';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { edit } from '@/routes/profile';
|
||||
import { send } from '@/routes/verification';
|
||||
import type { Auth } from '@/types';
|
||||
|
||||
type PageProps = {
|
||||
auth: Auth;
|
||||
};
|
||||
|
||||
export default function Profile({
|
||||
mustVerifyEmail,
|
||||
status,
|
||||
}: {
|
||||
mustVerifyEmail: boolean;
|
||||
status?: string;
|
||||
}) {
|
||||
const { auth } = usePage<PageProps>().props;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Profile settings" />
|
||||
|
||||
<h1 className="sr-only">Profile settings</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Heading
|
||||
variant="small"
|
||||
title="Profile"
|
||||
description="Update your name and email address"
|
||||
/>
|
||||
|
||||
<Form
|
||||
{...ProfileController.update.form()}
|
||||
options={{
|
||||
preserveScroll: true,
|
||||
}}
|
||||
className="space-y-6"
|
||||
>
|
||||
{({ processing, errors }) => (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
|
||||
<Input
|
||||
id="name"
|
||||
className="mt-1 block w-full"
|
||||
defaultValue={auth.user.name}
|
||||
name="name"
|
||||
required
|
||||
autoComplete="name"
|
||||
placeholder="Full name"
|
||||
/>
|
||||
|
||||
<InputError
|
||||
className="mt-2"
|
||||
message={errors.name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email">Email address</Label>
|
||||
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
className="mt-1 block w-full"
|
||||
defaultValue={auth.user.email}
|
||||
name="email"
|
||||
required
|
||||
autoComplete="username"
|
||||
placeholder="Email address"
|
||||
/>
|
||||
|
||||
<InputError
|
||||
className="mt-2"
|
||||
message={errors.email}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{mustVerifyEmail &&
|
||||
auth.user.email_verified_at === null && (
|
||||
<div>
|
||||
<p className="-mt-4 text-sm text-muted-foreground">
|
||||
Your email address is unverified.{' '}
|
||||
<Link
|
||||
href={send()}
|
||||
as="button"
|
||||
className="text-foreground underline decoration-neutral-300 underline-offset-4 transition-colors duration-300 ease-out hover:decoration-current! dark:decoration-neutral-500"
|
||||
>
|
||||
Click here to re-send the
|
||||
verification email.
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
{status ===
|
||||
'verification-link-sent' && (
|
||||
<div className="mt-2 text-sm font-medium text-green-600">
|
||||
A new verification link has been
|
||||
sent to your email address.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
disabled={processing}
|
||||
data-test="update-profile-button"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<DeleteUser />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Profile.layout = {
|
||||
breadcrumbs: [
|
||||
{
|
||||
title: 'Profile settings',
|
||||
href: edit(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Form, Head } from '@inertiajs/react';
|
||||
import { useRef } from 'react';
|
||||
import SecurityController from '@/actions/App/Http/Controllers/Settings/SecurityController';
|
||||
import Heading from '@/components/heading';
|
||||
import InputError from '@/components/input-error';
|
||||
import type { Props as ManagePasskeysProps } from '@/components/manage-passkeys';
|
||||
import ManagePasskeys from '@/components/manage-passkeys';
|
||||
import type { Props as ManageTwoFactorProps } from '@/components/manage-two-factor';
|
||||
import ManageTwoFactor from '@/components/manage-two-factor';
|
||||
import PasswordInput from '@/components/password-input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { edit } from '@/routes/security';
|
||||
|
||||
type Props = {
|
||||
passwordRules: string;
|
||||
} & ManagePasskeysProps &
|
||||
ManageTwoFactorProps;
|
||||
|
||||
export default function Security(props: Props) {
|
||||
const passwordInput = useRef<HTMLInputElement>(null);
|
||||
const currentPasswordInput = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Security settings" />
|
||||
|
||||
<h1 className="sr-only">Security settings</h1>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Heading
|
||||
variant="small"
|
||||
title="Update password"
|
||||
description="Ensure your account is using a long, random password to stay secure"
|
||||
/>
|
||||
|
||||
<Form
|
||||
{...SecurityController.update.form()}
|
||||
options={{
|
||||
preserveScroll: true,
|
||||
}}
|
||||
resetOnError={[
|
||||
'password',
|
||||
'password_confirmation',
|
||||
'current_password',
|
||||
]}
|
||||
resetOnSuccess
|
||||
onError={(errors) => {
|
||||
if (errors.password) {
|
||||
passwordInput.current?.focus();
|
||||
}
|
||||
|
||||
if (errors.current_password) {
|
||||
currentPasswordInput.current?.focus();
|
||||
}
|
||||
}}
|
||||
className="space-y-6"
|
||||
>
|
||||
{({ errors, processing }) => (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="current_password">
|
||||
Current password
|
||||
</Label>
|
||||
|
||||
<PasswordInput
|
||||
id="current_password"
|
||||
ref={currentPasswordInput}
|
||||
name="current_password"
|
||||
className="mt-1 block w-full"
|
||||
autoComplete="current-password"
|
||||
placeholder="Current password"
|
||||
/>
|
||||
|
||||
<InputError message={errors.current_password} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password">New password</Label>
|
||||
|
||||
<PasswordInput
|
||||
id="password"
|
||||
ref={passwordInput}
|
||||
name="password"
|
||||
className="mt-1 block w-full"
|
||||
autoComplete="new-password"
|
||||
placeholder="New password"
|
||||
passwordrules={props.passwordRules}
|
||||
/>
|
||||
|
||||
<InputError message={errors.password} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password_confirmation">
|
||||
Confirm password
|
||||
</Label>
|
||||
|
||||
<PasswordInput
|
||||
id="password_confirmation"
|
||||
name="password_confirmation"
|
||||
className="mt-1 block w-full"
|
||||
autoComplete="new-password"
|
||||
placeholder="Confirm password"
|
||||
passwordrules={props.passwordRules}
|
||||
/>
|
||||
|
||||
<InputError
|
||||
message={errors.password_confirmation}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
disabled={processing}
|
||||
data-test="update-password-button"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<ManageTwoFactor
|
||||
canManageTwoFactor={props.canManageTwoFactor}
|
||||
requiresConfirmation={props.requiresConfirmation}
|
||||
twoFactorEnabled={props.twoFactorEnabled}
|
||||
/>
|
||||
|
||||
<ManagePasskeys
|
||||
canManagePasskeys={props.canManagePasskeys}
|
||||
passkeys={props.passkeys}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Security.layout = {
|
||||
breadcrumbs: [
|
||||
{
|
||||
title: 'Security settings',
|
||||
href: edit(),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Head, Link, usePage } from '@inertiajs/react';
|
||||
import { Cloud, Network, Server, ShieldCheck } from 'lucide-react';
|
||||
import { dashboard, login } from '@/routes';
|
||||
|
||||
export default function Welcome() {
|
||||
const { auth } = usePage().props;
|
||||
const features = [
|
||||
{
|
||||
icon: Network,
|
||||
title: 'Centralized NDM',
|
||||
text: 'Kelola akun dan perangkat jaringan multi-vendor dari satu tempat.',
|
||||
},
|
||||
{
|
||||
icon: ShieldCheck,
|
||||
title: 'Tenant Isolation',
|
||||
text: 'Data, role, credential, dan operasi setiap ISP dipisahkan di backend.',
|
||||
},
|
||||
{
|
||||
icon: Cloud,
|
||||
title: 'Managed Cloud',
|
||||
text: 'Gunakan layanan terpusat RADIQ NDM tanpa mengelola server aplikasi.',
|
||||
},
|
||||
{
|
||||
icon: Server,
|
||||
title: 'Self Hosted',
|
||||
text: 'Instal di server sendiri dengan lisensi yang tetap dikelola control plane RADIQ.',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<Head title="Network Device Management" />
|
||||
<main className="min-h-screen bg-slate-950 text-white">
|
||||
<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">
|
||||
<img
|
||||
src="/images/radiq-ndm-logo.png"
|
||||
alt="RADIQ NDM"
|
||||
className="size-14 object-contain"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-semibold tracking-wide">
|
||||
RADIQ NDM
|
||||
</div>
|
||||
<div className="text-xs 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>
|
||||
</header>
|
||||
<section className="grid flex-1 items-center gap-12 py-16 lg:grid-cols-2">
|
||||
<div>
|
||||
<div className="mb-4 inline-flex rounded-full border border-sky-400/30 bg-sky-400/10 px-3 py-1 text-xs font-medium text-sky-300">
|
||||
Built for ISP & Network Operations
|
||||
</div>
|
||||
<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">
|
||||
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>
|
||||
<div className="flex justify-center">
|
||||
<img
|
||||
src="/images/radiq-ndm-logo.png"
|
||||
alt="RADIQ NDM network logo"
|
||||
className="w-full max-w-lg object-contain drop-shadow-[0_0_40px_rgba(14,165,233,0.25)]"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
<section className="grid gap-4 pb-16 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{features.map((feature) => (
|
||||
<article
|
||||
key={feature.title}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 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">
|
||||
{feature.text}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user