Redesign the frontend UI on twui

Rebuild every page on a shared set of twui-based primitives with a dense
ops-console look, WireGuard red accent and light/dark/system theming.

- Add design tokens (WireGuard red accent, zinc neutrals, theme-flipping
  semantic colours) and a no-flash theme cookie + toggle
- Add shared components: page header, panel, sub-nav, data table with
  mobile card list, stat tile, status dot, key-value list, copy button,
  inline alert, empty state, form section/actions, confirm dialog and
  toast providers
- New admin shell (sidebar, sticky header, mobile drawer) and a centered
  shell for auth and 404; strip the public template pages
- Unify adding clients at /admin/clients/add with a host picker; tabbed
  client detail (Overview / Rules / Config & QR) with config copy/download
- Re-run client setup after rule changes on the detail page so the
  firewall matches the DB
- Split Setup into status/maintenance/firewall and Settings into
  profile/password subpages
- Replace window.confirm/alert and page reloads with in-app dialogs,
  toasts and in-place list updates
- Fix nested rule form submitting the client form, prefilled signup
  passwords, hidden submit buttons, fields not updating after "next
  free" lookups, and TinyMCE white-on-white in dark mode

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-16 06:29:21 +01:00
co-authored by Claude Opus 5.5
parent e50b0405b0
commit cad1d32ae4
291 changed files with 6436 additions and 6612 deletions
@@ -0,0 +1,8 @@
import { Activity, Shield, Wrench } from "lucide-react";
import type { SubNavItemType } from "@/src/components/general/sub-nav/sub-nav-item";
export const SetupNav: SubNavItemType[] = [
{ title: "System status", href: "/admin/setup/status", Icon: Activity },
{ title: "Maintenance", href: "/admin/setup/maintenance", Icon: Wrench },
{ title: "Firewall", href: "/admin/setup/firewall", Icon: Shield },
];
@@ -0,0 +1,49 @@
import StatusDot from "@/src/components/general/status-dot";
import type { KeyValueItem } from "@/src/components/general/key-value-row";
import type { SystemSetupStatus } from "../(hooks)/use-system-setup-status";
type Params = {
setup_status: SystemSetupStatus;
};
export default function buildSetupOverview({ setup_status }: Params): KeyValueItem[] {
const service_value =
setup_status.service_active === null ? (
<StatusDot tone="neutral" label="Not managed" />
) : (
<StatusDot
tone={setup_status.service_active ? "success" : "error"}
label={setup_status.service_active ? "Active" : "Inactive"}
/>
);
return [
{
key: "env",
label: "Environment",
value: (
<StatusDot
tone={setup_status.is_dev ? "warning" : "success"}
label={setup_status.environment}
/>
),
},
{
key: "root",
label: "Running as root",
value: (
<StatusDot
tone={setup_status.is_root ? "success" : "error"}
label={setup_status.is_root ? "Yes" : "No"}
/>
),
},
{ key: "distro", label: "Distro", value: setup_status.distro_pretty_name || setup_status.distro },
{ key: "init", label: "Init system", value: setup_status.init_system, mono: true },
{ key: "service", label: "Service", value: setup_status.service_name, mono: true },
{ key: "service-status", label: "Service status", value: service_value },
{ key: "version", label: "App version", value: setup_status.app_version || "—", mono: true },
{ key: "dir", label: "Install directory", value: setup_status.install_dir, mono: true },
{ key: "repo", label: "Repository", value: setup_status.repo_url || "—", mono: true },
];
}
@@ -0,0 +1,32 @@
import type { SetupCheckItem } from "../(partials)/setup-check-status-item";
import type { SystemSetupStatus } from "../(hooks)/use-system-setup-status";
type Params = {
setup_status: SystemSetupStatus;
};
export default function buildSetupRuntimeChecks({ setup_status }: Params): SetupCheckItem[] {
return [
{
key: "kernel-module",
title: "WireGuard kernel module",
subtitle: "/sys/module/wireguard",
ok: setup_status.wg_module_loaded,
ok_label: "Loaded",
},
{
key: "wg-quick-helper",
title: "wg-quick manage helper",
subtitle: `/var/lib/wgui/scripts/wg-quick-manage.sh`,
ok: setup_status.wg_quick_helper_installed,
ok_label: "Installed",
},
...setup_status.lib_dirs.map((dir) => ({
key: dir.path,
title: `Runtime directory · ${dir.label}`,
subtitle: dir.path,
ok: dir.exists,
ok_label: "Exists",
})),
];
}
@@ -0,0 +1,24 @@
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import userAuth from "@/src/functions/backend/auth/user-auth";
type Params = {
req: Request;
};
/**
* Setup pages are super-admin only. Returns a redirect when the request
* isn't allowed, `null` otherwise.
*/
export default async function guardSetupPage({ req }: Params) {
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return { destination: "/auth/login", permanent: false };
}
if (!checkUserAccess({ user_types }).success) {
return { destination: "/admin", permanent: false };
}
return null;
}
@@ -1,53 +1,18 @@
import CodeBlock from "@/src/components/twui/elements/CodeBlock";
import Tag from "@/src/components/twui/elements/Tag";
import Row from "@/src/components/twui/layout/Row";
import Span from "@/src/components/twui/layout/Span";
import Stack from "@/src/components/twui/layout/Stack";
import StatusDot, { type StatusTone } from "@/src/components/general/status-dot";
import type {
FirewallRuleKind,
FirewallRuleInfo,
} from "@/src/functions/backend/firewall/grab-firewall-status";
import Row from "@/src/components/twui/layout/Row";
import Span from "@/src/components/twui/layout/Span";
import Stack from "@/src/components/twui/layout/Stack";
import type { TWUIButtonColorType } from "@/src/components/twui/layout/Button";
import {
ShieldAlert,
ShieldCheck,
ShieldX,
type LucideIcon,
} from "lucide-react";
const KIND_META: Record<
FirewallRuleKind,
{
label: string;
color: TWUIButtonColorType;
icon: LucideIcon;
text_class: string;
}
> = {
required: {
label: `Active`,
color: `success`,
icon: ShieldCheck,
text_class: `text-success`,
},
temporary: {
label: `Temporary`,
color: `warning`,
icon: ShieldAlert,
text_class: `text-warning`,
},
orphaned: {
label: `Orphaned`,
color: `error`,
icon: ShieldX,
text_class: `text-error`,
},
other: {
label: `Unmanaged`,
color: `gray`,
icon: ShieldCheck,
text_class: `text-foreground-light/45 dark:text-foreground-dark/45`,
},
const KIND_META: Record<FirewallRuleKind, { label: string; tone: StatusTone }> = {
required: { label: "Active", tone: "success" },
temporary: { label: "Temporary", tone: "warning" },
orphaned: { label: "Orphaned", tone: "error" },
other: { label: "Unmanaged", tone: "neutral" },
};
type Props = {
@@ -56,40 +21,23 @@ type Props = {
export default function FirewallRuleRow({ rule }: Props) {
const meta = KIND_META[rule.kind];
const Icon = meta.icon;
return (
<Stack className="w-full items-stretch gap-2.5 rounded-lg bg-background-light dark:bg-[#0e0e10] border border-slate-200 dark:border-white/10 p-3">
<Row className="w-full items-start justify-between gap-3 flex-wrap">
<Row className="gap-2.5 items-start min-w-0">
<Icon
size={15}
className={`mt-0.5 shrink-0 ${meta.text_class}`}
/>
<Span className="text-[12.5px] leading-5 min-w-0">
{rule.description}
</Span>
</Row>
<Tag
variant="outlined"
color={meta.color}
className="shrink-0"
>
{meta.label}
</Tag>
<Stack className="w-full items-stretch gap-2.5 px-4 py-3 border-t border-line first:border-t-0">
<Row className="w-full items-start justify-between gap-3 flex-nowrap">
<Span className="text-[15px] leading-5 min-w-0">{rule.description}</Span>
<StatusDot tone={meta.tone} label={meta.label} className="shrink-0 mt-0.5" />
</Row>
{rule.delete_command ? (
<CodeBlock
singleBlock
language="shell"
wrapperProps={{
className: `max-w-none! xl:max-w-none!`,
}}
wrapperProps={{ className: "max-w-full!" }}
>
{`# remove rule\n${rule.delete_command}`}
</CodeBlock>
) : null}
</Stack>
);
}
}
@@ -1,114 +0,0 @@
import AdminCard from "@/src/components/general/admin-card";
import Button from "@/src/components/twui/layout/Button";
import H3 from "@/src/components/twui/layout/H3";
import P from "@/src/components/twui/layout/P";
import Row from "@/src/components/twui/layout/Row";
import Span from "@/src/components/twui/layout/Span";
import Stack from "@/src/components/twui/layout/Stack";
import useStatus from "@/src/components/twui/hooks/useStatus";
import { TriangleAlert } from "lucide-react";
import { useState, type ComponentProps, type ReactNode } from "react";
import SetupConsole from "./setup-console";
type Props = {
title: string;
description?: string | ReactNode;
icon?: ReactNode;
button_title: string;
buttonProps?: Omit<ComponentProps<typeof Button>, "title">;
on_run: () =>
| Promise<{ success: boolean; msg?: string } | void>
| { success: boolean; msg?: string }
| void;
children?: ReactNode;
};
export default function SetupActionCard({
title,
description,
icon,
button_title,
buttonProps,
on_run,
children,
}: Props) {
const { loading, setLoading, status, setStatus } = useStatus();
const [output, setOutput] = useState<string | null>(null);
const [is_error, setIsError] = useState(false);
const run = () => {
setLoading(true);
setStatus(undefined);
setOutput(null);
Promise.resolve()
.then(() => on_run())
.then((res) => {
if (res) {
setOutput(res.msg || null);
setIsError(!res.success);
}
})
.catch((error: any) => {
setStatus({
error: true,
msg: `Request failed — the wg-ui server may have restarted. ${
error?.message || ""
}`.trim(),
});
})
.finally(() => {
setLoading(false);
});
};
return (
<AdminCard className="w-full p-5 flex flex-col gap-4">
<Row className="gap-3 items-start">
{icon ? (
<Span className="p-2.5 rounded-lg bg-primary/10 dark:bg-primary-dark/10 text-primary dark:text-primary-dark shrink-0">
{icon}
</Span>
) : null}
<Stack className="gap-1 min-w-0">
<H3 className="text-[14px] font-semibold mb-0!">
{title}
</H3>
{description ? (
typeof description == "string" ? (
<P
noMargin
className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45"
>
{description}
</P>
) : (
description
)
) : null}
</Stack>
</Row>
{children}
<Button
title={button_title}
className="w-full"
loading={loading}
{...buttonProps}
onClick={run}
>
{button_title}
</Button>
{status?.error && status.msg ? (
<Row className="gap-2 items-center bg-error/5 rounded-lg px-3 py-2 text-error">
<TriangleAlert size={15} className="shrink-0" />
<Span className="text-[12.5px]">{status.msg}</Span>
</Row>
) : null}
<SetupConsole output={output} error={is_error} />
</AdminCard>
);
}
@@ -0,0 +1,105 @@
import { useState, type ReactNode } from "react";
import Button from "@/src/components/twui/layout/Button";
import H3 from "@/src/components/twui/layout/H3";
import P from "@/src/components/twui/layout/P";
import Row from "@/src/components/twui/layout/Row";
import Stack from "@/src/components/twui/layout/Stack";
import InlineAlert from "@/src/components/general/inline-alert";
import useConfirm from "@/src/hooks/use-confirm";
import useToast from "@/src/hooks/use-toast";
import type { ConfirmParams } from "@/src/components/general/confirm/confirm-context";
import SetupConsole from "./setup-console";
type RunResult = { success: boolean; msg?: string } | void;
type Props = {
title: string;
description?: ReactNode;
icon?: ReactNode;
button_title: string;
/** Ask before running (restart, update…) */
confirm_params?: ConfirmParams;
on_run: () => Promise<RunResult> | RunResult;
children?: ReactNode;
};
export default function SetupActionRow({
title,
description,
icon,
button_title,
confirm_params,
on_run,
children,
}: Props) {
const { confirm } = useConfirm();
const { toast } = useToast();
const [loading, setLoading] = useState(false);
const [output, setOutput] = useState<string | null>(null);
const [is_error, setIsError] = useState(false);
const [request_error, setRequestError] = useState<string>();
async function run() {
if (confirm_params && !(await confirm(confirm_params))) return;
setLoading(true);
setOutput(null);
setRequestError(undefined);
try {
const res = await on_run();
if (res) {
setOutput(res.msg || null);
setIsError(!res.success);
toast({
msg: res.success ? `${title}: done` : `${title} failed`,
color: res.success ? "success" : "error",
});
}
} catch (error: any) {
setRequestError(
`Request failed — the wg-ui server may have restarted. ${error?.message || ""}`.trim(),
);
} finally {
setLoading(false);
}
}
return (
<Stack className="w-full gap-4 px-4 py-4 border-t border-line first:border-t-0 items-stretch">
<Row className="w-full justify-between gap-4 items-start flex-wrap sm:flex-nowrap">
<Row className="gap-3 flex-nowrap items-start min-w-0">
{icon ? (
<Row className="size-8 shrink-0 justify-center rounded-default bg-gray dark:bg-gray-dark text-muted">
{icon}
</Row>
) : null}
<Stack className="gap-1 min-w-0">
<H3>{title}</H3>
{description ? (
<P className="text-[15px] max-w-2xl">{description}</P>
) : null}
</Stack>
</Row>
<Button
title={button_title}
type="button"
variant="outlined"
color="gray"
size="small"
loading={loading}
className="shrink-0"
onClick={run}
>
{button_title}
</Button>
</Row>
{children ? <Stack className="w-full gap-5 sm:pl-11 items-stretch">{children}</Stack> : null}
{request_error ? <InlineAlert tone="error" msg={request_error} /> : null}
<SetupConsole output={output} error={is_error} />
</Stack>
);
}
@@ -0,0 +1,19 @@
import Stack from "@/src/components/twui/layout/Stack";
import SectionLabel from "@/src/components/general/section-label";
import SetupCheckStatusItem, { type SetupCheckItem } from "./setup-check-status-item";
type Props = {
title: string;
items: SetupCheckItem[];
};
export default function SetupCheckGroup({ title, items }: Props) {
return (
<Stack className="w-full gap-0 items-stretch">
<SectionLabel className="px-4 pt-4 pb-2">{title}</SectionLabel>
{items.map((item) => (
<SetupCheckStatusItem {...item} key={item.key} />
))}
</Stack>
);
}
@@ -1,10 +1,12 @@
import Card from "@/src/components/twui/elements/Card";
import Tag from "@/src/components/twui/elements/Tag";
import Row from "@/src/components/twui/layout/Row";
import Span from "@/src/components/twui/layout/Span";
import Stack from "@/src/components/twui/layout/Stack";
import { twMerge } from "tailwind-merge";
import StatusDot from "@/src/components/general/status-dot";
import MonoText from "@/src/components/general/mono-text";
import MutedText from "@/src/components/general/muted-text";
type Props = {
export type SetupCheckItem = {
key: string;
title: string;
subtitle?: string;
ok: boolean;
@@ -18,47 +20,17 @@ export default function SetupCheckStatusItem({
ok,
ok_label = "Installed",
fail_label = "Missing",
}: Props) {
}: SetupCheckItem) {
return (
<Card
noHover
className="w-full p-2.5 flex flex-row items-center gap-2.5"
title={subtitle}
>
<Span
aria-hidden="true"
className={twMerge(
"relative flex w-2.5 h-2.5 shrink-0 rounded-full",
ok ? "bg-success" : "bg-error",
)}
/>
<Stack className="gap-1 min-w-0 flex-1">
<Span
className={twMerge(
"text-[13px] font-semibold leading-none text-foreground-light",
"dark:text-foreground-dark truncate w-full line-clamp-1",
)}
>
{title}
</Span>
{subtitle ? (
<Span
className={twMerge(
"font-mono text-[11.5px] text-foreground-light/45 dark:text-foreground-dark/45 truncate",
"line-clamp-1 w-full",
)}
>
{subtitle}
</Span>
) : null}
<Row className="w-full gap-3 px-4 py-2.5 border-t border-line flex-nowrap items-start">
<StatusDot tone={ok ? "success" : "error"} label={ok ? ok_label : fail_label} hide_label className="mt-1.5" />
<Stack className="gap-0 min-w-0 grow">
<Span className="text-[15px] font-medium text-fg">{title}</Span>
{subtitle ? <MonoText className="text-subtle break-all">{subtitle}</MonoText> : null}
</Stack>
<Tag
variant="outlined"
color={ok ? "success" : "error"}
className="shrink-0"
>
<MutedText tone={ok ? "success" : "error"} className="shrink-0 whitespace-nowrap">
{ok ? ok_label : fail_label}
</Tag>
</Card>
</MutedText>
</Row>
);
}
@@ -1,5 +1,4 @@
import Span from "@/src/components/twui/layout/Span";
import { twMerge } from "tailwind-merge";
import CodeBlock from "@/src/components/twui/elements/CodeBlock";
type Props = {
output?: string | null;
@@ -12,17 +11,14 @@ export default function SetupConsole({ output, error }: Props) {
}
return (
<div
aria-live="polite"
className={twMerge(
"w-full max-h-64 overflow-y-auto overflow-x-hidden rounded-lg bg-slate-950 p-3",
"border border-solid",
error ? "border-error/40" : "border-slate-700/40",
)}
>
<pre className="text-[11.5px] leading-5 font-mono whitespace-pre-wrap break-words text-slate-300">
<div aria-live="polite" className="w-full">
<CodeBlock
data-title={error ? "Error output" : "Output"}
wrapperProps={{ className: "max-w-full!" }}
className="max-h-64 overflow-y-auto"
>
{output}
</pre>
</CodeBlock>
</div>
);
}
}
@@ -1,39 +0,0 @@
import Span from "@/src/components/twui/layout/Span";
import Stack from "@/src/components/twui/layout/Stack";
import { twMerge } from "tailwind-merge";
export type SetupStatCellTone = "success" | "error" | "warning" | "muted";
const toneClassName: Record<SetupStatCellTone, string> = {
success: "text-success",
error: "text-error",
warning: "text-warning",
muted: "text-foreground-light/40 dark:text-foreground-dark/40",
};
type Props = {
label: string;
value: string;
tone?: SetupStatCellTone;
};
export default function SetupStatCell({ label, value, tone }: Props) {
return (
<Stack className="gap-1 min-w-0">
<Span className="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-foreground-light/40 dark:text-foreground-dark/40">
{label}
</Span>
<Span
title={value}
className={twMerge(
"tabular text-[15px] font-semibold truncate line-clamp-1 w-full",
tone
? toneClassName[tone]
: "text-foreground-light/85 dark:text-foreground-dark/85",
)}
>
{value}
</Span>
</Stack>
);
}
@@ -1,14 +1,13 @@
import { useState } from "react";
import { CloudDownload } from "lucide-react";
import fetchApi from "@/src/components/twui/utils/fetch/fetchApi";
import Input from "@/src/components/twui/form/Input";
import Stack from "@/src/components/twui/layout/Stack";
import Span from "@/src/components/twui/layout/Span";
import type useSystemSetupStatus from "../(hooks)/use-system-setup-status";
import SetupActionCard from "./setup-action-card";
import type { ApiReqParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import { CloudDownload } from "lucide-react";
import { useState } from "react";
import { SiteData } from "@/src/data/site-data";
import type useSystemSetupStatus from "../(hooks)/use-system-setup-status";
import SetupActionRow from "./setup-action-row";
type Props = {
setup: ReturnType<typeof useSystemSetupStatus>;
@@ -17,87 +16,68 @@ type Props = {
export default function SetupUpdateWgUiAction({ setup }: Props) {
const setup_status = setup.setup_status;
const [repoUrl, setRepoUrl] = useState<string>(
setup_status?.repo_url || "",
);
const [branch, setBranch] = useState<string>(
setup_status?.repo_branch || "main",
);
const [repoUrl, setRepoUrl] = useState<string>(setup_status?.repo_url || "");
const [branch, setBranch] = useState<string>(setup_status?.repo_branch || "main");
const needs_repo_url = !setup_status?.is_dev && !setup_status?.repo_url;
const service_name = setup_status?.service_name || "wgui";
const description = needs_repo_url
? "wg-ui isn't installed yet. Provide the git repository so the installer can clone it."
: setup_status?.is_dev
? "Development mode: reinstalls dependencies in this checkout and syncs the wg-quick helper. The dev server isn't restarted."
: `Pulls the latest code, installs dependencies, syncs helper scripts and restarts the ${service_name} service.`;
return (
<SetupActionCard
title="Update wg-ui"
description={
needs_repo_url ? (
<Span className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45">
wg-ui isn't installed yet — provide the git repository
URL so the installer can clone it, then run the update
again to pull newer versions.
</Span>
) : setup_status?.is_dev ? (
<Span className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45">
Development mode — uses this local checkout. Reinstalls
dependencies and syncs the wg-quick helper script. The
dev server is not restarted.
</Span>
) : (
<Span className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45">
Pulls the latest code, installs dependencies, syncs
helper scripts and restarts the{" "}
{setup_status?.service_name || "wgui"} service. The
server will briefly go offline.
</Span>
)
}
icon={<CloudDownload size={19} />}
button_title={
setup_status?.app_installed ? "Update wg-ui" : "Install wg-ui"
<SetupActionRow
title={setup_status?.app_installed ? "Update wg-ui" : "Install wg-ui"}
description={description}
icon={<CloudDownload size={15} />}
button_title={setup_status?.app_installed ? "Update" : "Install"}
confirm_params={
setup_status?.is_dev
? undefined
: {
title: setup_status?.app_installed ? "Update wg-ui?" : "Install wg-ui?",
message: `The ${service_name} service restarts and this dashboard goes briefly offline.`,
confirm_label: setup_status?.app_installed ? "Update and restart" : "Install",
tone: "danger",
}
}
on_run={async () => {
const res = await fetchApi<ApiReqParams, APIResponseObject>(
`/api/admin/update-wg-ui`,
{
method: "POST",
body: {
repo_url: repoUrl,
branch,
},
},
);
const res = await fetchApi<ApiReqParams, APIResponseObject>(`/api/admin/update-wg-ui`, {
method: "POST",
body: { repo_url: repoUrl, branch },
});
setup.refresh();
return res;
}}
>
{needs_repo_url || !setup_status?.is_dev ? (
<Stack className="gap-2.5">
<Stack className="w-full gap-5 sm:grid sm:grid-cols-[2fr_1fr] sm:gap-3 items-stretch">
{needs_repo_url ? (
<Input<"repo_url">
label="Repository URL"
name="repo_url"
title="Repository URL"
placeholder={SiteData["RepoURL"]}
defaultValue={repoUrl}
onChange={(e) => {
setRepoUrl(e.target.value);
}}
showLabel
onChange={(e) => setRepoUrl(e.target.value)}
/>
) : null}
{!setup_status?.is_dev ? (
<Input<"branch">
label="Branch"
name="branch"
title="Branch"
placeholder="main"
defaultValue={branch}
onChange={(e) => {
setBranch(e.target.value);
}}
title="Repo Branch"
showLabel
onChange={(e) => setBranch(e.target.value)}
/>
) : null}
</Stack>
) : null}
</SetupActionCard>
</SetupActionRow>
);
}
@@ -1,119 +1,94 @@
import AdminCard from "@/src/components/general/admin-card";
import { RefreshCw, ShieldCheck } from "lucide-react";
import Button from "@/src/components/twui/layout/Button";
import H3 from "@/src/components/twui/layout/H3";
import P from "@/src/components/twui/layout/P";
import Row from "@/src/components/twui/layout/Row";
import Span from "@/src/components/twui/layout/Span";
import LoadingBlock from "@/src/components/twui/elements/LoadingBlock";
import Stack from "@/src/components/twui/layout/Stack";
import Tag from "@/src/components/twui/elements/Tag";
import { RefreshCw, Shield, TriangleAlert } from "lucide-react";
import Panel from "@/src/components/general/panel";
import StatusDot from "@/src/components/general/status-dot";
import InlineAlert from "@/src/components/general/inline-alert";
import EmptyState from "@/src/components/general/empty-state";
import useFirewallStatus from "../(hooks)/use-firewall-status";
import FirewallRuleRow from "../(partials)/firewall-rule-row";
const MANAGER_LABEL: Record<string, string> = {
firewalld: `firewalld (active)`,
ufw: `ufw (active)`,
none: `none detected`,
firewalld: "firewalld active",
ufw: "ufw active",
none: "No manager detected",
};
export default function FirewallSection() {
const { firewall_status, refresh, loading, error } = useFirewallStatus();
if (!firewall_status) {
return (
<AdminCard className="w-full p-5 flex flex-col gap-4">
<Row className="gap-3 items-start">
<Span className="p-2.5 rounded-lg bg-primary/10 dark:bg-primary-dark/10 text-primary dark:text-primary-dark shrink-0">
<Shield size={19} />
</Span>
<Stack className="gap-1 min-w-0">
<H3 className="text-[14px] font-semibold mb-0!">
Firewall
</H3>
<P
noMargin
className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45"
>
{error ||
`Checking firewall status on this host ...`}
</P>
</Stack>
</Row>
</AdminCard>
);
}
const { manager, rules, hosts_found } = firewall_status;
const manager = firewall_status?.manager;
return (
<AdminCard className="w-full p-5 flex flex-col gap-4">
<Row className="gap-3 items-start">
<Span className="p-2.5 rounded-lg bg-primary/10 dark:bg-primary-dark/10 text-primary dark:text-primary-dark shrink-0">
<Shield size={19} />
</Span>
<Stack className="gap-1 min-w-0 flex-1">
<Row className="gap-2 items-center flex-wrap">
<H3 className="text-[14px] font-semibold mb-0!">
Firewall
</H3>
<Tag
variant="outlined"
color={manager == "none" ? "warning" : "success"}
className="shrink-0"
>
{MANAGER_LABEL[manager] || manager}
</Tag>
</Row>
<P
noMargin
className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45"
>
Host setups automatically allow their wireguard subnet
and listen port here. Rules that are no longer needed
(bootstrap or stale) are listed with removal commands.
</P>
</Stack>
<Button
title="Refresh"
variant="outlined"
loading={loading}
beforeIcon={<RefreshCw size={14} />}
onClick={refresh}
>
Refresh
</Button>
</Row>
{manager == "none" ? (
<Row className="gap-2 items-center bg-warning/5 rounded-lg px-3 py-2.5 text-warning">
<TriangleAlert size={15} className="shrink-0" />
<Span className="text-[12.5px]">
No active high-level firewall manager detected
(firewalld/ufw). If you use iptables/nftables directly,
allow each host subnet and its wireguard listen port
manually.
</Span>
</Row>
) : null}
{rules.length ? (
<Stack className="w-full gap-2 items-stretch">
{rules.map((rule, index) => (
<FirewallRuleRow
key={rule.id || `${manager}-${index}`}
rule={rule}
<Panel
flush
title="Rules"
description="Host setups allow their subnet and listen port automatically."
actions={
<>
{manager ? (
<StatusDot
tone={manager == "none" ? "warning" : "success"}
label={MANAGER_LABEL[manager] || manager}
/>
))}
</Stack>
) : null}
<Button
title="Refresh firewall status"
type="button"
variant="outlined"
color="gray"
size="small"
loading={loading}
onClick={refresh}
>
<RefreshCw size={14} />
Refresh
</Button>
</>
}
>
{!firewall_status ? (
error ? (
<Stack className="p-4 items-stretch">
<InlineAlert tone="error" msg={error} />
</Stack>
) : (
<Stack className="p-4 gap-2 items-stretch" aria-busy>
<LoadingBlock className="h-10" />
<LoadingBlock className="h-10" />
</Stack>
)
) : (
<Stack className="w-full items-start gap-1 rounded-lg bg-background-light dark:bg-[#0e0e10] border border-slate-200 dark:border-white/10 p-3">
<Span className="text-[12.5px] text-foreground-light/60 dark:text-foreground-dark/60">
{hosts_found
? `No wg-ui related firewall rules found.`
: `No hosts created yet — creating a host automatically allows its subnet (e.g. 10.5.0.0/24) and its wireguard listen port (e.g. 51821/udp) here.`}
</Span>
</Stack>
<>
{manager == "none" ? (
<Stack className="p-4 pb-0 items-stretch">
<InlineAlert
tone="warning"
msg="No firewalld or ufw detected. If you manage iptables/nftables directly, allow each host's subnet and listen port yourself."
/>
</Stack>
) : null}
{firewall_status.rules.length ? (
<Stack className="w-full gap-0 items-stretch">
{firewall_status.rules.map((rule, index) => (
<FirewallRuleRow key={rule.id || `${manager}-${index}`} rule={rule} />
))}
</Stack>
) : (
<EmptyState
className="py-8"
icon={<ShieldCheck size={18} />}
title={firewall_status.hosts_found ? "No wg-ui firewall rules" : "No hosts yet"}
description={
firewall_status.hosts_found
? undefined
: "Creating a host allows its subnet (e.g. 10.5.0.0/24) and listen port (e.g. 51821/udp) here."
}
/>
)}
</>
)}
</AdminCard>
</Panel>
);
}
}
@@ -1,12 +1,11 @@
import { RotateCcw, Wrench } from "lucide-react";
import fetchApi from "@/src/components/twui/utils/fetch/fetchApi";
import Row from "@/src/components/twui/layout/Row";
import Span from "@/src/components/twui/layout/Span";
import type useSystemSetupStatus from "../(hooks)/use-system-setup-status";
import SetupActionCard from "../(partials)/setup-action-card";
import SetupUpdateWgUiAction from "../(partials)/setup-update-wg-ui-action";
import Panel from "@/src/components/general/panel";
import type { ApiReqParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import { RotateCcw, TriangleAlert, Wrench } from "lucide-react";
import type useSystemSetupStatus from "../(hooks)/use-system-setup-status";
import SetupActionRow from "../(partials)/setup-action-row";
import SetupUpdateWgUiAction from "../(partials)/setup-update-wg-ui-action";
type Props = {
setup: ReturnType<typeof useSystemSetupStatus>;
@@ -25,41 +24,21 @@ export default function SetupActionsSection({ setup }: Props) {
.filter((tool) => WG_TOOL_COMMANDS.includes(tool.command))
.every((tool) => tool.installed);
const show_restart =
!setup_status.is_dev && setup_status.init_system !== "unknown";
const show_restart = !setup_status.is_dev && setup_status.init_system !== "unknown";
return (
<>
{!setup_status.is_root ? (
<Row className="gap-2 items-center bg-warning/5 rounded-lg px-3 py-2.5 text-warning">
<TriangleAlert size={16} className="shrink-0" />
<Span className="text-[12.5px]">
The server is not running as root — installs and
tunnel management may fail. Run wg-ui as root for full
functionality.
</Span>
</Row>
) : null}
<SetupActionCard
<Panel flush>
<SetupActionRow
title="WireGuard tools"
description="Installs or updates the WireGuard tools (wg, wg-quick, iproute2) using your distro's package manager. Safe to re-run — the installer is idempotent."
icon={<Wrench size={19} />}
button_title={
have_wg_tools
? "Update WireGuard tools"
: "Install WireGuard tools"
}
description="Installs or updates wg, wg-quick and iproute2 with your distro's package manager. Safe to re-run."
icon={<Wrench size={15} />}
button_title={have_wg_tools ? "Update tools" : "Install tools"}
on_run={async () => {
const res = await fetchApi<ApiReqParams, APIResponseObject>(
`/api/admin/setup-wireguard-tools`,
{
method: "POST",
},
{ method: "POST" },
);
setup.refresh();
return res;
}}
/>
@@ -67,25 +46,27 @@ export default function SetupActionsSection({ setup }: Props) {
<SetupUpdateWgUiAction setup={setup} />
{show_restart ? (
<SetupActionCard
<SetupActionRow
title="Restart service"
description={`Restarts the ${setup_status.service_name} system service. Use this after manual changes — the server goes briefly offline.`}
icon={<RotateCcw size={19} />}
button_title="Restart service"
description={`Restarts the ${setup_status.service_name} system service. Use after manual changes.`}
icon={<RotateCcw size={15} />}
button_title="Restart"
confirm_params={{
title: "Restart wg-ui?",
message: `The ${setup_status.service_name} service restarts and this dashboard goes briefly offline.`,
confirm_label: "Restart service",
tone: "danger",
}}
on_run={async () => {
const res = await fetchApi<
ApiReqParams,
APIResponseObject
>(`/api/admin/restart-wg-ui-service`, {
method: "POST",
});
const res = await fetchApi<ApiReqParams, APIResponseObject>(
`/api/admin/restart-wg-ui-service`,
{ method: "POST" },
);
setup.refresh();
return res;
}}
/>
) : null}
</>
</Panel>
);
}
}
@@ -0,0 +1,36 @@
import type { PropsWithChildren } from "react";
import PageHeader from "@/src/components/general/page-header";
import PageBody from "@/src/components/general/page-body";
import SubNav from "@/src/components/general/sub-nav";
import InlineAlert from "@/src/components/general/inline-alert";
import { SetupNav } from "../(data)/setup-nav-dict";
type Props = PropsWithChildren & {
title: string;
description: string;
/** `false` shows the not-root warning */
is_root?: boolean;
};
/** Header, sub-navigation and body shared by the three setup subpages. */
export default function SetupPageFrame({ title, description, is_root, children }: Props) {
return (
<>
<PageHeader
title={title}
description={description}
back={{ href: "/admin", title: "Dashboard" }}
nav={<SubNav label="Setup sections" items={SetupNav} />}
/>
<PageBody>
{is_root === false ? (
<InlineAlert
tone="warning"
msg="wg-ui isn't running as root. Installs and tunnel management may fail."
/>
) : null}
{children}
</PageBody>
</>
);
}
@@ -1,18 +1,13 @@
import AdminCard from "@/src/components/general/admin-card";
import { RefreshCw, ServerCrash } from "lucide-react";
import Button from "@/src/components/twui/layout/Button";
import Divider from "@/src/components/twui/layout/Divider";
import H3 from "@/src/components/twui/layout/H3";
import P from "@/src/components/twui/layout/P";
import Row from "@/src/components/twui/layout/Row";
import Span from "@/src/components/twui/layout/Span";
import Stack from "@/src/components/twui/layout/Stack";
import EmptyContent from "@/src/components/twui/elements/EmptyContent";
import Panel from "@/src/components/general/panel";
import KeyValueList from "@/src/components/general/key-value-list";
import EmptyState from "@/src/components/general/empty-state";
import type useSystemSetupStatus from "../(hooks)/use-system-setup-status";
import SetupCheckStatusItem from "../(partials)/setup-check-status-item";
import SetupStatCell, {
type SetupStatCellTone,
} from "../(partials)/setup-stat-cell";
import { RefreshCw } from "lucide-react";
import SetupCheckGroup from "../(partials)/setup-check-group";
import buildSetupOverview from "../(functions)/build-setup-overview";
import buildSetupRuntimeChecks from "../(functions)/build-setup-runtime-checks";
type Props = {
setup: ReturnType<typeof useSystemSetupStatus>;
@@ -22,173 +17,50 @@ export default function SystemStatusSection({ setup }: Props) {
const setup_status = setup.setup_status;
if (!setup_status) {
return <EmptyContent title="No system status available" />;
return (
<Panel title="Environment">
<EmptyState icon={<ServerCrash size={18} />} title="No system status available" />
</Panel>
);
}
const service_label =
setup_status.service_active === null
? "Not managed"
: setup_status.service_active
? "Active"
: "Inactive";
const overview = buildSetupOverview({ setup_status });
const half = Math.ceil(overview.length / 2);
const service_tone: SetupStatCellTone =
setup_status.service_active === null
? "muted"
: setup_status.service_active
? "success"
: "error";
const overview_items: {
label: string;
value: string;
tone?: SetupStatCellTone;
}[] = [
{
label: "Environment",
value: setup_status.environment,
tone: setup_status.is_dev ? "warning" : "success",
},
{
label: "Running as root",
value: setup_status.is_root ? "Yes" : "No",
tone: setup_status.is_root ? "success" : "error",
},
{
label: "Distro",
value: setup_status.distro_pretty_name || setup_status.distro,
},
{
label: "Init system",
value: setup_status.init_system,
},
{
label: "Install directory",
value: setup_status.install_dir,
},
{
label: "App version",
value: setup_status.app_version || "—",
},
{
label: "Repository",
value: setup_status.repo_url || "—",
},
{
label: "Service",
value: setup_status.service_name,
},
{
label: "Service status",
value: service_label,
tone: service_tone,
},
];
const runtime_items: {
key: string;
title: string;
subtitle: string;
ok: boolean;
ok_label: string;
}[] = [
{
key: "kernel-module",
title: "WireGuard kernel module",
subtitle: "/sys/module/wireguard",
ok: setup_status.wg_module_loaded,
ok_label: "Loaded",
},
{
key: "wg-quick-helper",
title: "wg-quick manage helper",
subtitle: `/var/lib/wgui/scripts/wg-quick-manage.sh`,
ok: setup_status.wg_quick_helper_installed,
ok_label: "Installed",
},
...setup_status.lib_dirs.map((dir) => ({
key: dir.path,
title: `Runtime directory · ${dir.label}`,
subtitle: dir.path,
ok: dir.exists,
ok_label: "Exists",
})),
];
const tool_checks = setup_status.tools.map((tool) => ({
key: tool.command,
title: tool.name,
subtitle: tool.version ? `${tool.command} · ${tool.version}` : tool.command,
ok: tool.installed,
}));
return (
<AdminCard className="w-full p-5 flex flex-col gap-4">
<Row className="justify-between items-start gap-3">
<Stack className="gap-1">
<H3 className="text-[14px] font-semibold mb-0!">
System status
</H3>
<P
noMargin
className="text-[12.5px] text-foreground-light/45 dark:text-foreground-dark/45"
>
The web server's environment and required tools
</P>
</Stack>
<Panel
flush
title="Environment"
actions={
<Button
title="Refresh system status"
size="small"
variant="ghost"
type="button"
variant="outlined"
color="gray"
beforeIcon={<RefreshCw size={15} />}
size="small"
loading={setup.loading}
onClick={setup.refresh}
>
<RefreshCw size={14} />
Refresh
</Button>
</Row>
<Divider className="w-full" />
<div className="grid grid-cols-2 lg:grid-cols-4 gap-x-4 gap-y-5 min-w-0">
{overview_items.map((item) => (
<SetupStatCell
key={item.label}
label={item.label}
value={item.value}
tone={item.tone}
/>
))}
</div>
<Divider className="w-full" />
<Span className="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-foreground-light/40 dark:text-foreground-dark/40">
WireGuard tools
</Span>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{setup_status.tools.map((tool) => (
<SetupCheckStatusItem
key={tool.command}
title={tool.name}
subtitle={
tool.version
? `${tool.command} · ${tool.version}`
: tool.command
}
ok={tool.installed}
/>
))}
</div>
<Span className="text-[11.5px] font-semibold uppercase tracking-[0.08em] text-foreground-light/40 dark:text-foreground-dark/40">
Runtime
</Span>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{runtime_items.map((item) => (
<SetupCheckStatusItem
key={item.key}
title={item.title}
subtitle={item.subtitle}
ok={item.ok}
ok_label={item.ok_label}
/>
))}
</div>
</AdminCard>
}
>
<Stack className="w-full gap-0 md:grid md:grid-cols-2 md:gap-8 px-4 py-2 items-stretch">
<KeyValueList items={overview.slice(0, half)} />
<KeyValueList items={overview.slice(half)} />
</Stack>
<Stack className="w-full gap-0 lg:grid lg:grid-cols-2 border-t border-line items-stretch">
<SetupCheckGroup title="WireGuard tools" items={tool_checks} />
<SetupCheckGroup title="Runtime" items={buildSetupRuntimeChecks({ setup_status })} />
</Stack>
</Panel>
);
}
@@ -0,0 +1,16 @@
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import guardSetupPage from "../(functions)/guard-setup-page";
/** Firewall status is fetched client-side by `useFirewallStatus`. */
const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
const redirect = await guardSetupPage({ req });
if (redirect) {
return { redirect };
}
return { props: {} };
};
export default server;
+20
View File
@@ -0,0 +1,20 @@
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import { SiteData } from "@/src/data/site-data";
import SetupPageFrame from "../(sections)/setup-page-frame";
import FirewallSection from "../(sections)/firewall-section";
export default function SetupFirewallPage() {
return (
<SetupPageFrame
title="Firewall"
description="Rules wg-ui manages on this host, and stale ones you can remove."
>
<FirewallSection />
</SetupPageFrame>
);
}
export const meta: BunextPageModuleMeta = {
title: `Firewall | ${SiteData["SiteName"]}`,
description: `Firewall rules managed by wg-ui`,
};
+5 -28
View File
@@ -1,37 +1,14 @@
import checkUserAccess from "@/src/functions/backend/auth/check-user-access";
import userAuth from "@/src/functions/backend/auth/user-auth";
import grabSystemSetupStatus from "@/src/functions/backend/setup/grab-system-setup-status";
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import guardSetupPage from "./(functions)/guard-setup-page";
/** `/admin/setup` has no content of its own; land on System status. */
const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
const { user, user_types } = await userAuth({ req });
if (!user?.logged_in_status || !user.id) {
return {
redirect: {
destination: "/auth/login",
permanent: false,
},
};
}
const is_super_admin = checkUserAccess({ user_types });
if (!is_super_admin.success) {
return {
redirect: {
destination: "/admin",
permanent: false,
},
};
}
const redirect = await guardSetupPage({ req });
return {
props: {
setup_status: grabSystemSetupStatus(),
},
redirect: redirect || { destination: "/admin/setup/status", permanent: false },
};
};
export default server;
export default server;
+3 -38
View File
@@ -1,39 +1,4 @@
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import AdminHero from "@/src/components/general/admin-hero";
import { SiteData } from "@/src/data/site-data";
import Divider from "@/src/components/twui/layout/Divider";
import Stack from "@/src/components/twui/layout/Stack";
import useSystemSetupStatus from "./(hooks)/use-system-setup-status";
import SystemStatusSection from "./(sections)/system-status-section";
import SetupActionsSection from "./(sections)/setup-actions-section";
import FirewallSection from "./(sections)/firewall-section";
import { useContext } from "react";
import { AppContext } from "@/src/pages/__root";
export default function AdminWireguardSetupPage() {
const { pageProps } = useContext(AppContext);
const setup = useSystemSetupStatus({
setup_status: pageProps?.setup_status,
});
return (
<>
<AdminHero
title="Setup"
description="Setup wireguard and wg-ui. Check if dependencies are installed. Update wg-ui and related packages. Etc."
/>
<Divider className="mb-6" />
<Stack className="w-full px-6 pb-8 gap-5 items-stretch">
<SystemStatusSection setup={setup} />
<SetupActionsSection setup={setup} />
<FirewallSection />
</Stack>
</>
);
/** Always redirected by `index.server.ts`. */
export default function SetupIndexPage() {
return null;
}
export const meta: BunextPageModuleMeta = {
title: `Admin Wireguard Setup | ${SiteData["SiteName"]}`,
description: `Setup wireguard and wg-ui. Check if dependencies are installed. Update wg-ui and related packages. Etc.`,
};
@@ -0,0 +1,20 @@
import grabSystemSetupStatus from "@/src/functions/backend/setup/grab-system-setup-status";
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import guardSetupPage from "../(functions)/guard-setup-page";
const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
const redirect = await guardSetupPage({ req });
if (redirect) {
return { redirect };
}
return {
props: {
setup_status: grabSystemSetupStatus(),
},
};
};
export default server;
@@ -0,0 +1,27 @@
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import { useContext } from "react";
import { SiteData } from "@/src/data/site-data";
import { AppContext } from "@/src/pages/__root";
import useSystemSetupStatus from "../(hooks)/use-system-setup-status";
import SetupPageFrame from "../(sections)/setup-page-frame";
import SetupActionsSection from "../(sections)/setup-actions-section";
export default function SetupMaintenancePage() {
const { pageProps } = useContext(AppContext);
const setup = useSystemSetupStatus({ setup_status: pageProps?.setup_status });
return (
<SetupPageFrame
title="Maintenance"
description="Install and update WireGuard tools and wg-ui, or restart the service."
is_root={setup.setup_status?.is_root}
>
<SetupActionsSection setup={setup} />
</SetupPageFrame>
);
}
export const meta: BunextPageModuleMeta = {
title: `Maintenance | ${SiteData["SiteName"]}`,
description: `Install, update and restart wg-ui`,
};
@@ -0,0 +1,20 @@
import grabSystemSetupStatus from "@/src/functions/backend/setup/grab-system-setup-status";
import type { PagePropsType } from "@/src/types";
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
import guardSetupPage from "../(functions)/guard-setup-page";
const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
const redirect = await guardSetupPage({ req });
if (redirect) {
return { redirect };
}
return {
props: {
setup_status: grabSystemSetupStatus(),
},
};
};
export default server;
+29
View File
@@ -0,0 +1,29 @@
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
import { useContext } from "react";
import { SiteData } from "@/src/data/site-data";
import { AppContext } from "@/src/pages/__root";
import useSystemSetupStatus from "../(hooks)/use-system-setup-status";
import SetupPageFrame from "../(sections)/setup-page-frame";
import SystemStatusSection from "../(sections)/system-status-section";
export default function SetupStatusPage() {
const { pageProps } = useContext(AppContext);
const setup = useSystemSetupStatus({
setup_status: pageProps?.setup_status,
});
return (
<SetupPageFrame
title="System status"
description="The server's environment and the tools wg-ui depends on."
is_root={setup.setup_status?.is_root}
>
<SystemStatusSection setup={setup} />
</SetupPageFrame>
);
}
export const meta: BunextPageModuleMeta = {
title: `System status | ${SiteData["SiteName"]}`,
description: `Server environment and dependency checks`,
};