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
+11 -4
View File
@@ -46,8 +46,9 @@ No linter or formatter configured. Type-check and `bun test` are the verificatio
## Architecture ## Architecture
- `src/pages/__root.tsx` — picks the layout (`src/layouts/main` or `src/layouts/admin`, or `LoginShell` for auth pages) and provides `AppContext`. - `src/pages/__root.tsx` — picks the layout (`src/layouts/admin` for `/admin*`, `src/layouts/centered` for auth, 404 and everything else), mounts `AppProviders` (toast + confirm dialog) and provides `AppContext`. Its `Head` renders `ThemeScript` so the theme applies before first paint.
- **Pages:** `/` + `about`, `privacy`, `terms`; `/auth/{login,logout,signup}`; `/admin` (dashboard), `/admin/setup`, `/admin/settings`, `/admin/clients`, `/admin/hosts/*` (add/edit hosts, `[host_id]/clients/*` add/edit/view clients). - **Pages:** `/` redirects to `/admin` or `/auth/login`; `/auth/{login,logout,signup}`; `/admin` (dashboard), `/admin/setup/{status,maintenance,firewall}` (super admin only, guarded per page by `guard-setup-page`; `/admin/setup` redirects to `status`), `/admin/settings/{profile,password}` (`/admin/settings` redirects to `profile`), `/admin/clients`, `/admin/clients/add` (the only add-client form — `?host_id=` preselects the host), `/admin/hosts/*` (add/edit hosts, `[host_id]/clients` list, `[host_id]/clients/[client_id]` tabbed detail + `edit`). `[host_id]/clients/add-client` is a legacy redirect.
- **Theme:** light/dark/system, stored in the `wgui_theme` cookie (`src/dict/theme-dict.ts`). twui dark styles key off a `.dark` class on `<html>` — toggle it only via `applyTheme`, never replace `className`.
- **API:** `src/pages/api/admin/*` (host/client setup, network info, firewall status, service restart/update, media), generic CRUD at `src/pages/api/admin/crud/[[...paths]].ts` with per-table handlers in `crud/(tables)/<table>/{get,post,put,del}.ts`, plus `api/auth/login.ts` and `api/create-first-user.ts`. - **API:** `src/pages/api/admin/*` (host/client setup, network info, firewall status, service restart/update, media), generic CRUD at `src/pages/api/admin/crud/[[...paths]].ts` with per-table handlers in `crud/(tables)/<table>/{get,post,put,del}.ts`, plus `api/auth/login.ts` and `api/create-first-user.ts`.
- **Every `/api/admin/*` route must authenticate** — nothing guards the prefix globally. Use `adminApiGuard({ req })` (`src/functions/backend/auth/admin-api-guard.ts`) or `userAuth` + `checkUserAccess`. - **Every `/api/admin/*` route must authenticate** — nothing guards the prefix globally. Use `adminApiGuard({ req })` (`src/functions/backend/auth/admin-api-guard.ts`) or `userAuth` + `checkUserAccess`.
- **Shell safety:** never interpolate request/DB values into `execSync` strings. Use `execFileSync` with an args array or `node:fs` APIs, and validate values before they reach a WireGuard config (newlines there can inject `PostUp` commands). Validators live in `src/utils/` (`validate-ipv4`, `validate-ipv4-cidr`, `validate-allowed-ips`, `validate-endpoint-host`, `validate-interface-name`). - **Shell safety:** never interpolate request/DB values into `execSync` strings. Use `execFileSync` with an args array or `node:fs` APIs, and validate values before they reach a WireGuard config (newlines there can inject `PostUp` commands). Validators live in `src/utils/` (`validate-ipv4`, `validate-ipv4-cidr`, `validate-allowed-ips`, `validate-endpoint-host`, `validate-interface-name`).
@@ -78,11 +79,17 @@ Server functions use `index.server.ts` alongside `index.tsx` (e.g. `single-docum
- **`src/functions/backend/`** — Server-only: `auth/`, `db/`, `firewall/` (firewalld/ufw rules, status), `media/`, `setup/` (host/client setup, iptables script builder, network detection). - **`src/functions/backend/`** — Server-only: `auth/`, `db/`, `firewall/` (firewalld/ufw rules, status), `media/`, `setup/` (host/client setup, iptables script builder, network detection).
- **`src/functions/frontend/`** — Client-safe: CRUD/fetch handlers, media upload/delete, client rule label formatting. - **`src/functions/frontend/`** — Client-safe: CRUD/fetch handlers, media upload/delete, client rule label formatting.
- **`src/functions/server/`** — Request-level server handlers (media server). - **`src/functions/server/`** — Request-level server handlers (media server).
- **`src/hooks/`** — `use-admin-crud-get`, `use-client-rules-map`, `use-fetch-get`, `use-form-init`, `use-users-get`. - **`src/hooks/`** — `use-admin-crud-get` (with `refetch`), `use-client-rules-map`, `use-confirm`, `use-toast`, `use-theme`, `use-fetch-get`, `use-form-init`, `use-users-get`.
- **`src/utils/`** — Cookies, crypto, dir names, IP/CIDR/port/interface validators, WireGuard interface naming. Most have `.test.ts` files. - **`src/utils/`** — Cookies, crypto, dir names, IP/CIDR/port/interface validators, WireGuard interface naming. Most have `.test.ts` files.
- **`src/dict/`**, **`src/data/`** — Constants (`app-data.ts`, `site-data.ts`), user types, client rules, links. - **`src/dict/`**, **`src/data/`** — Constants (`app-data.ts`, `site-data.ts`), user types, client rules, links.
- **`src/scripts/`** — Shell scripts: `install-wg-ui.sh`, `setup-wireguard.sh`, `wg-quick-manage.sh`. - **`src/scripts/`** — Shell scripts: `install-wg-ui.sh`, `setup-wireguard.sh`, `wg-quick-manage.sh`.
- **`src/components/general/`** — Shared app components (admin cards/buttons, clients table, host IP field, login shell, user form). - **`src/components/general/`** — Shared app components, all built from twui. Reach for these before writing page markup:
- Page frame: `page-header` (title, meta, primary action + overflow menu), `page-body`, `panel` (the one card style), `form-section`, `form-actions` (submit is always rendered).
- Text & status: `muted-text`, `mono-text` (IPs, keys, paths), `section-label`, `status-dot`, `inline-alert`, `empty-state`, `stat-tile`, `key-value-list`, `copy-button`, `copyable-value`.
- Data: `data-table` (desktop table + mobile card list; the only place raw `<table>` is allowed), `clients-table`, `host-clients-panel`, `action-menu`, `sub-nav` (link tabs between sibling routes).
- Flows: `client-form` + `client-rules` (shared by add/edit client and the client detail Rules tab), `user-form`, `host-wg-ip-field`, `use-delete-host`.
- Feedback: `confirm/` + `useConfirm()` and `toast/` + `useToast()` (`src/hooks`). Never use `window.confirm`/`alert`; update lists in place instead of `location.reload()`.
- **Design tokens** live in `src/styles/main.css`: one accent, WireGuard red `#89161b` (`primary` = `secondary` = `accent`; `error` is a separate brighter red), zinc neutrals, and theme-flipping semantic colours (`bg-surface`, `bg-surface-2`, `border-line`, `text-fg`, `text-muted`, `text-subtle`) that need no `dark:` pair.
## Conventions ## Conventions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

@@ -0,0 +1,45 @@
import Button from "@/src/components/twui/layout/Button";
import { twMerge } from "tailwind-merge";
import type { ActionMenuItem as ActionMenuItemType } from "./types";
type Props = ActionMenuItemType & {
onDone: () => void;
};
export default function ActionMenuItem({
title,
href,
onClick,
icon,
tone,
disabled,
loading,
onDone,
}: Props) {
return (
<Button
title={title}
type="button"
variant="ghost"
color={tone == "danger" ? "error" : "text"}
size="small"
href={href}
disabled={disabled}
loading={loading}
role="menuitem"
linkProps={{ className: "w-full no-underline!" }}
className={twMerge(
"w-full justify-start px-2.5! py-1.5! rounded-default-sm font-normal",
"hover:bg-gray! dark:hover:bg-gray-dark!",
)}
buttonContentProps={{ className: "gap-2.5 [&_svg]:opacity-70" }}
onClick={() => {
onClick?.();
onDone();
}}
>
{icon}
{title}
</Button>
);
}
@@ -0,0 +1,64 @@
import Dropdown from "@/src/components/twui/elements/Dropdown";
import Paper from "@/src/components/twui/elements/Paper";
import Button from "@/src/components/twui/layout/Button";
import { MoreHorizontal } from "lucide-react";
import { useState, type ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import ActionMenuItem from "./action-menu-item";
import type { ActionMenuItem as ActionMenuItemType } from "./types";
type Props = {
/** Accessible label of the trigger */
label: string;
items: ActionMenuItemType[];
trigger_icon?: ReactNode;
className?: string;
};
export default function ActionMenu({
label,
items,
trigger_icon,
className,
}: Props) {
const [open, setOpen] = useState(false);
if (!items.length) return null;
return (
<Dropdown
position="bottom-right"
externalOpen={open}
externalSetOpen={setOpen}
className={className}
target={
<Button
title={label}
type="button"
variant="ghost"
color="gray"
aria-haspopup="menu"
aria-expanded={open}
className="p-1.5! rounded-default-sm hover:bg-gray! dark:hover:bg-gray-dark!"
>
{trigger_icon || <MoreHorizontal size={16} />}
</Button>
}
>
<Paper
role="menu"
className={twMerge(
"min-w-[180px] p-1! gap-0.5 shadow-lg rounded-default-lg",
)}
>
{items.map((item) => (
<ActionMenuItem
{...item}
key={item.key}
onDone={() => setOpen(false)}
/>
))}
</Paper>
</Dropdown>
);
}
@@ -0,0 +1,12 @@
import type { ReactNode } from "react";
export type ActionMenuItem = {
key: string;
title: string;
href?: string;
onClick?: () => void;
icon?: ReactNode;
tone?: "default" | "danger";
disabled?: boolean;
loading?: boolean;
};
-40
View File
@@ -1,40 +0,0 @@
import type { ButtonHTMLAttributes } from "react";
import type { LucideIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: "primary" | "ghost";
Icon?: LucideIcon;
};
const baseClass =
"h-[30px] px-3 rounded-[5px] inline-flex items-center justify-center gap-2 " +
"text-[13px] font-medium cursor-pointer transition-colors whitespace-nowrap";
const variantClass = {
primary: "bg-primary text-white font-semibold hover:opacity-90",
ghost:
"text-foreground-light/70 dark:text-foreground-dark/70 " +
"hover:text-foreground-light dark:hover:text-foreground-dark " +
"border border-slate-200 dark:border-white/10 " +
"hover:border-secondary/60 dark:hover:border-secondary/60",
};
export default function AdminButton({
variant = "ghost",
Icon,
className,
children,
...props
}: Props) {
return (
<button
type="button"
{...props}
className={twMerge(baseClass, variantClass[variant], className)}
>
{Icon ? <Icon size={13} strokeWidth={2.25} /> : null}
{children}
</button>
);
}
-25
View File
@@ -1,25 +0,0 @@
import type { HTMLAttributes } from "react";
import { twMerge } from "tailwind-merge";
type Props = HTMLAttributes<HTMLDivElement> & {
tier?: "default" | "secondary";
};
export default function AdminCard({
tier = "default",
className,
...props
}: Props) {
return (
<div
{...props}
className={twMerge(
"rounded-default border border-slate-200 dark:border-white/10",
tier == "secondary"
? "bg-background-light dark:bg-[#0e0e10]"
: "bg-background-light dark:bg-background-dark",
className,
)}
/>
);
}
-37
View File
@@ -1,37 +0,0 @@
import H1 from "@/src/components/twui/layout/H1";
import Section from "@/src/components/twui/layout/Section";
import { useContext, type ReactNode } from "react";
import Row from "../twui/layout/Row";
import Stack from "../twui/layout/Stack";
import Divider from "../twui/layout/Divider";
import Breadcrumbs from "../twui/elements/Breadcrumbs";
import { AppContext } from "@/src/pages/__root";
import Span from "../twui/layout/Span";
type Props = {
title: string;
description?: string | ReactNode;
buttons?: ReactNode;
};
export default function AdminHero({ title, description, buttons }: Props) {
return (
<Section>
<Stack className="w-full items-stretch">
<Row className="w-full justify-between items-start">
<Stack className="gap-3">
<H1>{title}</H1>
{description ? (
typeof description == "string" ? (
<Span>{description}</Span>
) : (
description
)
) : null}
</Stack>
<Row>{buttons}</Row>
</Row>
</Stack>
</Section>
);
}
+11
View File
@@ -0,0 +1,11 @@
import type { PropsWithChildren } from "react";
import ToastProvider from "./toast/toast-provider";
import ConfirmProvider from "./confirm/confirm-provider";
export default function AppProviders({ children }: PropsWithChildren) {
return (
<ToastProvider>
<ConfirmProvider>{children}</ConfirmProvider>
</ToastProvider>
);
}
+19
View File
@@ -0,0 +1,19 @@
import H1 from "@/src/components/twui/layout/H1";
import P from "@/src/components/twui/layout/P";
import Stack from "@/src/components/twui/layout/Stack";
import type { ReactNode } from "react";
type Props = {
title: string;
description?: ReactNode;
};
/** Title block at the top of a `CenteredShell` card. */
export default function AuthHeading({ title, description }: Props) {
return (
<Stack className="gap-1 mb-6">
<H1 className="text-xl!">{title}</H1>
{description ? <P className="text-[15px]">{description}</P> : null}
</Stack>
);
}
@@ -1,24 +0,0 @@
import Img from "@/src/components/twui/layout/Img";
import { twMerge } from "tailwind-merge";
type Props = {
src: string;
opacity?: number;
};
export default function BlurredImageBG({ src, opacity = 0.1 }: Props) {
/**
* Render component
*/
return (
<Img
alt="bg image"
src={src}
className={twMerge(
`absolute top-0 left-0 w-[150vw] h-full blur-xs object-cover`,
"z-0",
)}
style={{ opacity }}
/>
);
}
@@ -0,0 +1,18 @@
import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db";
import deriveHostDisplayName from "@/src/utils/derive-host-display-name";
import deriveWireguardInterfaceName from "@/src/utils/derive-wireguard-interface-name";
type Params = {
host: BUN_SQLITE_WGUI_HOSTS;
};
/** e.g. `Main host · wgui0 · 10.8.0.1` */
export default function buildHostOptionTitle({ host }: Params) {
return [
deriveHostDisplayName({ host }),
deriveWireguardInterfaceName({ host_id: Number(host.id || 0) }),
host.wg_ip_address,
]
.filter(Boolean)
.join(" · ");
}
@@ -0,0 +1,41 @@
import Checkbox from "@/src/components/twui/form/Checkbox";
import Input from "@/src/components/twui/form/Input";
import Stack from "@/src/components/twui/layout/Stack";
import type { ClientFormInit } from "./client-form-types";
type Props = ClientFormInit & {
field_key: number;
};
export default function ClientFormAllowedIps({ form, setForm, field_key }: Props) {
return (
<Stack className="gap-3 w-full items-stretch">
{form.allow_all_ips == 1 ? null : (
<Input
key={field_key}
name="allowed_ips"
title="Allowed IPs"
placeholder="e.g. 10.8.0.0/24"
info="Routes the client sends through the tunnel."
onChange={(e) => {
setForm((prev) => ({ ...prev, allowed_ips: e.target.value }));
}}
defaultValue={form.allowed_ips}
showLabel
/>
)}
<Checkbox
label="Route all traffic through the tunnel"
defaultChecked={form.allow_all_ips == 1}
changeHandler={(value) => {
setForm((prev) => ({ ...prev, allow_all_ips: value ? 1 : 0 }));
}}
info={
<span>
Sets <code>AllowedIPs</code> to <code>0.0.0.0/0, ::/0</code>
</span>
}
/>
</Stack>
);
}
@@ -0,0 +1,26 @@
import Select from "@/src/components/twui/form/Select";
import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db";
import buildHostOptionTitle from "./build-host-option-title";
type Props = {
hosts: BUN_SQLITE_WGUI_HOSTS[];
host_id: number;
onChange: ({ host_id }: { host_id: number }) => void;
disabled?: boolean;
};
export default function ClientFormHostSelect({ hosts, host_id, onChange, disabled }: Props) {
return (
<Select<string>
title="Host"
showLabel
disabled={disabled}
options={hosts.map((host) => ({
title: buildHostOptionTitle({ host }),
value: String(host.id),
default: Number(host.id) == host_id,
}))}
changeHandler={(value) => onChange({ host_id: Number(value) })}
/>
);
}
@@ -0,0 +1,19 @@
import Input from "@/src/components/twui/form/Input";
import type { ClientFormInit } from "./client-form-types";
export default function ClientFormName({ form, setForm, existing_full }: ClientFormInit) {
return (
<Input
name="name"
title="Name"
placeholder="e.g. Pixel phone"
autoFocus={!existing_full?.id}
onChange={(e) => {
setForm((prev) => ({ ...prev, name: e.target.value }));
}}
defaultValue={form.name}
showLabel
required
/>
);
}
@@ -0,0 +1,19 @@
import Input from "@/src/components/twui/form/Input";
import type { ClientFormInit } from "./client-form-types";
export default function ClientFormNotes({ form, setForm }: ClientFormInit) {
return (
<Input
name="notes"
title="Notes"
placeholder="e.g. Personal phone of Jane"
istextarea
rows={3}
onChange={(e) => {
setForm((prev) => ({ ...prev, notes: e.target.value }));
}}
defaultValue={form.notes}
showLabel
/>
);
}
@@ -0,0 +1,4 @@
import type { BUN_SQLITE_WGUI_CLIENTS } from "@/db/types/db";
import type useFormInit from "@/src/hooks/use-form-init";
export type ClientFormInit = ReturnType<typeof useFormInit<BUN_SQLITE_WGUI_CLIENTS>>;
@@ -0,0 +1,88 @@
import { useState } from "react";
import { Wand2 } from "lucide-react";
import Input from "@/src/components/twui/form/Input";
import Button from "@/src/components/twui/layout/Button";
import Row from "@/src/components/twui/layout/Row";
import Stack from "@/src/components/twui/layout/Stack";
import MutedText from "../muted-text";
import grabNextAvailableClientIp from "./grab-next-available-client-ip";
import type { ClientFormInit } from "./client-form-types";
type Props = ClientFormInit & {
host_id: number;
/** Bump to remount the input after the value is set by code */
field_key: number;
onGrabbed: () => void;
};
export default function ClientFormWgIp({
form,
setForm,
existing_full,
host_id,
field_key,
onGrabbed,
}: Props) {
const [grabbing, setGrabbing] = useState(false);
const [grab_error, setGrabError] = useState<string>();
const is_edit = Boolean(existing_full?.id);
async function handleGrab() {
setGrabbing(true);
setGrabError(undefined);
const res = await grabNextAvailableClientIp({ host_id, setForm });
setGrabbing(false);
if (!res.success) {
setGrabError(res.msg);
return;
}
onGrabbed();
}
return (
<Stack className="w-full items-stretch gap-1.5">
<Row className="w-full gap-2 items-end flex-wrap sm:flex-nowrap">
<Input
key={field_key}
name="wg_ip_address"
title="Tunnel IP"
placeholder="e.g. 10.8.0.2"
changeHandler={(v) => {
setForm((prev) => ({
...prev,
wg_ip_address: v,
allowed_ips: `${v.replace(/\.\d+$/, "")}.0/24`,
}));
}}
defaultValue={form.wg_ip_address}
readOnly={is_edit}
showLabel
wrapperWrapperProps={{ className: "grow" }}
/>
{is_edit ? null : (
<Button
title="Use the next free IP in this host's subnet"
type="button"
variant="outlined"
color="gray"
loading={grabbing}
className="py-2.5"
onClick={handleGrab}
>
<Wand2 size={14} />
Next free IP
</Button>
)}
</Row>
{grab_error ? (
<MutedText text_size="xs" tone="error">
{grab_error}
</MutedText>
) : is_edit ? (
<MutedText text_size="xs">The tunnel IP can't change after creation.</MutedText>
) : null}
</Stack>
);
}
@@ -0,0 +1,145 @@
import { useState } from "react";
import type {
BUN_SQLITE_WGUI_CLIENTS,
BUN_SQLITE_WGUI_CLIENT_RULES,
BUN_SQLITE_WGUI_HOSTS,
} from "@/db/types/db";
import Form from "@/src/components/twui/form/Form";
import LoadingBlock from "@/src/components/twui/elements/LoadingBlock";
import useFormInit from "@/src/hooks/use-form-init";
import useConfirm from "@/src/hooks/use-confirm";
import useToast from "@/src/hooks/use-toast";
import Panel from "../panel";
import FormSection from "../form-section";
import FormActions from "../form-actions";
import InlineAlert from "../inline-alert";
import ClientRulesEditor from "../client-rules/client-rules-editor";
import { clientRuleToDraft, type ClientRuleDraft } from "../client-rules/client-rule-draft";
import ClientFormHostSelect from "./client-form-host-select";
import ClientFormName from "./client-form-name";
import ClientFormNotes from "./client-form-notes";
import ClientFormWgIp from "./client-form-wg-ip";
import ClientFormAllowedIps from "./client-form-allowed-ips";
import grabNextAvailableClientIp from "./grab-next-available-client-ip";
import submitClientForm from "./submit-client-form";
type Props = {
hosts: BUN_SQLITE_WGUI_HOSTS[];
host_id: number;
existing_client?: BUN_SQLITE_WGUI_CLIENTS;
existing_client_rules?: BUN_SQLITE_WGUI_CLIENT_RULES[];
next_available_client_ip?: string | null;
cancel_href: string;
};
export default function ClientForm({
hosts,
host_id: initial_host_id,
existing_client,
existing_client_rules,
next_available_client_ip,
cancel_href,
}: Props) {
const { confirm } = useConfirm();
const { toast } = useToast();
const init = useFormInit<BUN_SQLITE_WGUI_CLIENTS>({
existing: existing_client,
existing_full: existing_client,
// Separate drafts so an edit session never pre-fills the add form
title: existing_client?.id ? `Edit Client ${existing_client.id}` : "Add Client",
async before_ready_function({ setForm }) {
setForm((prev) => ({
...prev,
wg_ip_address:
existing_client?.wg_ip_address || next_available_client_ip || "",
}));
},
});
const [host_id, setHostId] = useState(initial_host_id);
const [field_key, setFieldKey] = useState(0);
const [rules, setRules] = useState<ClientRuleDraft[]>(
(existing_client_rules || []).map((rule) => clientRuleToDraft({ rule })),
);
const { loading, status, ready, setForm } = init;
const is_edit = Boolean(existing_client?.id);
async function changeHost({ host_id: next }: { host_id: number }) {
setHostId(next);
const url = new URL(window.location.href);
url.searchParams.set("host_id", String(next));
window.history.replaceState(null, "", url);
const res = await grabNextAvailableClientIp({ host_id: next, setForm });
if (res.success) {
setFieldKey((k) => k + 1);
} else {
toast({ msg: res.msg || "Couldn't find a free IP on that host", color: "error" });
}
}
if (!ready) {
return <LoadingBlock className="h-[480px]" />;
}
return (
<Panel>
<Form
className="w-full gap-0"
onSubmit={() => {
submitClientForm({ init, host_id, rules, confirm, toast });
}}
>
{status?.error ? (
<InlineAlert tone="error" msg={status.msg} className="mb-6" />
) : null}
<FormSection
title="Host"
description="The WireGuard host this client connects to."
>
<ClientFormHostSelect
hosts={hosts}
host_id={host_id}
onChange={changeHost}
disabled={is_edit}
/>
</FormSection>
<FormSection title="Details" description="How this client shows up in the list.">
<ClientFormName {...init} />
<ClientFormNotes {...init} />
</FormSection>
<FormSection
title="Tunnel"
description="The client's address inside the tunnel and what it routes through it."
>
<ClientFormWgIp
{...init}
host_id={host_id}
field_key={field_key}
onGrabbed={() => setFieldKey((k) => k + 1)}
/>
<ClientFormAllowedIps {...init} field_key={field_key} />
</FormSection>
<FormSection
title="Access rules"
description="What this client may reach. Nothing is allowed until you add a rule."
>
<ClientRulesEditor rules={rules} setRules={setRules} />
</FormSection>
<FormActions
submit_title={is_edit ? "Save changes" : "Create client"}
loading={loading}
cancel_href={cancel_href}
/>
</Form>
</Panel>
);
}
@@ -0,0 +1,129 @@
import type {
BUN_SQLITE_WGUI_CLIENT_RULES,
BUN_SQLITE_WGUI_CLIENTS,
} from "@/db/types/db";
import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler";
import fetchApi from "@/src/components/twui/utils/fetch/fetchApi";
import type { ApiReqParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import _ from "lodash";
import { clientRuleDraftToRecord, type ClientRuleDraft } from "../client-rules/client-rule-draft";
import type { ConfirmContextValue } from "../confirm/confirm-context";
import type { ToastContextValue } from "../toast/toast-context";
import type { ClientFormInit } from "./client-form-types";
type Params = {
init: ClientFormInit;
host_id: number;
rules: ClientRuleDraft[];
confirm: ConfirmContextValue["confirm"];
toast: ToastContextValue["toast"];
};
export default async function submitClientForm({
init,
host_id,
rules,
confirm,
toast,
}: Params) {
const { setLoading, setStatus, form, existing_full, app_context, clearLocalForm } = init;
const is_edit = Boolean(existing_full?.id);
if (is_edit) {
const confirmed = await confirm({
title: "Save changes?",
message: "The client's config is regenerated. Devices may need the updated config.",
confirm_label: "Save changes",
});
if (!confirmed) return;
}
setLoading(true);
try {
const client_data: BUN_SQLITE_WGUI_CLIENTS = _.omitBy<BUN_SQLITE_WGUI_CLIENTS>(
{
name: form.name || "",
wg_ip_address: form.wg_ip_address || "",
allowed_ips: form.allowed_ips || "",
notes: form.notes || "",
host_id,
user_id: app_context?.user?.id,
allow_all_ips: form.allow_all_ips,
},
(value) => value === undefined,
);
const res = is_edit
? await adminCrudHandler({
action: "update",
table: "clients",
update_data: client_data,
id: existing_full?.id,
})
: await adminCrudHandler({
action: "insert",
table: "clients",
insert_data: [client_data],
});
if (!res.success) {
throw new Error(res.msg || (is_edit ? "Couldn't update the client" : "Couldn't create the client"));
}
const client_id = existing_full?.id || res.postInsertReturn?.insertId;
if (!client_id) {
throw new Error("The client was saved but no ID came back");
}
await adminCrudHandler<BUN_SQLITE_WGUI_CLIENT_RULES>({
action: "delete",
table: "client_rules",
sql_query: {
query: {
client_id: { value: String(client_id) },
host_id: { value: String(host_id) },
},
},
});
if (rules.length) {
const rules_res = await adminCrudHandler<BUN_SQLITE_WGUI_CLIENT_RULES>({
action: "insert",
table: "client_rules",
insert_data: rules.map((draft) => ({
...clientRuleDraftToRecord({ draft }),
host_id,
client_id: Number(client_id),
user_id: app_context.user?.id,
})),
});
if (!rules_res.success) {
toast({ msg: rules_res.msg || "Client saved, but its rules failed to save", color: "error" });
}
}
const setup_res = await fetchApi<ApiReqParams, APIResponseObject>(
`/api/admin/run-client-setup`,
{ method: "POST", body: { client_id } },
);
if (!setup_res?.success) {
toast({ msg: setup_res?.msg || "Client saved, but its config failed to generate", color: "error" });
}
clearLocalForm();
const base = `/admin/hosts/${host_id}/clients/${client_id}`;
window.location.href = is_edit ? base : `${base}?tab=config`;
} catch (error: any) {
setStatus({
error: true,
msg: error.message || "Couldn't save the client",
});
setLoading(false);
}
}
-131
View File
@@ -1,131 +0,0 @@
import { useState } from "react";
import { Pencil, Trash2 } from "lucide-react";
import Link from "@/src/components/twui/layout/Link";
import Row from "@/src/components/twui/layout/Row";
import Span from "@/src/components/twui/layout/Span";
import formatUnixTimestamp from "@/src/utils/format-unix-timestamp";
import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler";
import type {
BUN_SQLITE_WGUI_CLIENTS,
BUN_SQLITE_WGUI_CLIENT_RULES,
} from "@/db/types/db";
import deriveClientAccessLabel from "@/src/functions/frontend/derive-client-access-label";
import type { BUN_SQLITE_WGUI_CLIENTS_JOIN } from "@/src/types/sql-joins";
type Props = {
client: BUN_SQLITE_WGUI_CLIENTS_JOIN;
rules?: BUN_SQLITE_WGUI_CLIENT_RULES[];
};
export default function ClientRow({ client, rules }: Props) {
const [deleting, setDeleting] = useState(false);
const host_id = Number(client.host_id || 0);
const access = deriveClientAccessLabel({ rules });
const host_name =
host_id == 0 ? "Main" : client.host_name || `Host #${host_id}`;
async function handleDelete() {
if (
!window.confirm(
`Delete client "${client?.name || `#${client?.id}`}"? The client will be removed from the host and its config file deleted.`,
)
) {
return;
}
setDeleting(true);
const res = await adminCrudHandler({
action: "delete",
table: "clients",
id: client?.id,
});
if (!res.success) {
setDeleting(false);
window.alert(res.msg || "Could not delete client");
return;
}
window.location.reload();
}
return (
<tr className="border-b border-slate-200/60 dark:border-white/5 last:border-b-0 hover:bg-foreground-light/[0.02] dark:hover:bg-foreground-dark/[0.02] transition-colors">
<td className="px-4 py-[9px]">
<Link
href={`/admin/hosts/${host_id}/clients/${client.id}`}
className="text-[13.5px] font-medium text-foreground-light dark:text-foreground-dark hover:text-secondary dark:hover:text-secondary"
>
{client.name || "—"}
</Link>
</td>
<td className="px-4 py-[9px]">
{typeof host_id == "number" ? (
<Link
href={`/admin/hosts/${host_id}`}
className="tabular text-[13px] text-foreground-light/60 dark:text-foreground-dark/60 hover:text-secondary dark:hover:text-secondary whitespace-nowrap"
>
{host_name}
</Link>
) : (
<Span className="tabular text-[13px] text-foreground-light/40 dark:text-foreground-dark/40 whitespace-nowrap">
—
</Span>
)}
</td>
<td className="px-4 py-[9px] tabular text-[13px] text-foreground-light/60 dark:text-foreground-dark/60 whitespace-nowrap">
{client.wg_ip_address || "—"}
</td>
<td className="px-4 py-[9px] tabular text-[13px] text-foreground-light/45 dark:text-foreground-dark/45 whitespace-nowrap">
{client.allow_all_ips
? "0.0.0.0/0, ::/0"
: client.allowed_ips || "—"}
</td>
<td className="px-4 py-[9px] whitespace-nowrap">
<Span
className={
access.kind == "none"
? "text-[12.5px] text-error/80"
: "text-[12.5px] text-foreground-light/60 dark:text-foreground-dark/60"
}
>
{access.text}
</Span>
</td>
<td className="px-4 py-[9px]">
<Span className="font-mono text-[12px] text-foreground-light/45 dark:text-foreground-dark/45 whitespace-nowrap">
{client.public_key
? `${client.public_key.slice(0, 16)}…`
: "—"}
</Span>
</td>
<td className="px-4 py-[9px] tabular text-[12px] text-foreground-light/40 dark:text-foreground-dark/40 text-right whitespace-nowrap">
{formatUnixTimestamp({ timestamp: client.created_at })}
</td>
<td className="px-4 py-[9px] text-right">
<Row className="gap-1.5 items-center justify-end">
<Link
href={`/admin/hosts/${host_id}/clients/${client.id}/edit`}
title={`Edit client ${client.name || `#${client.id}`}`}
aria-label={`Edit client ${client.name || `#${client.id}`}`}
className="p-1.5 text-foreground-light/40 dark:text-foreground-dark/40 hover:text-foreground-light dark:hover:text-foreground-dark focus:outline-2"
>
<Pencil size={14} />
</Link>
<button
type="button"
title={`Delete client ${client.name || `#${client.id}`}`}
aria-label={`Delete client ${client.name || `#${client.id}`}`}
disabled={deleting}
onClick={handleDelete}
className="p-1.5 text-error hover:bg-error/10 focus:outline-2 transition-colors duration-150"
>
<Trash2 size={14} />
</button>
</Row>
</td>
</tr>
);
}
@@ -0,0 +1,30 @@
import expandClientRuleDestinations from "@/src/functions/backend/setup/expand-client-rule-destinations";
import sanitizeClientRule from "@/src/functions/backend/setup/sanitize-client-rule";
import validateClientRule from "@/src/functions/backend/setup/validate-client-rule";
import {
clientRuleDraftToRecord,
clientRuleToDraft,
type ClientRuleDraft,
} from "./client-rule-draft";
type Params = {
draft: ClientRuleDraft;
};
/** Sanitizes and validates a draft, splitting comma-separated destinations. */
export default function buildClientRuleDrafts({ draft }: Params) {
const sanitized = sanitizeClientRule({
rule: clientRuleDraftToRecord({ draft }),
});
const valid = validateClientRule({ rule: sanitized });
if (!valid.success || !sanitized) {
return { success: false as const, msg: valid.msg || "Invalid rule" };
}
const drafts = expandClientRuleDestinations({ rule: sanitized }).map(
(rule) => clientRuleToDraft({ rule }),
);
return { success: true as const, drafts };
}
@@ -0,0 +1,16 @@
import ClientRuleItem from "./client-rule-item";
import { clientRuleDraftToRecord, type ClientRuleDraft } from "./client-rule-draft";
type Props = {
draft: ClientRuleDraft;
onRemove: ({ local_id }: { local_id: string }) => void;
};
export default function ClientRuleDraftItem({ draft, onRemove }: Props) {
return (
<ClientRuleItem
rule={clientRuleDraftToRecord({ draft })}
onRemove={() => onRemove({ local_id: draft.local_id })}
/>
);
}
@@ -0,0 +1,37 @@
import Stack from "@/src/components/twui/layout/Stack";
import type { ClientRuleDraft } from "./client-rule-draft";
import ClientRuleDestinationField from "./client-rule-destination-field";
import ClientRulePortsField from "./client-rule-ports-field";
import ClientRuleProtocolField from "./client-rule-protocol-field";
import ClientRuleTypeField from "./client-rule-type-field";
type Props = {
draft: ClientRuleDraft;
setDraft: (draft: ClientRuleDraft) => void;
onEnter: () => void;
};
/**
* Not a `<form>`: this renders inside a portalled modal, and React submit
* events bubble through portals into whatever form hosts the modal.
*/
export default function ClientRuleFields({ draft, setDraft, onEnter }: Props) {
return (
<Stack
className="w-full items-stretch gap-5"
onKeyDown={(e) => {
if (e.key == "Enter" && (e.target as HTMLElement).tagName == "INPUT") {
e.preventDefault();
onEnter();
}
}}
>
<ClientRuleTypeField draft={draft} setDraft={setDraft} />
<ClientRuleDestinationField draft={draft} setDraft={setDraft} />
<Stack className="w-full gap-5 sm:grid sm:grid-cols-2 sm:gap-3 items-stretch">
<ClientRulePortsField draft={draft} setDraft={setDraft} />
<ClientRuleProtocolField draft={draft} setDraft={setDraft} />
</Stack>
</Stack>
);
}
@@ -0,0 +1,35 @@
import { ShieldCheck, Trash2 } from "lucide-react";
import Button from "@/src/components/twui/layout/Button";
import Row from "@/src/components/twui/layout/Row";
import MonoText from "../mono-text";
import formatClientRuleLabel from "@/src/functions/frontend/format-client-rule-label";
import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db";
type Props = {
rule: BUN_SQLITE_WGUI_CLIENT_RULES;
onRemove: () => void;
removing?: boolean;
};
export default function ClientRuleItem({ rule, onRemove, removing }: Props) {
return (
<Row className="w-full justify-between gap-3 px-4 py-2.5 border-t border-line first:border-t-0 flex-nowrap">
<Row className="gap-2.5 flex-nowrap min-w-0">
<ShieldCheck size={15} className="shrink-0 text-success dark:text-success-dark" />
<MonoText className="break-all">{formatClientRuleLabel({ rule })}</MonoText>
</Row>
<Button
title="Remove rule"
type="button"
variant="ghost"
color="error"
size="small"
loading={removing}
className="p-1.5!"
onClick={onRemove}
>
<Trash2 size={14} />
</Button>
</Row>
);
}
@@ -0,0 +1,123 @@
import { useState } from "react";
import { Plus } from "lucide-react";
import Modal from "@/src/components/twui/elements/Modal";
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 "../inline-alert";
import ClientRuleFields from "./client-rule-fields";
import buildClientRuleDrafts from "./build-client-rule-drafts";
import { createClientRuleDraft, type ClientRuleDraft } from "./client-rule-draft";
export type ClientRuleModalAddResult = { success: boolean; msg?: string };
type Props = {
onAdd: ({ rules }: { rules: ClientRuleDraft[] }) => Promise<ClientRuleModalAddResult>;
disabled?: boolean;
};
/** Validates a rule and hands it to `onAdd`. Closes only on success. */
export default function ClientRuleModal({ onAdd, disabled }: Props) {
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<ClientRuleDraft>(createClientRuleDraft());
const [error, setError] = useState<string>();
const [saving, setSaving] = useState(false);
function reset() {
setDraft(createClientRuleDraft());
setError(undefined);
}
async function handleAdd() {
const built = buildClientRuleDrafts({ draft });
if (!built.success) {
setError(built.msg);
return;
}
setSaving(true);
const res = await onAdd({ rules: built.drafts });
setSaving(false);
if (!res.success) {
setError(res.msg || "Couldn't add the rule");
return;
}
reset();
setOpen(false);
}
return (
<Modal
open={open}
setOpen={(value) => {
setOpen(value);
if (value === false) reset();
}}
className="max-w-lg p-5"
target={
<Button
title="Add rule"
type="button"
variant="outlined"
color="gray"
size="small"
disabled={disabled}
>
<Plus size={14} />
Add rule
</Button>
}
>
<Stack className="w-full items-stretch gap-5">
<Stack className="gap-1 pr-8">
<H3 className="text-lg!">Add access rule</H3>
<P className="text-[15px]">
Allow this client to reach a destination. Leave ports empty for any port.
</P>
</Stack>
{error ? <InlineAlert tone="error" msg={error} /> : null}
<ClientRuleFields
key={draft.local_id}
draft={draft}
setDraft={(next) => {
setDraft(next);
setError(undefined);
}}
onEnter={handleAdd}
/>
<Row className="w-full justify-end gap-2 pt-1">
<Button
title="Cancel"
type="button"
variant="outlined"
color="gray"
size="small"
onClick={() => {
reset();
setOpen(false);
}}
>
Cancel
</Button>
<Button
title="Add rule"
type="button"
size="small"
loading={saving}
onClick={handleAdd}
>
Add rule
</Button>
</Row>
</Stack>
</Modal>
);
}
@@ -0,0 +1,23 @@
import Checkbox from "@/src/components/twui/form/Checkbox";
import type { ClientRuleDraft } from "./client-rule-draft";
type Props = {
draft: ClientRuleDraft;
setDraft: (draft: ClientRuleDraft) => void;
};
export default function ClientRuleTypeField({ draft, setDraft }: Props) {
return (
<Checkbox
defaultChecked={draft.rule_type == "all"}
label="Allow all access"
info="Unrestricted access to the host, its LAN, the internet and other clients."
changeHandler={(checked) => {
setDraft({
...draft,
rule_type: checked ? "all" : "destination",
});
}}
/>
);
}
@@ -0,0 +1,45 @@
import Card from "@/src/components/twui/elements/Card";
import Stack from "@/src/components/twui/layout/Stack";
import ClientRuleDraftItem from "./client-rule-draft-item";
import ClientRuleModal from "./client-rule-modal";
import ClientRulesEmpty from "./client-rules-empty";
import type { ClientRuleDraft } from "./client-rule-draft";
type Props = {
rules: ClientRuleDraft[];
setRules: (rules: ClientRuleDraft[]) => void;
};
/** Draft rules held in the client form; saved together with the client. */
export default function ClientRulesEditor({ rules, setRules }: Props) {
const has_all_access = rules.some((rule) => rule.rule_type == "all");
return (
<Stack className="w-full items-stretch gap-3">
<Card noHover className="w-full flex-col items-stretch p-0">
{rules.length ? (
rules.map((rule) => (
<ClientRuleDraftItem
key={rule.local_id}
draft={rule}
onRemove={({ local_id }) =>
setRules(rules.filter((item) => item.local_id != local_id))
}
/>
))
) : (
<ClientRulesEmpty />
)}
</Card>
<Stack className="items-start">
<ClientRuleModal
disabled={has_all_access}
onAdd={async ({ rules: added }) => {
setRules([...rules, ...added]);
return { success: true };
}}
/>
</Stack>
</Stack>
);
}
@@ -0,0 +1,13 @@
import { ShieldOff } from "lucide-react";
import EmptyState from "../empty-state";
export default function ClientRulesEmpty() {
return (
<EmptyState
className="py-8"
icon={<ShieldOff size={18} />}
title="No access rules"
description="Zero trust: without rules this client can't reach the host, its LAN, the internet or other clients."
/>
);
}
-55
View File
@@ -1,55 +0,0 @@
import ClientRow from "@/src/components/general/client-row";
import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db";
import type { BUN_SQLITE_WGUI_CLIENTS_JOIN } from "@/src/types/sql-joins";
type Props = {
clients: BUN_SQLITE_WGUI_CLIENTS_JOIN[];
rules_by_client_id: Map<number, BUN_SQLITE_WGUI_CLIENT_RULES[]>;
accessLabel?: string;
minWidthClass?: string;
};
const thClass =
"px-4 py-2 text-left text-[11px] font-semibold uppercase tracking-[0.08em] " +
"text-foreground-light/40 dark:text-foreground-dark/40 whitespace-nowrap";
const thRightClass = `${thClass} text-right`;
export default function ClientsTable({
clients,
rules_by_client_id,
accessLabel = "Access",
minWidthClass = "min-w-[860px]",
}: Props) {
return (
<div className="overflow-x-auto w-full">
<table className={`w-full ${minWidthClass}`}>
<thead className="w-full">
<tr className="border-y border-slate-200 dark:border-white/10 bg-foreground-light/[0.02] dark:bg-foreground-dark/[0.03] w-full">
<th className={thClass}>Client</th>
<th className={thClass}>Host</th>
<th className={thClass}>Tunnel IP</th>
<th className={thClass}>Allowed IPs</th>
<th className={thClass}>{accessLabel}</th>
<th className={thClass}>Public key</th>
<th className={thRightClass}>Created</th>
<th className={thRightClass}>Actions</th>
</tr>
</thead>
<tbody>
{clients.map((client) => (
<ClientRow
key={client.id}
client={client}
rules={
client.id
? rules_by_client_id.get(Number(client.id))
: undefined
}
/>
))}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,24 @@
import StatusDot from "../status-dot";
import deriveClientAccessLabel from "@/src/functions/frontend/derive-client-access-label";
import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db";
type Props = {
rules?: BUN_SQLITE_WGUI_CLIENT_RULES[];
};
export default function ClientAccessTag({ rules }: Props) {
const access = deriveClientAccessLabel({ rules });
return (
<StatusDot
tone={
access.kind == "none"
? "error"
: access.kind == "all"
? "success"
: "warning"
}
label={access.kind == "none" ? "No access" : access.text}
/>
);
}
@@ -0,0 +1,33 @@
import Row from "@/src/components/twui/layout/Row";
import Stack from "@/src/components/twui/layout/Stack";
import MutedText from "../muted-text";
import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db";
import type { BUN_SQLITE_WGUI_CLIENTS_JOIN } from "@/src/types/sql-joins";
import ClientAccessTag from "./client-access-tag";
import ClientNameCell from "./client-name-cell";
import ClientRowActions from "./client-row-actions";
import deriveClientHostName from "./derive-client-host-name";
type Props = {
client: BUN_SQLITE_WGUI_CLIENTS_JOIN;
rules?: BUN_SQLITE_WGUI_CLIENT_RULES[];
hide_host?: boolean;
onDeleted?: ({ id }: { id: number }) => void;
};
export default function ClientMobileCard({ client, rules, hide_host, onDeleted }: Props) {
return (
<Row className="w-full justify-between items-start gap-3 px-4 py-3 border-t border-line first:border-t-0 flex-nowrap">
<Stack className="gap-1.5 min-w-0">
<ClientNameCell client={client} />
<Row className="gap-x-3 gap-y-1">
{hide_host ? null : (
<MutedText>{deriveClientHostName({ client })}</MutedText>
)}
<ClientAccessTag rules={rules} />
</Row>
</Stack>
<ClientRowActions client={client} onDeleted={onDeleted} />
</Row>
);
}
@@ -0,0 +1,24 @@
import Link from "@/src/components/twui/layout/Link";
import Stack from "@/src/components/twui/layout/Stack";
import MonoText from "../mono-text";
import type { BUN_SQLITE_WGUI_CLIENTS_JOIN } from "@/src/types/sql-joins";
type Props = {
client: BUN_SQLITE_WGUI_CLIENTS_JOIN;
};
export default function ClientNameCell({ client }: Props) {
return (
<Stack className="gap-0">
<Link
href={`/admin/hosts/${Number(client.host_id || 0)}/clients/${client.id}`}
className="text-[15px] font-medium text-fg! hover:text-primary! dark:hover:text-primary-dark-text! no-underline!"
>
{client.name || `Client #${client.id}`}
</Link>
<MonoText className="text-subtle lg:hidden">
{client.wg_ip_address || "—"}
</MonoText>
</Stack>
);
}
@@ -0,0 +1,38 @@
import { Eye, Pencil, Trash2 } from "lucide-react";
import ActionMenu from "../action-menu";
import useDeleteClient from "./use-delete-client";
import type { BUN_SQLITE_WGUI_CLIENTS_JOIN } from "@/src/types/sql-joins";
type Props = {
client: BUN_SQLITE_WGUI_CLIENTS_JOIN;
onDeleted?: ({ id }: { id: number }) => void;
};
export default function ClientRowActions({ client, onDeleted }: Props) {
const { deleteClient, deleting } = useDeleteClient({ onDeleted });
const base = `/admin/hosts/${Number(client.host_id || 0)}/clients/${client.id}`;
return (
<ActionMenu
label={`Actions for ${client.name || `client #${client.id}`}`}
className="items-end"
items={[
{ key: "view", title: "View", href: base, icon: <Eye size={14} /> },
{
key: "edit",
title: "Edit",
href: `${base}/edit`,
icon: <Pencil size={14} />,
},
{
key: "delete",
title: "Delete",
tone: "danger",
icon: <Trash2 size={14} />,
loading: deleting,
onClick: () => deleteClient({ client }),
},
]}
/>
);
}
@@ -0,0 +1,108 @@
import Link from "@/src/components/twui/layout/Link";
import MonoText from "../mono-text";
import MutedText from "../muted-text";
import CopyableValue from "../copyable-value";
import formatUnixTimestamp from "@/src/utils/format-unix-timestamp";
import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db";
import type { BUN_SQLITE_WGUI_CLIENTS_JOIN } from "@/src/types/sql-joins";
import type { DataTableColumn } from "../data-table/types";
import ClientAccessTag from "./client-access-tag";
import ClientNameCell from "./client-name-cell";
import ClientRowActions from "./client-row-actions";
import deriveClientHostName from "./derive-client-host-name";
type Params = {
hide_host?: boolean;
rules_by_client_id: Map<number, BUN_SQLITE_WGUI_CLIENT_RULES[]>;
onDeleted?: ({ id }: { id: number }) => void;
};
export default function buildClientsTableColumns({
hide_host,
rules_by_client_id,
onDeleted,
}: Params): DataTableColumn<BUN_SQLITE_WGUI_CLIENTS_JOIN>[] {
const columns: (DataTableColumn<BUN_SQLITE_WGUI_CLIENTS_JOIN> | null)[] = [
{
key: "name",
header: "Client",
render: ({ item }) => <ClientNameCell client={item} />,
},
hide_host
? null
: {
key: "host",
header: "Host",
render: ({ item }) => (
<Link
href={`/admin/hosts/${Number(item.host_id || 0)}`}
className="text-[15px] text-muted! hover:text-fg! no-underline! whitespace-nowrap"
>
{deriveClientHostName({ client: item })}
</Link>
),
},
{
key: "ip",
header: "Tunnel IP",
hide_below: "lg",
render: ({ item }) => <MonoText>{item.wg_ip_address || "—"}</MonoText>,
},
{
key: "allowed",
header: "Allowed IPs",
hide_below: "xl",
className: "max-w-[200px]",
render: ({ item }) => (
<MonoText truncate_text className="text-muted">
{item.allow_all_ips ? "0.0.0.0/0, ::/0" : item.allowed_ips || "—"}
</MonoText>
),
},
{
key: "access",
header: "Access",
render: ({ item }) => (
<ClientAccessTag rules={rules_by_client_id.get(Number(item.id))} />
),
},
{
key: "key",
header: "Public key",
hide_below: "xl",
className: "max-w-[180px]",
render: ({ item }) =>
item.public_key ? (
<CopyableValue
value={item.public_key}
label="Public key"
display={`${item.public_key.slice(0, 12)}…`}
/>
) : (
<MutedText>—</MutedText>
),
},
{
key: "created",
header: "Created",
align: "right",
hide_below: "lg",
render: ({ item }) => (
<MutedText className="tabular whitespace-nowrap">
{formatUnixTimestamp({ timestamp: item.created_at })}
</MutedText>
),
},
{
key: "actions",
header: <span className="sr-only">Actions</span>,
align: "right",
className: "w-10",
render: ({ item }) => (
<ClientRowActions client={item} onDeleted={onDeleted} />
),
},
];
return columns.filter(Boolean) as DataTableColumn<BUN_SQLITE_WGUI_CLIENTS_JOIN>[];
}
@@ -0,0 +1,10 @@
import type { BUN_SQLITE_WGUI_CLIENTS_JOIN } from "@/src/types/sql-joins";
type Params = {
client: BUN_SQLITE_WGUI_CLIENTS_JOIN;
};
export default function deriveClientHostName({ client }: Params) {
const host_id = Number(client.host_id || 0);
return client.host_name || (host_id == 0 ? "Main" : `Host #${host_id}`);
}
@@ -0,0 +1,57 @@
import { useCallback, useMemo, type ReactNode } from "react";
import DataTable from "../data-table";
import buildClientsTableColumns from "./clients-table-columns";
import ClientMobileCard from "./client-mobile-card";
import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db";
import type { BUN_SQLITE_WGUI_CLIENTS_JOIN } from "@/src/types/sql-joins";
type Props = {
/** `undefined` while loading */
clients?: BUN_SQLITE_WGUI_CLIENTS_JOIN[];
rules_by_client_id: Map<number, BUN_SQLITE_WGUI_CLIENT_RULES[]>;
loading?: boolean;
hide_host?: boolean;
empty?: ReactNode;
skeleton_rows?: number;
onDeleted?: ({ id }: { id: number }) => void;
};
export default function ClientsTable({
clients,
rules_by_client_id,
loading,
hide_host,
empty,
skeleton_rows,
onDeleted,
}: Props) {
const columns = useMemo(
() => buildClientsTableColumns({ hide_host, rules_by_client_id, onDeleted }),
[hide_host, rules_by_client_id, onDeleted],
);
const MobileItem = useCallback(
({ item }: { item: BUN_SQLITE_WGUI_CLIENTS_JOIN }) => (
<ClientMobileCard
client={item}
rules={rules_by_client_id.get(Number(item.id))}
hide_host={hide_host}
onDeleted={onDeleted}
/>
),
[rules_by_client_id, hide_host, onDeleted],
);
return (
<DataTable
caption="Clients"
items={clients}
columns={columns}
getKey={({ item }) => Number(item.id)}
loading={loading}
empty={empty}
skeleton_rows={skeleton_rows}
MobileItem={MobileItem}
/>
);
}
@@ -0,0 +1,50 @@
import { useState } from "react";
import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler";
import useConfirm from "@/src/hooks/use-confirm";
import useToast from "@/src/hooks/use-toast";
import type { BUN_SQLITE_WGUI_CLIENTS } from "@/db/types/db";
type Params = {
onDeleted?: ({ id }: { id: number }) => void;
};
export default function useDeleteClient({ onDeleted }: Params = {}) {
const { confirm } = useConfirm();
const { toast } = useToast();
const [deleting, setDeleting] = useState(false);
async function deleteClient({ client }: { client: BUN_SQLITE_WGUI_CLIENTS }) {
const name = client.name || `#${client.id}`;
const confirmed = await confirm({
title: `Delete ${name}?`,
message:
"The peer is removed from its host and its config file is deleted. Devices using this config will lose access.",
confirm_label: "Delete client",
tone: "danger",
});
if (!confirmed || !client.id) return false;
setDeleting(true);
const res = await adminCrudHandler({
action: "delete",
table: "clients",
id: client.id,
});
setDeleting(false);
if (!res.success) {
toast({ msg: res.msg || "Couldn't delete the client", color: "error" });
return false;
}
toast({ msg: `${name} deleted`, color: "success" });
onDeleted?.({ id: Number(client.id) });
return true;
}
return { deleteClient, deleting };
}
@@ -0,0 +1,17 @@
import { createContext, type ReactNode } from "react";
export type ConfirmParams = {
title: string;
message?: ReactNode;
confirm_label?: string;
cancel_label?: string;
tone?: "default" | "danger";
};
export type ConfirmContextValue = {
confirm: (params: ConfirmParams) => Promise<boolean>;
};
export const ConfirmContext = createContext<ConfirmContextValue>({
confirm: async () => false,
});
@@ -0,0 +1,97 @@
import Modal from "@/src/components/twui/elements/Modal";
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 { TriangleAlert } from "lucide-react";
import React, { useEffect, useRef } from "react";
import type { ConfirmParams } from "./confirm-context";
type Props = {
open: boolean;
params: ConfirmParams | null;
onResolve: ({ confirmed }: { confirmed: boolean }) => void;
};
export default function ConfirmDialog({ open, params, onResolve }: Props) {
const confirmRef = useRef<HTMLButtonElement>(null);
const danger = params?.tone == "danger";
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => {
if (e.key == "Escape") onResolve({ confirmed: false });
};
document.addEventListener("keydown", onKey);
const focusTimer = setTimeout(() => confirmRef.current?.focus(), 30);
return () => {
document.removeEventListener("keydown", onKey);
clearTimeout(focusTimer);
};
}, [open]);
const setOpen = ((value: React.SetStateAction<boolean>) => {
const next = typeof value == "function" ? value(open) : value;
if (!next && open) onResolve({ confirmed: false });
}) as React.Dispatch<React.SetStateAction<boolean>>;
return (
<Modal
open={open}
setOpen={setOpen}
no_cancel_button
className="max-w-md p-5"
aria-labelledby="confirm-dialog-title"
>
<Stack className="gap-4 w-full">
<Row className="gap-3 flex-nowrap items-start">
{danger ? (
<Row className="size-8 shrink-0 justify-center rounded-full bg-error/10 text-error dark:text-error-dark">
<TriangleAlert size={16} />
</Row>
) : null}
<Stack className="gap-1.5">
<H3 id="confirm-dialog-title" className="text-lg!">
{params?.title}
</H3>
{params?.message ? (
typeof params.message == "string" ? (
<P className="text-[15px] m-0">
{params.message}
</P>
) : (
params.message
)
) : null}
</Stack>
</Row>
<Row className="w-full justify-end gap-2">
<Button
title={params?.cancel_label || "Cancel"}
type="button"
variant="outlined"
color="gray"
size="small"
onClick={() => onResolve({ confirmed: false })}
>
{params?.cancel_label || "Cancel"}
</Button>
<Button
ref={confirmRef}
title={params?.confirm_label || "Confirm"}
type="button"
color={danger ? "error" : "primary"}
size="small"
onClick={() => onResolve({ confirmed: true })}
>
{params?.confirm_label || "Confirm"}
</Button>
</Row>
</Stack>
</Modal>
);
}
@@ -0,0 +1,32 @@
import { useCallback, useRef, useState, type PropsWithChildren } from "react";
import { ConfirmContext, type ConfirmParams } from "./confirm-context";
import ConfirmDialog from "./confirm-dialog";
export default function ConfirmProvider({ children }: PropsWithChildren) {
const [open, setOpen] = useState(false);
const [params, setParams] = useState<ConfirmParams | null>(null);
const resolver = useRef<((confirmed: boolean) => void) | null>(null);
const confirm = useCallback((next: ConfirmParams) => {
resolver.current?.(false);
setParams(next);
setOpen(true);
return new Promise<boolean>((resolve) => {
resolver.current = resolve;
});
}, []);
function onResolve({ confirmed }: { confirmed: boolean }) {
resolver.current?.(confirmed);
resolver.current = null;
setOpen(false);
}
return (
<ConfirmContext.Provider value={{ confirm }}>
{children}
<ConfirmDialog open={open} params={params} onResolve={onResolve} />
</ConfirmContext.Provider>
);
}
+50
View File
@@ -0,0 +1,50 @@
import Button from "@/src/components/twui/layout/Button";
import useToast from "@/src/hooks/use-toast";
import { Check, Copy } from "lucide-react";
import { useState } from "react";
import { twMerge } from "tailwind-merge";
type Props = {
value: string;
/** What is being copied, e.g. "Public key" */
label: string;
/** Show the word "Copy" next to the icon */
show_text?: boolean;
className?: string;
};
export default function CopyButton({ value, label, show_text, className }: Props) {
const { toast } = useToast();
const [copied, setCopied] = useState(false);
async function copy() {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
toast({ msg: `${label} copied`, color: "success" });
setTimeout(() => setCopied(false), 1500);
} catch {
toast({ msg: `Couldn't copy ${label.toLowerCase()}`, color: "error" });
}
}
const Icon = copied ? Check : Copy;
return (
<Button
title={`Copy ${label.toLowerCase()}`}
type="button"
variant={show_text ? "outlined" : "ghost"}
color="gray"
size="small"
className={twMerge(
!show_text && "p-1! rounded-default-sm hover:bg-gray! dark:hover:bg-gray-dark!",
className,
)}
onClick={copy}
>
<Icon size={show_text ? 14 : 13} />
{show_text ? (copied ? "Copied" : "Copy") : null}
</Button>
);
}
+24
View File
@@ -0,0 +1,24 @@
import Row from "@/src/components/twui/layout/Row";
import type { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import CopyButton from "./copy-button";
import MonoText from "./mono-text";
type Props = {
value: string;
label: string;
/** Rendered instead of the raw value (e.g. a shortened key) */
display?: ReactNode;
className?: string;
};
export default function CopyableValue({ value, label, display, className }: Props) {
return (
<Row className={twMerge("gap-1 flex-nowrap min-w-0", className)}>
<MonoText truncate_text title={value} className="min-w-0">
{display ?? value}
</MonoText>
<CopyButton value={value} label={label} className="shrink-0" />
</Row>
);
}
@@ -0,0 +1,22 @@
import { twMerge } from "tailwind-merge";
import { HideBelowClasses, type DataTableColumn } from "./types";
type Props<T> = {
column: DataTableColumn<T>;
item: T;
};
export default function DataTableCell<T>({ column, item }: Props<T>) {
return (
<td
className={twMerge(
"px-4 py-2.5 align-middle text-[15px]",
column.align == "right" && "text-right",
column.hide_below && HideBelowClasses[column.hide_below],
column.className,
)}
>
{column.render({ item })}
</td>
);
}
@@ -0,0 +1,21 @@
import { twMerge } from "tailwind-merge";
import { HideBelowClasses, type DataTableColumn } from "./types";
type Props = {
column: DataTableColumn<any>;
};
export default function DataTableHeadCell({ column }: Props) {
return (
<th
scope="col"
className={twMerge(
"px-4 py-2.5 text-left text-xs font-semibold uppercase tracking-[0.06em] text-subtle whitespace-nowrap",
column.align == "right" && "text-right",
column.hide_below && HideBelowClasses[column.hide_below],
)}
>
{column.header}
</th>
);
}
@@ -0,0 +1,26 @@
import Stack from "@/src/components/twui/layout/Stack";
import type { DataTableProps } from "./types";
import DataTableMobileSkeleton from "./data-table-mobile-skeleton";
type Props<T> = Pick<DataTableProps<T>, "items" | "getKey" | "MobileItem"> & {
loading: boolean;
skeleton_keys: number[];
};
export default function DataTableMobileList<T>({
items,
getKey,
MobileItem,
loading,
skeleton_keys,
}: Props<T>) {
return (
<Stack className="w-full gap-0 md:hidden items-stretch">
{loading
? skeleton_keys.map((key) => <DataTableMobileSkeleton key={key} />)
: items?.map((item) => (
<MobileItem key={getKey({ item })} item={item} />
))}
</Stack>
);
}
@@ -0,0 +1,11 @@
import LoadingBlock from "@/src/components/twui/elements/LoadingBlock";
import Stack from "@/src/components/twui/layout/Stack";
export default function DataTableMobileSkeleton() {
return (
<Stack className="w-full gap-2 p-4 border-t border-line first:border-t-0" aria-hidden>
<LoadingBlock className="h-4 w-1/2" />
<LoadingBlock className="h-3 w-3/4" />
</Stack>
);
}
@@ -0,0 +1,17 @@
import DataTableCell from "./data-table-cell";
import type { DataTableColumn } from "./types";
type Props<T> = {
columns: DataTableColumn<T>[];
item: T;
};
export default function DataTableRow<T>({ columns, item }: Props<T>) {
return (
<tr className="border-t border-line hover:bg-surface-2 transition-colors">
{columns.map((column) => (
<DataTableCell key={column.key} column={column} item={item} />
))}
</tr>
);
}
@@ -0,0 +1,20 @@
import LoadingBlock from "@/src/components/twui/elements/LoadingBlock";
import { twMerge } from "tailwind-merge";
import { HideBelowClasses, type DataTableColumn } from "./types";
type Props = {
column: DataTableColumn<any>;
};
export default function DataTableSkeletonCell({ column }: Props) {
return (
<td
className={twMerge(
"px-4 py-3",
column.hide_below && HideBelowClasses[column.hide_below],
)}
>
<LoadingBlock className="h-3.5 w-full max-w-[120px]" />
</td>
);
}
@@ -0,0 +1,16 @@
import DataTableSkeletonCell from "./data-table-skeleton-cell";
import type { DataTableColumn } from "./types";
type Props = {
columns: DataTableColumn<any>[];
};
export default function DataTableSkeletonRow({ columns }: Props) {
return (
<tr className="border-t border-line" aria-hidden>
{columns.map((column) => (
<DataTableSkeletonCell key={column.key} column={column} />
))}
</tr>
);
}
@@ -0,0 +1,68 @@
import Stack from "@/src/components/twui/layout/Stack";
import DataTableHeadCell from "./data-table-head-cell";
import DataTableMobileList from "./data-table-mobile-list";
import DataTableRow from "./data-table-row";
import DataTableSkeletonRow from "./data-table-skeleton-row";
import type { DataTableProps } from "./types";
/**
* Desktop table + mobile card list.
*
* Raw `<table>` elements are used here on purpose: twui `Table` only renders
* plain object data and has no column/cell API. Keep all table markup inside
* this folder.
*/
export default function DataTable<T>({
items,
columns,
getKey,
loading,
skeleton_rows = 4,
empty,
MobileItem,
caption,
}: DataTableProps<T>) {
const is_loading = Boolean(loading || !items);
const skeleton_keys = Array.from({ length: skeleton_rows }, (_, i) => i);
if (!is_loading && !items?.length && empty) {
return <>{empty}</>;
}
return (
<Stack className="w-full gap-0 items-stretch">
<div className="hidden md:block w-full">
<table className="w-full border-collapse">
{caption ? <caption className="sr-only">{caption}</caption> : null}
<thead>
<tr>
{columns.map((column) => (
<DataTableHeadCell key={column.key} column={column} />
))}
</tr>
</thead>
<tbody>
{is_loading
? skeleton_keys.map((key) => (
<DataTableSkeletonRow key={key} columns={columns} />
))
: items?.map((item) => (
<DataTableRow
key={getKey({ item })}
columns={columns}
item={item}
/>
))}
</tbody>
</table>
</div>
<DataTableMobileList
items={items}
getKey={getKey}
MobileItem={MobileItem}
loading={is_loading}
skeleton_keys={skeleton_keys}
/>
</Stack>
);
}
@@ -0,0 +1,29 @@
import type { ComponentType, ReactNode } from "react";
export type DataTableColumn<T> = {
key: string;
header: ReactNode;
align?: "left" | "right";
className?: string;
/** Hide the column below this breakpoint */
hide_below?: "lg" | "xl";
render: ({ item }: { item: T }) => ReactNode;
};
export type DataTableProps<T> = {
items?: T[];
columns: DataTableColumn<T>[];
getKey: ({ item }: { item: T }) => string | number;
loading?: boolean;
skeleton_rows?: number;
/** Rendered when not loading and there are no items */
empty?: ReactNode;
/** Card rendered for each item below `md` */
MobileItem: ComponentType<{ item: T }>;
caption?: string;
};
export const HideBelowClasses: Record<NonNullable<DataTableColumn<any>["hide_below"]>, string> = {
lg: "hidden lg:table-cell",
xl: "hidden xl:table-cell",
};
@@ -1,50 +0,0 @@
import { useState } from "react";
import { Trash2 } from "lucide-react";
import Button from "@/src/components/twui/layout/Button";
import fetchApi from "@/src/components/twui/utils/fetch/fetchApi";
import type { ApiReqParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
export default function DeleteHostButton({ host_id }: { host_id: number }) {
const [deleting, setDeleting] = useState(false);
async function handleDelete() {
if (
!window.confirm(
`Delete host #${host_id}? All clients and their configurations will be removed.`,
)
) {
return;
}
setDeleting(true);
const res = await fetchApi<ApiReqParams, APIResponseObject>(
`/api/admin/delete-host`,
{
method: "DELETE",
body: { host_id },
},
);
if (!res.success) {
setDeleting(false);
window.alert(res.msg || "Could not delete the host");
return;
}
window.location.pathname = "/admin/hosts";
}
return (
<Button
title="Delete Host"
size="small"
variant="ghost"
color="error"
beforeIcon={<Trash2 size={14} />}
disabled={deleting}
onClick={handleDelete}
/>
);
}
+44
View File
@@ -0,0 +1,44 @@
import H3 from "@/src/components/twui/layout/H3";
import P from "@/src/components/twui/layout/P";
import Stack from "@/src/components/twui/layout/Stack";
import type { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
type Props = {
icon?: ReactNode;
title: string;
description?: ReactNode;
action?: ReactNode;
className?: string;
};
export default function EmptyState({
icon,
title,
description,
action,
className,
}: Props) {
return (
<Stack
center
className={twMerge("w-full gap-3 px-6 py-12 text-center", className)}
>
{icon ? (
<Stack
center
className="size-10 justify-center rounded-full bg-gray dark:bg-gray-dark text-muted"
>
{icon}
</Stack>
) : null}
<Stack center className="gap-1">
<H3>{title}</H3>
{description ? (
<P className="text-[15px] max-w-sm">{description}</P>
) : null}
</Stack>
{action}
</Stack>
);
}
+58
View File
@@ -0,0 +1,58 @@
import Button from "@/src/components/twui/layout/Button";
import Row from "@/src/components/twui/layout/Row";
import type { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
type Props = {
submit_title: string;
loading?: boolean;
disabled?: boolean;
cancel_href?: string;
/** Extra content on the left, e.g. a hint */
start?: ReactNode;
className?: string;
};
/** Always rendered — errors show next to fields, never by hiding submit. */
export default function FormActions({
submit_title,
loading,
disabled,
cancel_href,
start,
className,
}: Props) {
return (
<Row
className={twMerge(
"w-full justify-between gap-3 pt-5 border-t border-line",
className,
)}
>
<Row className="min-w-0">{start}</Row>
<Row className="gap-2 ml-auto">
{cancel_href ? (
<Button
title="Cancel"
type="button"
variant="outlined"
color="gray"
size="small"
href={cancel_href}
>
Cancel
</Button>
) : null}
<Button
title={submit_title}
type="submit"
size="small"
loading={loading}
disabled={disabled}
>
{submit_title}
</Button>
</Row>
</Row>
);
}
+35
View File
@@ -0,0 +1,35 @@
import H2 from "@/src/components/twui/layout/H2";
import P from "@/src/components/twui/layout/P";
import Stack from "@/src/components/twui/layout/Stack";
import type { PropsWithChildren, ReactNode } from "react";
import { twMerge } from "tailwind-merge";
type Props = PropsWithChildren & {
title: string;
description?: ReactNode;
className?: string;
};
/**
* A labelled group of fields: label column on the left from `lg`, stacked
* above the fields below that.
*/
export default function FormSection({ title, description, className, children }: Props) {
return (
<Stack
className={twMerge(
"w-full gap-4 lg:grid lg:grid-cols-[220px_1fr] lg:gap-8",
"py-6 first:pt-0 border-b border-line last:border-b-0",
className,
)}
>
<Stack className="gap-1">
<H2 className="text-base!">{title}</H2>
{description ? (
<P className="text-[15px]">{description}</P>
) : null}
</Stack>
<Stack className="w-full gap-4 items-stretch min-w-0">{children}</Stack>
</Stack>
);
}
@@ -2,6 +2,7 @@ import { useState } from "react";
import { Wand2 } from "lucide-react"; import { Wand2 } from "lucide-react";
import Button from "../twui/layout/Button"; import Button from "../twui/layout/Button";
import fetchApi from "../twui/utils/fetch/fetchApi"; import fetchApi from "../twui/utils/fetch/fetchApi";
import useToast from "@/src/hooks/use-toast";
import type { ApiReqParams } from "@/src/types"; import type { ApiReqParams } from "@/src/types";
type Props = { type Props = {
@@ -10,6 +11,7 @@ type Props = {
export default function GrabNextSubnetButton({ onGrab }: Props) { export default function GrabNextSubnetButton({ onGrab }: Props) {
const [grabbing, setGrabbing] = useState(false); const [grabbing, setGrabbing] = useState(false);
const { toast } = useToast();
function grabNextSubnet() { function grabNextSubnet() {
setGrabbing(true); setGrabbing(true);
@@ -21,6 +23,8 @@ export default function GrabNextSubnetButton({ onGrab }: Props) {
.then((res) => { .then((res) => {
if (res?.success && res.msg) { if (res?.success && res.msg) {
onGrab?.(res.msg); onGrab?.(res.msg);
} else {
toast({ msg: "Couldn't find a free subnet", color: "error" });
} }
}) })
.finally(() => { .finally(() => {
@@ -30,17 +34,16 @@ export default function GrabNextSubnetButton({ onGrab }: Props) {
return ( return (
<Button <Button
title="Grab Next Available Subnet" title="Use the next free subnet"
type="button"
variant="outlined" variant="outlined"
color="primary" color="gray"
beforeIcon={<Wand2 size={16} />}
loading={grabbing} loading={grabbing}
onClick={(e) => { className="py-2.5"
e.preventDefault(); onClick={grabNextSubnet}
grabNextSubnet();
}}
> >
Grab Next Subnet <Wand2 size={14} />
Next free subnet
</Button> </Button>
); );
} }
@@ -0,0 +1,80 @@
import { useCallback, useContext } from "react";
import { Plus, Users } from "lucide-react";
import Button from "@/src/components/twui/layout/Button";
import Panel from "./panel";
import ClientsTable from "./clients-table";
import EmptyState from "./empty-state";
import useClientRulesMap from "@/src/hooks/use-client-rules-map";
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
import { AppContext } from "@/src/pages/__root";
import type { BUN_SQLITE_WGUI_CLIENTS_JOIN } from "@/src/types/sql-joins";
type Props = {
host_id: number;
title?: string;
};
/** Clients of one host. Seeds from `pageProps.clients`/`client_rules`. */
export default function HostClientsPanel({ host_id, title = "Clients" }: Props) {
const { pageProps } = useContext(AppContext);
const { res: clients, setRes } = useAdminCrudGet<BUN_SQLITE_WGUI_CLIENTS_JOIN>({
table: "clients",
sql_query: {
query: {
host_id: { value: host_id },
},
order: { field: "created_at", strategy: "DESC" },
},
initial_res: pageProps?.clients || undefined,
});
const rules_by_client_id = useClientRulesMap(pageProps?.client_rules);
const onDeleted = useCallback(
({ id }: { id: number }) =>
setRes((prev) => prev?.filter((client) => client.id != id)),
[],
);
const add_href = `/admin/clients/add?host_id=${host_id}`;
return (
<Panel
flush
title={title}
description={clients ? `${clients.length} on this host` : undefined}
actions={
<Button
title="Add client"
href={add_href}
variant="outlined"
color="gray"
size="small"
>
<Plus size={14} />
Add client
</Button>
}
>
<ClientsTable
clients={clients}
rules_by_client_id={rules_by_client_id}
hide_host
onDeleted={onDeleted}
empty={
<EmptyState
icon={<Users size={18} />}
title="No clients on this host"
description="Clients get a tunnel IP from this host's subnet."
action={
<Button title="Add client" href={add_href} size="small">
Add client
</Button>
}
/>
}
/>
</Panel>
);
}
+14 -76
View File
@@ -1,22 +1,16 @@
import { useEffect, useState, type RefObject } from "react"; import { useEffect, useState, type RefObject } from "react";
import { CircleCheck, Loader2, Network, TriangleAlert } from "lucide-react"; import { Network } from "lucide-react";
import Input from "../twui/form/Input"; import Input from "../twui/form/Input";
import Row from "../twui/layout/Row"; import Row from "../twui/layout/Row";
import Span from "../twui/layout/Span";
import Tag from "../twui/elements/Tag";
import Stack from "../twui/layout/Stack"; import Stack from "../twui/layout/Stack";
import fetchApi from "../twui/utils/fetch/fetchApi"; import fetchApi from "../twui/utils/fetch/fetchApi";
import type { ApiReqParams } from "@/src/types"; import type { ApiReqParams } from "@/src/types";
import GrabNextSubnetButton from "./grab-next-subnet-button"; import GrabNextSubnetButton from "./grab-next-subnet-button";
import HostWgIpStatus, { type HostWgIpFieldStatus } from "./host-wg-ip-status";
import type useFormInit from "@/src/hooks/use-form-init"; import type useFormInit from "@/src/hooks/use-form-init";
import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db"; import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db";
export type HostWgIpFieldStatus = export type { HostWgIpFieldStatus };
| "checking"
| "available"
| "not_available"
| "invalid"
| "current";
type Props = ReturnType<typeof useFormInit<BUN_SQLITE_WGUI_HOSTS>> & { type Props = ReturnType<typeof useFormInit<BUN_SQLITE_WGUI_HOSTS>> & {
value: string; value: string;
@@ -43,6 +37,7 @@ export default function HostWgIpField({
setForm, setForm,
}: Props) { }: Props) {
const [availability, setAvailability] = useState<AvailabilityType>(); const [availability, setAvailability] = useState<AvailabilityType>();
const [field_key, setFieldKey] = useState(0);
useEffect(() => { useEffect(() => {
setAvailability(undefined); setAvailability(undefined);
@@ -85,91 +80,34 @@ export default function HostWgIpField({
}, [ip_status, onStatus]); }, [ip_status, onStatus]);
return ( return (
<Stack className="w-full gap-2"> <Stack className="w-full gap-1.5 items-stretch">
<Row className="w-full flex-nowrap"> <Row className="w-full gap-2 items-end flex-wrap sm:flex-nowrap">
<Input <Input
key={field_key}
name="wg_ip_address"
defaultValue={value} defaultValue={value}
changeHandler={(v) => { changeHandler={(v) => {
onChange?.(v); onChange?.(v);
}} }}
prefix={<Network size={17} opacity={0.5} />} prefix={<Network size={15} className="text-subtle shrink-0" />}
suffix={ title="Host tunnel address"
<Span className="text-sm opacity-50 whitespace-nowrap"> placeholder="e.g. 10.8.0.1"
WireGuard IP Address
</Span>
}
title="Wireguard IP Address"
showLabel showLabel
componentRef={inputRef} componentRef={inputRef}
autoFocus={autoFocus} autoFocus={autoFocus}
wrapperWrapperProps={{ className: "grow" }}
/> />
<GrabNextSubnetButton <GrabNextSubnetButton
onGrab={(ip) => { onGrab={(ip) => {
setForm((prev) => ({ setForm((prev) => ({
...prev, ...prev,
wg_ip_address: ip, wg_ip_address: ip,
})); }));
setFieldKey((k) => k + 1);
}} }}
/> />
</Row> </Row>
<HostWgIpStatus status={ip_status} />
{ip_status === "invalid" ? (
<Tag
variant="outlined"
color="error"
className="w-full py-1.5 outline-error/50 bg-error/5!"
>
<Row>
<TriangleAlert size={15} />
<Span>Invalid IP</Span>
</Row>
</Tag>
) : ip_status === "current" ? (
<Tag
variant="outlined"
color="gray"
className="w-full py-1.5 outline-gray/50 bg-gray/5!"
>
<Row>
<CircleCheck size={15} />
<Span>Current value</Span>
</Row>
</Tag>
) : ip_status === "not_available" ? (
<Tag
variant="outlined"
color="error"
className="w-full py-1.5 outline-error/50 bg-error/5!"
>
<Row>
<TriangleAlert size={15} />
<Span>Not available</Span>
</Row>
</Tag>
) : ip_status === "available" ? (
<Tag
variant="outlined"
color="success"
className="w-full py-1.5 outline-success/50 bg-success/5!"
>
<Row>
<CircleCheck size={15} />
<Span>Available</Span>
</Row>
</Tag>
) : (
<Tag
variant="outlined"
color="gray"
className="w-full py-1.5 outline-gray/50 bg-gray/5!"
>
<Row>
<Loader2 size={15} className="animate-spin" />
<Span>Checking availability…</Span>
</Row>
</Tag>
)}
</Stack> </Stack>
); );
} }
@@ -0,0 +1,30 @@
import StatusDot, { type StatusTone } from "./status-dot";
export type HostWgIpFieldStatus =
| "checking"
| "available"
| "not_available"
| "invalid"
| "current";
const StatusDisplay: Record<HostWgIpFieldStatus, { tone: StatusTone; label: string }> = {
checking: { tone: "neutral", label: "Checking availability…" },
available: { tone: "success", label: "Subnet available" },
not_available: { tone: "error", label: "Subnet already used by another host" },
invalid: { tone: "error", label: "Use a host address ending in .1, e.g. 10.8.0.1" },
current: { tone: "neutral", label: "Current value" },
};
type Props = {
status: HostWgIpFieldStatus;
};
export default function HostWgIpStatus({ status }: Props) {
const { tone, label } = StatusDisplay[status];
return (
<div aria-live="polite">
<StatusDot tone={tone} label={label} pulse={status == "checking"} />
</div>
);
}
-23
View File
@@ -1,23 +0,0 @@
import Img from "@/src/components/twui/layout/Img";
import type { ComponentProps } from "react";
import { twMerge } from "tailwind-merge";
type Props = {
src: string;
img_props?: Omit<ComponentProps<typeof Img>, "alt">;
};
export default function ImageBG({ src, img_props }: Props) {
return (
<Img
{...img_props}
alt="bg image"
src={src}
className={twMerge(
`absolute top-0 left-0 w-full h-full object-cover`,
"z-0",
img_props?.className,
)}
/>
);
}
+50
View File
@@ -0,0 +1,50 @@
import Row from "@/src/components/twui/layout/Row";
import Span from "@/src/components/twui/layout/Span";
import { CircleCheck, Info, TriangleAlert, type LucideIcon } from "lucide-react";
import type { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
type Tone = "error" | "success" | "warning" | "info";
type Props = {
tone: Tone;
msg: ReactNode;
className?: string;
};
const ToneStyles: Record<Tone, { className: string; Icon: LucideIcon }> = {
error: {
className: "border-error/30 bg-error/5 text-error dark:text-error-dark",
Icon: TriangleAlert,
},
success: {
className: "border-success/30 bg-success/5 text-success dark:text-success-dark",
Icon: CircleCheck,
},
warning: {
className: "border-warning/30 bg-warning/5 text-warning dark:text-warning-dark",
Icon: TriangleAlert,
},
info: {
className: "border-line bg-surface-2 text-muted",
Icon: Info,
},
};
export default function InlineAlert({ tone, msg, className }: Props) {
const { className: toneClassName, Icon } = ToneStyles[tone];
return (
<Row
role={tone == "error" ? "alert" : "status"}
className={twMerge(
"w-full gap-2 flex-nowrap items-start px-3 py-2 rounded-default border",
toneClassName,
className,
)}
>
<Icon size={15} className="shrink-0 mt-0.5" />
<Span className="text-[15px] leading-snug">{msg}</Span>
</Row>
);
}
@@ -1,39 +0,0 @@
import Section from "@/src/components/twui/layout/Section";
import Container from "@/src/components/twui/layout/Container";
import Stack from "@/src/components/twui/layout/Stack";
import H1 from "@/src/components/twui/layout/H1";
import Span from "@/src/components/twui/layout/Span";
import BlurredImageBG from "@/src/components/general/blured-image-bg";
import { twMerge } from "tailwind-merge";
type Props = {
title: string;
sub_title?: string;
description?: string;
bg_image?: string;
};
export default function InnerPageHeroSection({
title,
bg_image,
description,
sub_title,
}: Props) {
return (
<Section className={twMerge("")}>
<Container className="relative z-10">
<Stack className="w-full items-center max-w-3xl mx-auto gap-7">
{sub_title ? (
<Span className="htag">{sub_title}</Span>
) : null}
<H1 className="text-center font-bold">{title}</H1>
{description ? (
<Span className="text-xl sm:text-2xl leading-snug text-center">
{description}
</Span>
) : null}
</Stack>
</Container>
</Section>
);
}
+18
View File
@@ -0,0 +1,18 @@
import Stack from "@/src/components/twui/layout/Stack";
import { twMerge } from "tailwind-merge";
import KeyValueRow, { type KeyValueItem } from "./key-value-row";
type Props = {
items: KeyValueItem[];
className?: string;
};
export default function KeyValueList({ items, className }: Props) {
return (
<Stack className={twMerge("w-full gap-0", className)}>
{items.map((item) => (
<KeyValueRow {...item} key={item.key} />
))}
</Stack>
);
}
+35
View File
@@ -0,0 +1,35 @@
import Stack from "@/src/components/twui/layout/Stack";
import Span from "@/src/components/twui/layout/Span";
import type { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import CopyableValue from "./copyable-value";
import MutedText from "./muted-text";
export type KeyValueItem = {
key: string;
label: string;
value: ReactNode;
mono?: boolean;
/** Show a copy button for this raw string */
copy_value?: string;
};
export default function KeyValueRow({ label, value, mono, copy_value }: KeyValueItem) {
return (
<Stack className="sm:grid sm:grid-cols-[160px_1fr] gap-1 sm:gap-4 w-full py-2.5 border-b border-line last:border-b-0">
<MutedText>{label}</MutedText>
{copy_value ? (
<CopyableValue value={copy_value} label={label} display={value} />
) : (
<Span
className={twMerge(
"text-[15px] text-fg break-words min-w-0",
mono && "font-mono text-sm tabular break-all",
)}
>
{value ?? "—"}
</Span>
)}
</Stack>
);
}
-59
View File
@@ -1,59 +0,0 @@
import { twMerge } from "tailwind-merge";
import Tag from "../twui/elements/Tag";
import Row from "../twui/layout/Row";
import { TriangleAlert, CircleCheck } from "lucide-react";
import type { PropsWithChildren } from "react";
import type { TWUIStatusType } from "../twui/hooks/useStatus";
import Paper from "../twui/elements/Paper";
import Center from "../twui/layout/Center";
import Stack from "../twui/layout/Stack";
import Border from "../twui/elements/Border";
import Logo from "./logo";
type Props = PropsWithChildren & {
title?: string;
className?: string;
status?: TWUIStatusType;
};
export default function LoginShell({ children, className, status }: Props) {
return (
<Stack className="w-screen h-screen justify-center! items-center! overflow-x-hidden overflow-y-auto">
<Logo />
<Border
className={twMerge(
"w-full lg:w-[600px] flex-col items-center",
"px-4 sm:px-6 py-8 shadow-md relative",
className,
)}
>
{status?.error && status.msg ? (
<Tag
variant="outlined"
color="error"
className="w-full py-1.5 outline-error/50 bg-error/5!"
>
<Row>
<TriangleAlert size={15} />
<span>{status.msg}</span>
</Row>
</Tag>
) : null}
{status?.success && status.msg ? (
<Tag
variant="outlined"
color="success"
className="w-full py-1.5 outline-success/50 bg-success/5!"
>
<Row>
<CircleCheck size={15} />
<span>{status.msg}</span>
</Row>
</Tag>
) : null}
{children}
</Border>
</Stack>
);
}
+22 -10
View File
@@ -1,21 +1,33 @@
import type { ComponentProps } from "react"; import type { ComponentProps } from "react";
import { twMerge } from "tailwind-merge";
import Img from "../twui/layout/Img"; import Img from "../twui/layout/Img";
import Span from "../twui/layout/Span";
type Props = { type Props = {
icon_size?: number; icon_size?: number;
img_props?: Omit<ComponentProps<typeof Img>, "alt">; img_props?: Omit<ComponentProps<typeof Img>, "alt">;
white?: boolean; className?: string;
}; };
export default function LogoIcon({ icon_size = 45, img_props, white }: Props) { /** Swaps to the white mark in dark mode. */
export default function LogoIcon({ icon_size = 32, img_props, className }: Props) {
return ( return (
<Img <Span className={twMerge("inline-flex shrink-0", className)}>
size={icon_size} <Img
{...img_props} size={icon_size}
alt="Logo Icon" {...img_props}
src={ alt="wg-ui"
white ? "/logos/wgui-logo-white.webp" : "/logos/wgui-logo.webp" src="/logos/wgui-logo-red.png"
} className={twMerge("dark:hidden", img_props?.className)}
/> />
<Img
size={icon_size}
{...img_props}
alt=""
aria-hidden
src="/logos/wgui-logo-white.webp"
className={twMerge("hidden dark:block", img_props?.className)}
/>
</Span>
); );
} }
+11 -44
View File
@@ -1,57 +1,24 @@
import Link from "../twui/layout/Link";
import Row from "../twui/layout/Row"; import Row from "../twui/layout/Row";
import Span from "../twui/layout/Span"; import Span from "../twui/layout/Span";
import type { ComponentProps, PropsWithChildren } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import Stack from "../twui/layout/Stack";
import LogoIcon from "./logo-icon"; import LogoIcon from "./logo-icon";
import { SiteData } from "@/src/data/site-data";
type Props = { type Props = {
icon_size?: number; icon_size?: number;
text_wrapper_props?: ComponentProps<typeof Stack>; href?: string;
text_props?: ComponentProps<typeof Span>; className?: string;
white?: boolean;
}; };
export default function Logo({ export default function Logo({ icon_size = 28, href = "/admin", className }: Props) {
icon_size = 45,
text_props,
text_wrapper_props,
white,
}: Props) {
return ( return (
<a href="/" className="logo-link"> <Link href={href} className={twMerge("no-underline! text-fg!", className)}>
<Row className="gap-2! flex-nowrap"> <Row className="gap-2 flex-nowrap">
<LogoIcon icon_size={icon_size} white={white} /> <LogoIcon icon_size={icon_size} />
<Stack <Span className="text-lg font-semibold tracking-tight whitespace-nowrap">
{...text_wrapper_props} wg-ui
className={twMerge("gap-1!", text_wrapper_props?.className)} </Span>
>
<Stack className="gap-0!">
<HeroText text_props={text_props}>wg-ui</HeroText>
</Stack>
</Stack>
</Row> </Row>
</a> </Link>
);
}
function HeroText({
text_props,
children,
}: PropsWithChildren & {
text_props?: ComponentProps<typeof Span>;
}) {
return (
<Span
{...text_props}
className={twMerge(
"text-xl text-dark font-serif whitespace-nowrap",
"heading leading-[24px]",
text_props?.className,
)}
>
{children}
</Span>
); );
} }
@@ -1,66 +0,0 @@
import React, { type ComponentProps, type ReactNode } from "react";
import Section from "@/src/components/twui/layout/Section";
import Container from "@/src/components/twui/layout/Container";
import Stack from "@/src/components/twui/layout/Stack";
import H1 from "@/src/components/twui/layout/H1";
import Row from "@/src/components/twui/layout/Row";
import { twMerge } from "tailwind-merge";
import Span from "@/src/components/twui/layout/Span";
import BlurredImageBG from "@/src/components/general/blured-image-bg";
import ImageBG from "@/src/components/general/image-bg";
import type Img from "../twui/layout/Img";
type Props = {
title: string;
image_url?: string;
sub_title?: string;
cta_buttons?: ReactNode;
img_props?: Omit<ComponentProps<typeof Img>, "alt">;
img_component?: ReactNode;
};
export default function MainHeroSection({
title,
cta_buttons,
sub_title,
image_url,
img_props,
img_component,
}: Props) {
const img_comp = img_component ? (
img_component
) : image_url ? (
<ImageBG src={image_url} img_props={img_props} />
) : null;
return (
<React.Fragment>
<Section className={twMerge("relative")}>
<Container>
<Row className="grid xl:grid-cols-2 relative z-10 max-w-xl xl:max-w-none lg:pb-20 pt-60 lg:pt-20">
<Stack className="w-full gap-8">
<H1
className={twMerge(
"text-center md:text-left w-full",
)}
>
{title}
</H1>
{sub_title ? (
<Span
className={twMerge(
"text-lg sm:text-2xl leading-snug text-center md:text-left",
)}
>
{sub_title}
</Span>
) : null}
{cta_buttons}
</Stack>
{/* <Stack className="relative w-full xl:w-[700px] xl:h-full overflow-visible"></Stack> */}
</Row>
</Container>
</Section>
</React.Fragment>
);
}
+21
View File
@@ -0,0 +1,21 @@
import Span from "@/src/components/twui/layout/Span";
import type { ComponentProps } from "react";
import { twMerge } from "tailwind-merge";
type Props = ComponentProps<typeof Span> & {
truncate_text?: boolean;
};
/** IPs, keys, paths, ports. */
export default function MonoText({ truncate_text, className, ...props }: Props) {
return (
<Span
{...props}
className={twMerge(
"font-mono text-sm tabular",
truncate_text && "truncate block max-w-full",
className,
)}
/>
);
}
+40
View File
@@ -0,0 +1,40 @@
import Span from "@/src/components/twui/layout/Span";
import type { ComponentProps } from "react";
import { twMerge } from "tailwind-merge";
type Props = Omit<ComponentProps<typeof Span>, "size"> & {
text_size?: "xs" | "sm";
tone?: "muted" | "subtle" | "error" | "success" | "warning";
mono?: boolean;
};
const ToneClasses: Record<NonNullable<Props["tone"]>, string> = {
muted: "text-muted",
subtle: "text-subtle",
error: "text-error dark:text-error-dark",
success: "text-success dark:text-success-dark",
warning: "text-warning dark:text-warning-dark",
};
/**
* Secondary text. Use instead of hand-written `text-sm opacity…` strings.
*/
export default function MutedText({
text_size = "sm",
tone = "muted",
mono,
className,
...props
}: Props) {
return (
<Span
{...props}
className={twMerge(
text_size == "xs" ? "text-[13px]" : "text-[15px]",
ToneClasses[tone],
mono && "font-mono tabular",
className,
)}
/>
);
}
+20
View File
@@ -0,0 +1,20 @@
import Stack from "@/src/components/twui/layout/Stack";
import type { PropsWithChildren } from "react";
import { twMerge } from "tailwind-merge";
type Props = PropsWithChildren & {
className?: string;
};
export default function PageBody({ children, className }: Props) {
return (
<Stack
className={twMerge(
"w-full items-stretch gap-5 px-4 sm:px-6 pb-10",
className,
)}
>
{children}
</Stack>
);
}
@@ -0,0 +1,67 @@
import H1 from "@/src/components/twui/layout/H1";
import Link from "@/src/components/twui/layout/Link";
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 { ArrowLeft } from "lucide-react";
import type { ReactNode } from "react";
import type { ActionMenuItem } from "../action-menu/types";
import PageHeaderActions from "./page-header-actions";
type Props = {
title: ReactNode;
description?: ReactNode;
/** Inline status line under the title (status dots, mono values) */
meta?: ReactNode;
primary_action?: ActionMenuItem;
actions?: ActionMenuItem[];
back?: { href: string; title: string };
/** Rendered under the title row, e.g. a `SubNav` */
nav?: ReactNode;
};
export default function PageHeader({
title,
description,
meta,
primary_action,
actions,
back,
nav,
}: Props) {
return (
<Stack className="w-full gap-3 px-4 sm:px-6 pt-6 pb-5">
{back ? (
<Link
href={back.href}
className="text-[15px] text-muted! hover:text-fg! no-underline!"
>
<Row className="gap-1">
<ArrowLeft size={14} />
{back.title}
</Row>
</Link>
) : null}
<Row className="w-full justify-between items-start gap-4 flex-nowrap">
<Stack className="gap-1.5 min-w-0">
<H1 className="break-words">{title}</H1>
{description ? (
typeof description == "string" ? (
<P className="text-[15px] max-w-2xl">
{description}
</P>
) : (
description
)
) : null}
{meta ? <Row className="gap-x-4 gap-y-1">{meta}</Row> : null}
</Stack>
<PageHeaderActions
primary_action={primary_action}
actions={actions}
/>
</Row>
{nav ? <div className="w-full pt-2">{nav}</div> : null}
</Stack>
);
}
@@ -0,0 +1,34 @@
import Button from "@/src/components/twui/layout/Button";
import type { ActionMenuItem } from "../action-menu/types";
type Props = ActionMenuItem & {
primary?: boolean;
};
export default function PageHeaderAction({
title,
href,
onClick,
icon,
tone,
disabled,
loading,
primary,
}: Props) {
return (
<Button
title={title}
type="button"
size="small"
href={href}
onClick={onClick}
disabled={disabled}
loading={loading}
variant={primary ? "normal" : "outlined"}
color={tone == "danger" ? "error" : primary ? "primary" : "gray"}
>
{icon}
{title}
</Button>
);
}
@@ -0,0 +1,37 @@
import Row from "@/src/components/twui/layout/Row";
import ActionMenu from "../action-menu";
import type { ActionMenuItem } from "../action-menu/types";
import PageHeaderAction from "./page-header-action";
type Props = {
primary_action?: ActionMenuItem;
actions?: ActionMenuItem[];
};
/**
* From `sm` up every action is a button. Below `sm` only the primary action
* stays visible and the rest collapse into an overflow menu.
*/
export default function PageHeaderActions({ primary_action, actions }: Props) {
const secondary = actions || [];
return (
<Row className="gap-2 flex-nowrap shrink-0">
<Row className="hidden sm:flex gap-2">
{secondary.map((action) => (
<PageHeaderAction {...action} key={action.key} />
))}
</Row>
{secondary.length ? (
<ActionMenu
label="More actions"
items={secondary}
className="sm:hidden"
/>
) : null}
{primary_action ? (
<PageHeaderAction {...primary_action} primary />
) : null}
</Row>
);
}
+64
View File
@@ -0,0 +1,64 @@
import Card from "@/src/components/twui/elements/Card";
import Stack from "@/src/components/twui/layout/Stack";
import type { PropsWithChildren, ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import PanelHeader from "./panel-header";
type Props = PropsWithChildren & {
title?: ReactNode;
description?: ReactNode;
actions?: ReactNode;
/** Remove body padding (tables, lists) */
flush?: boolean;
footer?: ReactNode;
className?: string;
body_class_name?: string;
};
/**
* Bordered surface with an optional header. The app's single card style.
*/
export default function Panel({
title,
description,
actions,
flush,
footer,
className,
body_class_name,
children,
}: Props) {
const has_header = Boolean(title || description || actions);
return (
<Card
noHover
className={twMerge(
"flex-col items-stretch p-0 w-full overflow-visible",
className,
)}
>
{has_header ? (
<PanelHeader
title={title}
description={description}
actions={actions}
/>
) : null}
<Stack
className={twMerge(
"w-full items-stretch gap-4",
flush ? "p-0 gap-0" : "p-4",
body_class_name,
)}
>
{children}
</Stack>
{footer ? (
<Stack className="w-full items-stretch px-4 py-3 border-t border-line">
{footer}
</Stack>
) : null}
</Card>
);
}
@@ -0,0 +1,23 @@
import H2 from "@/src/components/twui/layout/H2";
import Row from "@/src/components/twui/layout/Row";
import Stack from "@/src/components/twui/layout/Stack";
import type { ReactNode } from "react";
import MutedText from "../muted-text";
type Props = {
title?: ReactNode;
description?: ReactNode;
actions?: ReactNode;
};
export default function PanelHeader({ title, description, actions }: Props) {
return (
<Row className="w-full justify-between gap-3 px-4 py-3 border-b border-line">
<Stack className="gap-0.5 min-w-0">
{title ? <H2 className="text-base!">{title}</H2> : null}
{description ? <MutedText>{description}</MutedText> : null}
</Stack>
{actions ? <Row className="gap-2">{actions}</Row> : null}
</Row>
);
}
-42
View File
@@ -1,42 +0,0 @@
import Img from "@/src/components/twui/layout/Img";
import { twMerge } from "tailwind-merge";
import Stack from "../twui/layout/Stack";
type Props = {
src: string;
width?: number;
height?: number;
alt?: string;
};
export default function RectImage({
src,
height = 300,
width = 200,
alt,
}: Props) {
const img_component = (
<Img
alt={alt || "image"}
src={src}
className={twMerge(`w-full h-full object-cover`)}
/>
);
return (
<>
<Stack
className={twMerge(`overflow-hidden hidden xl:flex`)}
style={{
width: `${width}px`,
height: `${height}px`,
}}
>
{img_component}
</Stack>
<Stack className={twMerge(`overflow-hidden flex xl:hidden w-full`)}>
{img_component}
</Stack>
</>
);
}
+20
View File
@@ -0,0 +1,20 @@
import Span from "@/src/components/twui/layout/Span";
import type { PropsWithChildren } from "react";
import { twMerge } from "tailwind-merge";
type Props = PropsWithChildren & {
className?: string;
};
export default function SectionLabel({ children, className }: Props) {
return (
<Span
className={twMerge(
"text-xs font-semibold uppercase tracking-[0.08em] text-subtle",
className,
)}
>
{children}
</Span>
);
}
@@ -1,93 +0,0 @@
import { useState, type ComponentProps } from "react";
import Button from "../twui/layout/Button";
import useStatus from "../twui/hooks/useStatus";
import fetchApi from "../twui/utils/fetch/fetchApi";
import type { ApiReqParams } from "@/src/types";
import Row from "../twui/layout/Row";
import Stack from "../twui/layout/Stack";
import { CircleCheck, TriangleAlert } from "lucide-react";
import Span from "../twui/layout/Span";
import Tag from "../twui/elements/Tag";
import { AppData } from "@/src/data/app-data";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import HostWgIpField, { type HostWgIpFieldStatus } from "./host-wg-ip-field";
import GrabNextSubnetButton from "./grab-next-subnet-button";
type Props = {
button_props?: Omit<ComponentProps<typeof Button>, "title">;
};
export default function SetupMainHostButton({ button_props }: Props) {
const { loading, setLoading, status, setStatus } = useStatus();
const [wgIP, setWgIP] = useState<string>(AppData["DefaultPrivateIP"]);
const [ipStatus, setIpStatus] = useState<HostWgIpFieldStatus>("checking");
return (
<Stack className="w-full items-stretch gap-3">
{status?.error && status.msg ? (
<Tag
variant="outlined"
color="error"
className="w-full py-1.5 outline-error/50 bg-error/5!"
>
<Row>
<TriangleAlert size={15} />
<Span>{status.msg}</Span>
</Row>
</Tag>
) : null}
{status?.success && status.msg ? (
<Tag
variant="outlined"
color="success"
className="w-full py-1.5 outline-success/50 bg-success/5!"
>
<Row>
<CircleCheck size={15} />
<Span>{status.msg}</Span>
</Row>
</Tag>
) : null}
<Row className="w-full items-center gap-2">
<GrabNextSubnetButton onGrab={setWgIP} />
<Button
title="Setup Main Host"
{...button_props}
disabled={ipStatus !== "available"}
loading={loading}
onClick={() => {
setLoading(true);
setStatus(undefined);
fetchApi<ApiReqParams, APIResponseObject>(
`/api/admin/setup-main-host`,
{
method: "POST",
body: { ip_address: wgIP },
},
)
.then((res) => {
if (res.success) {
window.location.reload();
}
setStatus({
success: res.success,
error: !res.success,
msg: res.msg,
});
})
.finally(() => {
setLoading(false);
});
}}
>
Setup Main Host
</Button>
</Row>
</Stack>
);
}
+56
View File
@@ -0,0 +1,56 @@
import Card from "@/src/components/twui/elements/Card";
import LoadingBlock from "@/src/components/twui/elements/LoadingBlock";
import Span from "@/src/components/twui/layout/Span";
import Stack from "@/src/components/twui/layout/Stack";
import type { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import MutedText from "./muted-text";
import SectionLabel from "./section-label";
type Props = {
label: string;
value?: ReactNode;
hint?: ReactNode;
loading?: boolean;
mono?: boolean;
href?: string;
className?: string;
};
export default function StatTile({
label,
value,
hint,
loading,
mono,
href,
className,
}: Props) {
const is_loading = loading || value === undefined;
return (
<Card
href={href}
noHover={!href}
linkProps={{ className: "no-underline! block" }}
className={twMerge("flex-col items-start gap-2 p-4 h-full", className)}
>
<SectionLabel>{label}</SectionLabel>
<Stack className="gap-1 min-w-0 w-full">
{is_loading ? (
<LoadingBlock className="h-7 w-20" />
) : (
<Span
className={twMerge(
"text-3xl font-semibold tracking-tight text-fg tabular truncate max-w-full",
mono && "font-mono text-xl",
)}
>
{value}
</Span>
)}
{hint ? <MutedText text_size="xs">{hint}</MutedText> : null}
</Stack>
</Card>
);
}
+52
View File
@@ -0,0 +1,52 @@
import Row from "@/src/components/twui/layout/Row";
import Span from "@/src/components/twui/layout/Span";
import { twMerge } from "tailwind-merge";
export type StatusTone = "success" | "warning" | "error" | "neutral";
type Props = {
tone: StatusTone;
label?: string;
/** Keep the label for screen readers only */
hide_label?: boolean;
pulse?: boolean;
className?: string;
};
const DotClasses: Record<StatusTone, string> = {
success: "bg-success dark:bg-success-dark",
warning: "bg-warning dark:bg-warning-dark",
error: "bg-error dark:bg-error-dark",
neutral: "bg-subtle",
};
export default function StatusDot({
tone,
label,
hide_label,
pulse,
className,
}: Props) {
return (
<Row className={twMerge("gap-1.5 flex-nowrap", className)}>
<Span
aria-hidden
className={twMerge(
"inline-block size-2 shrink-0 rounded-full",
DotClasses[tone],
pulse && "animate-pulse",
)}
/>
{label ? (
<Span
className={twMerge(
"text-[15px] text-muted whitespace-nowrap",
hide_label && "sr-only",
)}
>
{label}
</Span>
) : null}
</Row>
);
}
+32
View File
@@ -0,0 +1,32 @@
import { useContext } from "react";
import Row from "@/src/components/twui/layout/Row";
import { AppContext } from "@/src/pages/__root";
import SubNavItem, { type SubNavItemType } from "./sub-nav-item";
type Props = {
label: string;
items: SubNavItemType[];
};
/** Link tabs between sibling routes (unlike twui `Tabs`, each is a URL). */
export default function SubNav({ label, items }: Props) {
const { pageProps } = useContext(AppContext);
const pathname = pageProps?.url?.pathname;
return (
<nav aria-label={label} className="w-full border-b border-line">
<Row className="gap-0 flex-nowrap overflow-x-auto overflow-y-hidden">
{items.map((item) => (
<SubNavItem
{...item}
key={item.href}
active={
pathname == item.href ||
Boolean(pathname?.startsWith(`${item.href}/`))
}
/>
))}
</Row>
</nav>
);
}
@@ -0,0 +1,33 @@
import Link from "@/src/components/twui/layout/Link";
import Row from "@/src/components/twui/layout/Row";
import type { LucideIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
export type SubNavItemType = {
title: string;
href: string;
Icon?: LucideIcon;
};
type Props = SubNavItemType & {
active: boolean;
};
export default function SubNavItem({ title, href, Icon, active }: Props) {
return (
<Link
href={href}
aria-current={active ? "page" : undefined}
className={twMerge(
"px-3 py-2.5 -mb-px border-b-2 border-transparent no-underline! whitespace-nowrap",
"text-base font-medium text-muted! hover:text-fg! transition-colors",
active && "text-fg! border-primary dark:border-primary-dark",
)}
>
<Row className="gap-2 flex-nowrap">
{Icon ? <Icon size={16} className={active ? "" : "opacity-70"} /> : null}
{title}
</Row>
</Link>
);
}
@@ -1,53 +0,0 @@
import Section from "@/src/components/twui/layout/Section";
import Container from "@/src/components/twui/layout/Container";
import H2 from "@/src/components/twui/layout/H2";
import Stack from "@/src/components/twui/layout/Stack";
import type { TWUILucideIconName } from "../twui/elements/lucide-icon";
import LucideIcon from "../twui/elements/lucide-icon";
import Span from "../twui/layout/Span";
import type { ReactNode } from "react";
type Props = {
tagline: string;
icon_name?: TWUILucideIconName;
sub_title?: string;
post_component?: ReactNode;
};
export default function TaglineSection({
tagline,
icon_name,
sub_title,
post_component,
}: Props) {
/**
* Render component
*/
return (
<Section className="bg-secondary relative">
<Container className="xl:py-10">
<Stack className="w-full items-center gap-6">
{icon_name && (
<LucideIcon
name={icon_name}
size={50}
className="text-accent"
/>
)}
<H2 className="text-center max-w-4xl xl:text-6xl! text-white">
{tagline}
</H2>
{sub_title ? (
<Span
className="text-white/80 max-w-4xl text-center"
size="larger"
>
{sub_title}
</Span>
) : null}
{post_component}
</Stack>
</Container>
</Section>
);
}
@@ -0,0 +1,11 @@
import { ThemeCookieName } from "@/src/dict/theme-dict";
/**
* Runs before first paint so the page never flashes the wrong theme. Reads
* the cookie in the browser; no server value is interpolated into the script.
*/
const script = `(function(){try{var m=(document.cookie.match(/(?:^|; )${ThemeCookieName}=(light|dark|system)/)||[])[1]||"system";var d=m==="dark"||(m==="system"&&window.matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.classList.toggle("dark",d);}catch(e){}})();`;
export default function ThemeScript() {
return <script dangerouslySetInnerHTML={{ __html: script }} />;
}
@@ -0,0 +1,38 @@
import Button from "@/src/components/twui/layout/Button";
import type { ThemeMode } from "@/src/dict/theme-dict";
import type { LucideIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
type Props = {
mode: ThemeMode;
label: string;
Icon: LucideIcon;
active: boolean;
onSelect: ({ mode }: { mode: ThemeMode }) => void;
};
export default function ThemeToggleOption({
mode,
label,
Icon,
active,
onSelect,
}: Props) {
return (
<Button
title={`${label} theme`}
variant="ghost"
color="gray"
type="button"
role="radio"
aria-checked={active}
className={twMerge(
"p-1.5! rounded-default-sm text-muted!",
active && "bg-surface! text-fg! shadow-sm",
)}
onClick={() => onSelect({ mode })}
>
<Icon size={14} />
</Button>
);
}
@@ -0,0 +1,40 @@
import Row from "@/src/components/twui/layout/Row";
import useTheme from "@/src/hooks/use-theme";
import type { ThemeMode } from "@/src/dict/theme-dict";
import { Monitor, Moon, Sun, type LucideIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import ThemeToggleOption from "./theme-toggle-option";
const Options: { mode: ThemeMode; label: string; Icon: LucideIcon }[] = [
{ mode: "light", label: "Light", Icon: Sun },
{ mode: "system", label: "System", Icon: Monitor },
{ mode: "dark", label: "Dark", Icon: Moon },
];
type Props = {
className?: string;
};
export default function ThemeToggle({ className }: Props) {
const { mode, setMode } = useTheme();
return (
<Row
role="radiogroup"
aria-label="Color theme"
className={twMerge(
"gap-0.5 p-0.5 flex-nowrap rounded-default bg-gray dark:bg-gray-dark",
className,
)}
>
{Options.map((option) => (
<ThemeToggleOption
key={option.mode}
{...option}
active={mode == option.mode}
onSelect={setMode}
/>
))}
</Row>
);
}
@@ -0,0 +1,16 @@
import { createContext } from "react";
export type ToastColor = "normal" | "success" | "error";
export type ToastParams = {
msg: string;
color?: ToastColor;
};
export type ToastContextValue = {
toast: (params: ToastParams) => void;
};
export const ToastContext = createContext<ToastContextValue>({
toast: () => {},
});
@@ -0,0 +1,50 @@
import Toast from "@/src/components/twui/elements/Toast";
import React, { useCallback, useRef, useState, type PropsWithChildren } from "react";
import { ToastContext, type ToastParams } from "./toast-context";
type ToastState = ToastParams & {
id: number;
open: boolean;
};
/**
* One twui Toast slot; the newest message replaces the current one. Each toast
* gets its own id so a stale close (old timer, old unmount) can't hide a newer
* toast.
*/
export default function ToastProvider({ children }: PropsWithChildren) {
const [state, setState] = useState<ToastState | null>(null);
const counter = useRef(0);
const toast = useCallback(({ msg, color = "normal" }: ToastParams) => {
counter.current += 1;
setState({ id: counter.current, msg, color, open: true });
}, []);
const closeFor = (id: number) =>
((value: React.SetStateAction<boolean>) => {
const next = typeof value == "function" ? value(true) : value;
if (next) return;
setState((prev) =>
prev && prev.id == id ? { ...prev, open: false } : prev,
);
}) as React.Dispatch<React.SetStateAction<boolean>>;
return (
<ToastContext.Provider value={{ toast }}>
{children}
{state ? (
<Toast
key={state.id}
open={state.open}
setOpen={closeFor(state.id)}
color={state.color}
role="status"
aria-live="polite"
>
{state.msg}
</Toast>
) : null}
</ToastContext.Provider>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { useState } from "react";
import fetchApi from "@/src/components/twui/utils/fetch/fetchApi";
import useConfirm from "@/src/hooks/use-confirm";
import useToast from "@/src/hooks/use-toast";
import deriveHostDisplayName from "@/src/utils/derive-host-display-name";
import type { ApiReqParams } from "@/src/types";
import type { APIResponseObject } from "@moduletrace/bunext/types";
import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db";
type Params = {
onDeleted?: ({ id }: { id: number }) => void;
};
export default function useDeleteHost({ onDeleted }: Params = {}) {
const { confirm } = useConfirm();
const { toast } = useToast();
const [deleting, setDeleting] = useState(false);
async function deleteHost({ host }: { host: BUN_SQLITE_WGUI_HOSTS }) {
const name = deriveHostDisplayName({ host });
const host_id = Number(host.id || 0);
const confirmed = await confirm({
title: `Delete ${name}?`,
message:
"The tunnel is torn down and every client on this host is deleted with its config. This can't be undone.",
confirm_label: "Delete host",
tone: "danger",
});
if (!confirmed) return false;
setDeleting(true);
const res = await fetchApi<ApiReqParams, APIResponseObject>(`/api/admin/delete-host`, {
method: "DELETE",
body: { host_id },
});
setDeleting(false);
if (!res.success) {
toast({ msg: res.msg || "Couldn't delete the host", color: "error" });
return false;
}
toast({ msg: `${name} deleted`, color: "success" });
onDeleted?.({ id: host_id });
return true;
}
return { deleteHost, deleting };
}
+40 -34
View File
@@ -1,17 +1,18 @@
import { useState } from "react";
import Form from "@/src/components/twui/form/Form"; import Form from "@/src/components/twui/form/Form";
import LoadingOverlay from "@/src/components/twui/elements/LoadingOverlay";
import useFormInit from "@/src/hooks/use-form-init"; import useFormInit from "@/src/hooks/use-form-init";
import useToast from "@/src/hooks/use-toast";
import type { UserFormObject } from "@/src/types";
import type { BUN_SQLITE_WGUI_USERS_JOIN } from "@/src/types/sql-joins";
import InlineAlert from "../inline-alert";
import UserFormName from "./user-form-name"; import UserFormName from "./user-form-name";
import Tag from "@/src/components/twui/elements/Tag";
import UserFormAction from "./user-form-action"; import UserFormAction from "./user-form-action";
import UserFormEmail from "./user-form-email"; import UserFormEmail from "./user-form-email";
import UserFormUsername from "./user-form-username"; import UserFormUsername from "./user-form-username";
import type { UserFormObject } from "@/src/types";
import submitUserForm from "./submit-user-form";
import UserFormPassword from "./user-form-password"; import UserFormPassword from "./user-form-password";
import UserFormImage from "./user-form-image"; import UserFormImage from "./user-form-image";
import UserFormBio from "./user-form-bio"; import UserFormBio from "./user-form-bio";
import type { BUN_SQLITE_WGUI_USERS_JOIN } from "@/src/types/sql-joins"; import submitUserForm from "./submit-user-form";
type Props = { type Props = {
existing_user?: BUN_SQLITE_WGUI_USERS_JOIN; existing_user?: BUN_SQLITE_WGUI_USERS_JOIN;
@@ -19,44 +20,49 @@ type Props = {
is_first_user?: boolean; is_first_user?: boolean;
}; };
export default function UserForm({ export default function UserForm({ existing_user, existing_user_full, is_first_user }: Props) {
existing_user, const { toast } = useToast();
existing_user_full,
is_first_user,
}: Props) {
const init = useFormInit<UserFormObject>({ const init = useFormInit<UserFormObject>({
existing: existing_user, existing: existing_user,
existing_full: existing_user_full, existing_full: existing_user_full,
is_first_user, is_first_user,
}); });
const { loading, status } = init; const [confirm_password, setConfirmPassword] = useState("");
const { status, form } = init;
const needs_password = !existing_user_full?.id;
const passwords_match = !needs_password || (form.password || "") === confirm_password;
return ( return (
<> <Form
<Form className="w-full gap-5"
className="w-full gap-6" onSubmit={() => {
onSubmit={() => { if (!passwords_match) return;
submitUserForm(init); submitUserForm({ init, toast });
}} }}
> >
{status?.error && <Tag color="error">{status.msg}</Tag>} {status?.error ? <InlineAlert tone="error" msg={status.msg} /> : null}
{loading && <LoadingOverlay />}
<UserFormName {...init} /> <UserFormName {...init} />
<UserFormEmail {...init} /> <UserFormEmail {...init} />
<UserFormUsername {...init} /> <UserFormUsername {...init} />
<UserFormPassword {...init} /> <UserFormPassword
{/* <UserFormUserTypes {...init} /> */} {...init}
confirm_password={confirm_password}
setConfirmPassword={setConfirmPassword}
passwords_match={passwords_match}
/>
{is_first_user ? null : ( {is_first_user ? null : (
<> <>
<UserFormBio {...init} /> <UserFormBio {...init} />
<UserFormImage {...init} /> <UserFormImage {...init} />
</> </>
)} )}
<UserFormAction {...init} />
</Form> <UserFormAction {...init} passwords_match={passwords_match} />
</> </Form>
); );
} }
@@ -11,24 +11,17 @@ import _ from "lodash";
import fetchApi from "../../twui/utils/fetch/fetchApi"; import fetchApi from "../../twui/utils/fetch/fetchApi";
import type { APIResponseObject } from "@moduletrace/bunext/types"; import type { APIResponseObject } from "@moduletrace/bunext/types";
export default async function submitUserForm({ import type { ToastContextValue } from "../toast/toast-context";
setLoading,
setStatus, type Params = {
form, init: ReturnType<typeof useFormInit<UserFormObject>>;
existing_full, toast: ToastContextValue["toast"];
existing, };
image,
is_first_user, export default async function submitUserForm({ init, toast }: Params) {
}: ReturnType<typeof useFormInit<UserFormObject>>) { const { setLoading, setStatus, form, existing_full, image, is_first_user } = init;
try { try {
const confirm_msg = existing_full?.id
? `Update this user?`
: `Create new user?`;
if (!window.confirm(confirm_msg)) {
return;
}
const new_user_data: BUN_SQLITE_WGUI_USERS = _.pick< const new_user_data: BUN_SQLITE_WGUI_USERS = _.pick<
UserFormObject, UserFormObject,
keyof UserFormObject keyof UserFormObject
@@ -72,8 +65,6 @@ export default async function submitUserForm({
}); });
if (!delete_media_res.success) { if (!delete_media_res.success) {
console.log("delete_media_res", delete_media_res);
throw new Error( throw new Error(
delete_media_res.msg || delete_media_res.msg ||
delete_media_res.error || delete_media_res.error ||
@@ -98,16 +89,26 @@ export default async function submitUserForm({
} }
} }
if (create_or_update_user_res.success && target_user_id) { if (!create_or_update_user_res.success || !target_user_id) {
window.location.reload();
} else {
console.log("res", create_or_update_user_res);
console.log("form", form);
throw new Error( throw new Error(
create_or_update_user_res.msg || "Person Creation Failed", create_or_update_user_res.msg || "Couldn't save the account",
); );
} }
if (is_first_user) {
window.location.href = "/auth/login";
return;
}
// The header avatar comes from the server-rendered user, so a new
// image needs a reload to show up.
if (image?.imageBase64Full) {
window.location.reload();
return;
}
toast({ msg: "Profile saved", color: "success" });
setLoading(false);
} catch (error: any) { } catch (error: any) {
setStatus({ setStatus({
error: true, error: true,
@@ -1,20 +1,23 @@
import type { BUN_SQLITE_WGUI_USERS } from "@/db/types/db"; import type { BUN_SQLITE_WGUI_USERS } from "@/db/types/db";
import Button from "@/src/components/twui/layout/Button"; import FormActions from "../form-actions";
import useFormInit from "@/src/hooks/use-form-init"; import useFormInit from "@/src/hooks/use-form-init";
export default function UserFormAction({ type Props = ReturnType<typeof useFormInit<BUN_SQLITE_WGUI_USERS>> & {
status, passwords_match: boolean;
existing_full, };
}: ReturnType<typeof useFormInit<BUN_SQLITE_WGUI_USERS>>) {
const title = existing_full?.id ? "Update User" : "Create New User";
if (status?.error) { export default function UserFormAction({ existing_full, loading, passwords_match, is_first_user }: Props) {
return null; const title = existing_full?.id
} ? "Save profile"
: is_first_user
? "Create account"
: "Create user";
return ( return (
<> <FormActions
<Button title={title}>{title}</Button> submit_title={title}
</> loading={loading}
disabled={!passwords_match}
/>
); );
} }

Some files were not shown because too many files have changed in this diff Show More