import { useCallback, useEffect, useState } from "react" import { Plus, RefreshCw, Trash2 } from "lucide-react" import { useArcadiaClient } from "@crema/arcadia-core-client" import { useToast } from "@crema/notification-ui" import { ConfirmDialog, EmptyState } from "@crema/feedback-ui" import { BadgeCell } from "@crema/table-ui" import type { TenantTabProps } from "~/routes/tenants.$id" import { DataState, DialogError } from "~/components/data-state" import { errorMessage } from "~/lib/errors" import { Button } from "~/components/ui/button" import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "~/components/ui/card" import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from "~/components/ui/dialog" import { Input } from "~/components/ui/input" import { Label } from "~/components/ui/label" import { NativeSelect, NativeSelectOption } from "~/components/ui/native-select" import { Switch } from "~/components/ui/switch" import { createIpRule, deleteIpRule, listIpRules, updateIpRule, type IpRule, } from "~/lib/arcadia/tenants" /** * Per-tenant IP allow/deny rules. With no allow rules every IP is permitted; * an allow rule locks access to known ranges, a deny rule blocks specific ones. */ export function IpRulesTab({ tenant }: TenantTabProps) { const arcadia = useArcadiaClient() const toast = useToast() const [rules, setRules] = useState([]) const [loading, setLoading] = useState(true) // Raw thrown value; DataState normalises it. A failed load must never render // as "no rules — every IP allowed". const [error, setError] = useState(null) const [busy, setBusy] = useState>(new Set()) const [addOpen, setAddOpen] = useState(false) const [pendingDelete, setPendingDelete] = useState(null) const load = useCallback(async () => { setError(null) setLoading(true) try { setRules(await listIpRules(arcadia, tenant.id)) } catch (err) { setError(err) } finally { setLoading(false) } }, [arcadia, tenant.id]) useEffect(() => { load() }, [load]) const markBusy = (id: string, on: boolean) => setBusy((prev) => { const next = new Set(prev) if (on) next.add(id) else next.delete(id) return next }) const toggle = useCallback( async (rule: IpRule, next: boolean) => { markBusy(rule.id, true) setRules((prev) => prev.map((r) => (r.id === rule.id ? { ...r, enabled: next } : r)), ) try { const updated = await updateIpRule(arcadia, tenant.id, rule.id, { enabled: next, }) setRules((prev) => prev.map((r) => (r.id === updated.id ? updated : r))) toast.success( `${next ? "Enabled" : "Disabled"} ${rule.rule_type} rule ${rule.cidr}`, ) } catch (err) { setRules((prev) => prev.map((r) => (r.id === rule.id ? { ...r, enabled: !next } : r)), ) toast.error(errorMessage(err, `update rule ${rule.cidr}`)) } finally { markBusy(rule.id, false) } }, [arcadia, tenant.id, toast], ) return (
IP rules Control which client IPs may reach {tenant.name}. With no allow rules, every IP is allowed.
} >
{rules.map((rule) => { const isBusy = busy.has(rule.id) return ( ) })}
CIDR Type Description Enabled
{rule.cidr} {rule.description || "—"} toggle(rule, next)} data-action={`tenant-detail-ip-toggle-${rule.id}`} />
setAddOpen(false)} onSaved={async (msg) => { setAddOpen(false) await load() toast.success(msg) }} /> !o && setPendingDelete(null)} title="Delete IP rule?" description={ pendingDelete ? `The ${pendingDelete.rule_type} rule for ${pendingDelete.cidr} will be removed. If this was the last allow rule, every IP becomes allowed again.` : "" } confirmLabel="Delete" variant="danger" onConfirm={async () => { if (!pendingDelete) return const target = pendingDelete try { await deleteIpRule(arcadia, tenant.id, target.id) setPendingDelete(null) await load() toast.success(`Deleted ${target.rule_type} rule ${target.cidr}`) } catch (err) { setPendingDelete(null) toast.error(errorMessage(err, `delete rule ${target.cidr}`)) } }} />
) } function AddIpRuleDialog({ open, tenantId, onClose, onSaved, }: { open: boolean tenantId: string onClose: () => void onSaved: (message: string) => Promise }) { const arcadia = useArcadiaClient() const [cidr, setCidr] = useState("") const [ruleType, setRuleType] = useState<"allow" | "deny">("allow") const [description, setDescription] = useState("") const [enabled, setEnabled] = useState(true) const [saving, setSaving] = useState(false) // Failed submit renders here, above the buttons, form kept intact. const [error, setError] = useState(null) useEffect(() => { if (!open) return setCidr("") setRuleType("allow") setDescription("") setEnabled(true) setError(null) }, [open]) const submit = async () => { const trimmed = cidr.trim() setError(null) setSaving(true) try { if (!trimmed) throw new Error("A CIDR range is required.") await createIpRule(arcadia, tenantId, { cidr: trimmed, rule_type: ruleType, description: description.trim() || undefined, enabled, }) await onSaved(`Added ${ruleType} rule ${trimmed}`) } catch (err) { setError(err) } finally { setSaving(false) } } return ( !o && onClose()}> Add IP rule Allow rules lock access to known ranges; deny rules block specific ones.
setCidr(e.target.value)} placeholder="203.0.113.0/24" className="font-mono" autoFocus data-action="tenant-detail-ip-form-cidr" />
setRuleType(e.target.value as "allow" | "deny")} data-action="tenant-detail-ip-form-type" > Allow Deny
setDescription(e.target.value)} placeholder="Office VPN egress" data-action="tenant-detail-ip-form-description" />
Enabled
Disabled rules are kept but not enforced.
{error ? : null}
) }