diff --git a/AGENTS.md b/AGENTS.md index 0aacf02..17f32bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,8 +46,9 @@ No linter or formatter configured. Type-check and `bun test` are the verificatio ## Architecture -- `src/pages/__root.tsx` — picks the layout (`src/layouts/main` or `src/layouts/admin`, or `LoginShell` for auth pages) and provides `AppContext`. -- **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). +- `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:** `/` 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 `` — 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)//{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`. - **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/frontend/`** — Client-safe: CRUD/fetch handlers, media upload/delete, client rule label formatting. - **`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/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/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 `
` 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 diff --git a/public/icons/google.png b/public/icons/google.png deleted file mode 100644 index 494aced..0000000 Binary files a/public/icons/google.png and /dev/null differ diff --git a/public/images/logo.webp b/public/images/logo.webp deleted file mode 100644 index 40c81c0..0000000 Binary files a/public/images/logo.webp and /dev/null differ diff --git a/src/components/general/action-menu/action-menu-item.tsx b/src/components/general/action-menu/action-menu-item.tsx new file mode 100644 index 0000000..8640a56 --- /dev/null +++ b/src/components/general/action-menu/action-menu-item.tsx @@ -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 ( + + ); +} diff --git a/src/components/general/action-menu/index.tsx b/src/components/general/action-menu/index.tsx new file mode 100644 index 0000000..e11f939 --- /dev/null +++ b/src/components/general/action-menu/index.tsx @@ -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 ( + + {trigger_icon || } + + } + > + + {items.map((item) => ( + setOpen(false)} + /> + ))} + + + ); +} diff --git a/src/components/general/action-menu/types.ts b/src/components/general/action-menu/types.ts new file mode 100644 index 0000000..500efb2 --- /dev/null +++ b/src/components/general/action-menu/types.ts @@ -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; +}; diff --git a/src/components/general/admin-button.tsx b/src/components/general/admin-button.tsx deleted file mode 100644 index 34b4f2a..0000000 --- a/src/components/general/admin-button.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { ButtonHTMLAttributes } from "react"; -import type { LucideIcon } from "lucide-react"; -import { twMerge } from "tailwind-merge"; - -type Props = ButtonHTMLAttributes & { - 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 ( - - ); -} diff --git a/src/components/general/admin-card.tsx b/src/components/general/admin-card.tsx deleted file mode 100644 index 30ede84..0000000 --- a/src/components/general/admin-card.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { HTMLAttributes } from "react"; -import { twMerge } from "tailwind-merge"; - -type Props = HTMLAttributes & { - tier?: "default" | "secondary"; -}; - -export default function AdminCard({ - tier = "default", - className, - ...props -}: Props) { - return ( -
- ); -} diff --git a/src/components/general/admin-hero.tsx b/src/components/general/admin-hero.tsx deleted file mode 100644 index a99cbb3..0000000 --- a/src/components/general/admin-hero.tsx +++ /dev/null @@ -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 ( -
- - - -

{title}

- {description ? ( - typeof description == "string" ? ( - {description} - ) : ( - description - ) - ) : null} -
- {buttons} -
-
-
- ); -} diff --git a/src/components/general/app-providers.tsx b/src/components/general/app-providers.tsx new file mode 100644 index 0000000..7c9bba9 --- /dev/null +++ b/src/components/general/app-providers.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/components/general/auth-heading.tsx b/src/components/general/auth-heading.tsx new file mode 100644 index 0000000..b8bc2e2 --- /dev/null +++ b/src/components/general/auth-heading.tsx @@ -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 ( + +

{title}

+ {description ?

{description}

: null} +
+ ); +} diff --git a/src/components/general/blured-image-bg.tsx b/src/components/general/blured-image-bg.tsx deleted file mode 100755 index 43eb819..0000000 --- a/src/components/general/blured-image-bg.tsx +++ /dev/null @@ -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 ( - bg image - ); -} diff --git a/src/components/general/client-form/build-host-option-title.ts b/src/components/general/client-form/build-host-option-title.ts new file mode 100644 index 0000000..dfb591f --- /dev/null +++ b/src/components/general/client-form/build-host-option-title.ts @@ -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(" · "); +} diff --git a/src/components/general/client-form/client-form-allowed-ips.tsx b/src/components/general/client-form/client-form-allowed-ips.tsx new file mode 100644 index 0000000..55c7562 --- /dev/null +++ b/src/components/general/client-form/client-form-allowed-ips.tsx @@ -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 ( + + {form.allow_all_ips == 1 ? null : ( + { + setForm((prev) => ({ ...prev, allowed_ips: e.target.value })); + }} + defaultValue={form.allowed_ips} + showLabel + /> + )} + { + setForm((prev) => ({ ...prev, allow_all_ips: value ? 1 : 0 })); + }} + info={ + + Sets AllowedIPs to 0.0.0.0/0, ::/0 + + } + /> + + ); +} diff --git a/src/components/general/client-form/client-form-host-select.tsx b/src/components/general/client-form/client-form-host-select.tsx new file mode 100644 index 0000000..be286bc --- /dev/null +++ b/src/components/general/client-form/client-form-host-select.tsx @@ -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 ( + + 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) })} + /> + ); +} diff --git a/src/components/general/client-form/client-form-name.tsx b/src/components/general/client-form/client-form-name.tsx new file mode 100644 index 0000000..1e5eb74 --- /dev/null +++ b/src/components/general/client-form/client-form-name.tsx @@ -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 ( + { + setForm((prev) => ({ ...prev, name: e.target.value })); + }} + defaultValue={form.name} + showLabel + required + /> + ); +} diff --git a/src/components/general/client-form/client-form-notes.tsx b/src/components/general/client-form/client-form-notes.tsx new file mode 100644 index 0000000..ff32c46 --- /dev/null +++ b/src/components/general/client-form/client-form-notes.tsx @@ -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 ( + { + setForm((prev) => ({ ...prev, notes: e.target.value })); + }} + defaultValue={form.notes} + showLabel + /> + ); +} diff --git a/src/components/general/client-form/client-form-types.ts b/src/components/general/client-form/client-form-types.ts new file mode 100644 index 0000000..a1bd7c5 --- /dev/null +++ b/src/components/general/client-form/client-form-types.ts @@ -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>; diff --git a/src/components/general/client-form/client-form-wg-ip.tsx b/src/components/general/client-form/client-form-wg-ip.tsx new file mode 100644 index 0000000..a25b154 --- /dev/null +++ b/src/components/general/client-form/client-form-wg-ip.tsx @@ -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(); + 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 ( + + + { + 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 : ( + + )} + + {grab_error ? ( + + {grab_error} + + ) : is_edit ? ( + The tunnel IP can't change after creation. + ) : null} + + ); +} diff --git a/src/pages/admin/hosts/[host_id]/clients/add-client/(functions)/grab-next-available-client-ip.ts b/src/components/general/client-form/grab-next-available-client-ip.ts similarity index 100% rename from src/pages/admin/hosts/[host_id]/clients/add-client/(functions)/grab-next-available-client-ip.ts rename to src/components/general/client-form/grab-next-available-client-ip.ts diff --git a/src/components/general/client-form/index.tsx b/src/components/general/client-form/index.tsx new file mode 100644 index 0000000..6e7f465 --- /dev/null +++ b/src/components/general/client-form/index.tsx @@ -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({ + 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( + (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 ; + } + + return ( + +
{ + submitClientForm({ init, host_id, rules, confirm, toast }); + }} + > + {status?.error ? ( + + ) : null} + + + + + + + + + + + + setFieldKey((k) => k + 1)} + /> + + + + + + + + + +
+ ); +} diff --git a/src/components/general/client-form/submit-client-form.ts b/src/components/general/client-form/submit-client-form.ts new file mode 100644 index 0000000..e7096ba --- /dev/null +++ b/src/components/general/client-form/submit-client-form.ts @@ -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( + { + 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({ + 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({ + 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( + `/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); + } +} diff --git a/src/components/general/client-row.tsx b/src/components/general/client-row.tsx deleted file mode 100644 index a25c3fd..0000000 --- a/src/components/general/client-row.tsx +++ /dev/null @@ -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 ( -
- - - - - - - - - - ); -} diff --git a/src/components/general/client-rules/build-client-rule-drafts.ts b/src/components/general/client-rules/build-client-rule-drafts.ts new file mode 100644 index 0000000..a540158 --- /dev/null +++ b/src/components/general/client-rules/build-client-rule-drafts.ts @@ -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 }; +} diff --git a/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-destination-field.tsx b/src/components/general/client-rules/client-rule-destination-field.tsx similarity index 100% rename from src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-destination-field.tsx rename to src/components/general/client-rules/client-rule-destination-field.tsx diff --git a/src/components/general/client-rules/client-rule-draft-item.tsx b/src/components/general/client-rules/client-rule-draft-item.tsx new file mode 100644 index 0000000..3fafc6b --- /dev/null +++ b/src/components/general/client-rules/client-rule-draft-item.tsx @@ -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 ( + onRemove({ local_id: draft.local_id })} + /> + ); +} diff --git a/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-draft.ts b/src/components/general/client-rules/client-rule-draft.ts similarity index 100% rename from src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-draft.ts rename to src/components/general/client-rules/client-rule-draft.ts diff --git a/src/components/general/client-rules/client-rule-fields.tsx b/src/components/general/client-rules/client-rule-fields.tsx new file mode 100644 index 0000000..d46d23e --- /dev/null +++ b/src/components/general/client-rules/client-rule-fields.tsx @@ -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 `
`: 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 ( + { + if (e.key == "Enter" && (e.target as HTMLElement).tagName == "INPUT") { + e.preventDefault(); + onEnter(); + } + }} + > + + + + + + + + ); +} diff --git a/src/components/general/client-rules/client-rule-item.tsx b/src/components/general/client-rules/client-rule-item.tsx new file mode 100644 index 0000000..45036e4 --- /dev/null +++ b/src/components/general/client-rules/client-rule-item.tsx @@ -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 ( + + + + {formatClientRuleLabel({ rule })} + + + + ); +} diff --git a/src/components/general/client-rules/client-rule-modal.tsx b/src/components/general/client-rules/client-rule-modal.tsx new file mode 100644 index 0000000..fa10ce5 --- /dev/null +++ b/src/components/general/client-rules/client-rule-modal.tsx @@ -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; + 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(createClientRuleDraft()); + const [error, setError] = useState(); + 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 ( + { + setOpen(value); + if (value === false) reset(); + }} + className="max-w-lg p-5" + target={ + + } + > + + +

Add access rule

+

+ Allow this client to reach a destination. Leave ports empty for any port. +

+
+ + {error ? : null} + + { + setDraft(next); + setError(undefined); + }} + onEnter={handleAdd} + /> + + + + + +
+
+ ); +} diff --git a/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-ports-field.tsx b/src/components/general/client-rules/client-rule-ports-field.tsx similarity index 100% rename from src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-ports-field.tsx rename to src/components/general/client-rules/client-rule-ports-field.tsx diff --git a/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-protocol-field.tsx b/src/components/general/client-rules/client-rule-protocol-field.tsx similarity index 100% rename from src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-protocol-field.tsx rename to src/components/general/client-rules/client-rule-protocol-field.tsx diff --git a/src/components/general/client-rules/client-rule-type-field.tsx b/src/components/general/client-rules/client-rule-type-field.tsx new file mode 100644 index 0000000..84e2125 --- /dev/null +++ b/src/components/general/client-rules/client-rule-type-field.tsx @@ -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 ( + { + setDraft({ + ...draft, + rule_type: checked ? "all" : "destination", + }); + }} + /> + ); +} diff --git a/src/components/general/client-rules/client-rules-editor.tsx b/src/components/general/client-rules/client-rules-editor.tsx new file mode 100644 index 0000000..136e30e --- /dev/null +++ b/src/components/general/client-rules/client-rules-editor.tsx @@ -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 ( + + + {rules.length ? ( + rules.map((rule) => ( + + setRules(rules.filter((item) => item.local_id != local_id)) + } + /> + )) + ) : ( + + )} + + + { + setRules([...rules, ...added]); + return { success: true }; + }} + /> + + + ); +} diff --git a/src/components/general/client-rules/client-rules-empty.tsx b/src/components/general/client-rules/client-rules-empty.tsx new file mode 100644 index 0000000..e68f883 --- /dev/null +++ b/src/components/general/client-rules/client-rules-empty.tsx @@ -0,0 +1,13 @@ +import { ShieldOff } from "lucide-react"; +import EmptyState from "../empty-state"; + +export default function ClientRulesEmpty() { + return ( + } + title="No access rules" + description="Zero trust: without rules this client can't reach the host, its LAN, the internet or other clients." + /> + ); +} diff --git a/src/components/general/clients-table.tsx b/src/components/general/clients-table.tsx deleted file mode 100644 index 3c1688e..0000000 --- a/src/components/general/clients-table.tsx +++ /dev/null @@ -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; - 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 ( -
-
- - {client.name || "—"} - - - {typeof host_id == "number" ? ( - - {host_name} - - ) : ( - - — - - )} - - {client.wg_ip_address || "—"} - - {client.allow_all_ips - ? "0.0.0.0/0, ::/0" - : client.allowed_ips || "—"} - - - {access.text} - - - - {client.public_key - ? `${client.public_key.slice(0, 16)}…` - : "—"} - - - {formatUnixTimestamp({ timestamp: client.created_at })} - - - - - - - -
- - - - - - - - - - - - - - {clients.map((client) => ( - - ))} - -
ClientHostTunnel IPAllowed IPs{accessLabel}Public keyCreatedActions
- - ); -} diff --git a/src/components/general/clients-table/client-access-tag.tsx b/src/components/general/clients-table/client-access-tag.tsx new file mode 100644 index 0000000..d22e643 --- /dev/null +++ b/src/components/general/clients-table/client-access-tag.tsx @@ -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 ( + + ); +} diff --git a/src/components/general/clients-table/client-mobile-card.tsx b/src/components/general/clients-table/client-mobile-card.tsx new file mode 100644 index 0000000..6a76972 --- /dev/null +++ b/src/components/general/clients-table/client-mobile-card.tsx @@ -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 ( + + + + + {hide_host ? null : ( + {deriveClientHostName({ client })} + )} + + + + + + ); +} diff --git a/src/components/general/clients-table/client-name-cell.tsx b/src/components/general/clients-table/client-name-cell.tsx new file mode 100644 index 0000000..0712104 --- /dev/null +++ b/src/components/general/clients-table/client-name-cell.tsx @@ -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 ( + + + {client.name || `Client #${client.id}`} + + + {client.wg_ip_address || "—"} + + + ); +} diff --git a/src/components/general/clients-table/client-row-actions.tsx b/src/components/general/clients-table/client-row-actions.tsx new file mode 100644 index 0000000..dc55b87 --- /dev/null +++ b/src/components/general/clients-table/client-row-actions.tsx @@ -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 ( + }, + { + key: "edit", + title: "Edit", + href: `${base}/edit`, + icon: , + }, + { + key: "delete", + title: "Delete", + tone: "danger", + icon: , + loading: deleting, + onClick: () => deleteClient({ client }), + }, + ]} + /> + ); +} diff --git a/src/components/general/clients-table/clients-table-columns.tsx b/src/components/general/clients-table/clients-table-columns.tsx new file mode 100644 index 0000000..15a5255 --- /dev/null +++ b/src/components/general/clients-table/clients-table-columns.tsx @@ -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; + onDeleted?: ({ id }: { id: number }) => void; +}; + +export default function buildClientsTableColumns({ + hide_host, + rules_by_client_id, + onDeleted, +}: Params): DataTableColumn[] { + const columns: (DataTableColumn | null)[] = [ + { + key: "name", + header: "Client", + render: ({ item }) => , + }, + hide_host + ? null + : { + key: "host", + header: "Host", + render: ({ item }) => ( + + {deriveClientHostName({ client: item })} + + ), + }, + { + key: "ip", + header: "Tunnel IP", + hide_below: "lg", + render: ({ item }) => {item.wg_ip_address || "—"}, + }, + { + key: "allowed", + header: "Allowed IPs", + hide_below: "xl", + className: "max-w-[200px]", + render: ({ item }) => ( + + {item.allow_all_ips ? "0.0.0.0/0, ::/0" : item.allowed_ips || "—"} + + ), + }, + { + key: "access", + header: "Access", + render: ({ item }) => ( + + ), + }, + { + key: "key", + header: "Public key", + hide_below: "xl", + className: "max-w-[180px]", + render: ({ item }) => + item.public_key ? ( + + ) : ( + — + ), + }, + { + key: "created", + header: "Created", + align: "right", + hide_below: "lg", + render: ({ item }) => ( + + {formatUnixTimestamp({ timestamp: item.created_at })} + + ), + }, + { + key: "actions", + header: Actions, + align: "right", + className: "w-10", + render: ({ item }) => ( + + ), + }, + ]; + + return columns.filter(Boolean) as DataTableColumn[]; +} diff --git a/src/components/general/clients-table/derive-client-host-name.ts b/src/components/general/clients-table/derive-client-host-name.ts new file mode 100644 index 0000000..d88b390 --- /dev/null +++ b/src/components/general/clients-table/derive-client-host-name.ts @@ -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}`); +} diff --git a/src/components/general/clients-table/index.tsx b/src/components/general/clients-table/index.tsx new file mode 100644 index 0000000..b3940a7 --- /dev/null +++ b/src/components/general/clients-table/index.tsx @@ -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; + 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 }) => ( + + ), + [rules_by_client_id, hide_host, onDeleted], + ); + + return ( + Number(item.id)} + loading={loading} + empty={empty} + skeleton_rows={skeleton_rows} + MobileItem={MobileItem} + /> + ); +} diff --git a/src/components/general/clients-table/use-delete-client.ts b/src/components/general/clients-table/use-delete-client.ts new file mode 100644 index 0000000..df726cf --- /dev/null +++ b/src/components/general/clients-table/use-delete-client.ts @@ -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 }; +} diff --git a/src/components/general/confirm/confirm-context.ts b/src/components/general/confirm/confirm-context.ts new file mode 100644 index 0000000..a2eab57 --- /dev/null +++ b/src/components/general/confirm/confirm-context.ts @@ -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; +}; + +export const ConfirmContext = createContext({ + confirm: async () => false, +}); diff --git a/src/components/general/confirm/confirm-dialog.tsx b/src/components/general/confirm/confirm-dialog.tsx new file mode 100644 index 0000000..511e4c4 --- /dev/null +++ b/src/components/general/confirm/confirm-dialog.tsx @@ -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(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) => { + const next = typeof value == "function" ? value(open) : value; + if (!next && open) onResolve({ confirmed: false }); + }) as React.Dispatch>; + + return ( + + + + {danger ? ( + + + + ) : null} + +

+ {params?.title} +

+ {params?.message ? ( + typeof params.message == "string" ? ( +

+ {params.message} +

+ ) : ( + params.message + ) + ) : null} +
+
+ + + + +
+
+ ); +} diff --git a/src/components/general/confirm/confirm-provider.tsx b/src/components/general/confirm/confirm-provider.tsx new file mode 100644 index 0000000..5a08eff --- /dev/null +++ b/src/components/general/confirm/confirm-provider.tsx @@ -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(null); + const resolver = useRef<((confirmed: boolean) => void) | null>(null); + + const confirm = useCallback((next: ConfirmParams) => { + resolver.current?.(false); + setParams(next); + setOpen(true); + + return new Promise((resolve) => { + resolver.current = resolve; + }); + }, []); + + function onResolve({ confirmed }: { confirmed: boolean }) { + resolver.current?.(confirmed); + resolver.current = null; + setOpen(false); + } + + return ( + + {children} + + + ); +} diff --git a/src/components/general/copy-button.tsx b/src/components/general/copy-button.tsx new file mode 100644 index 0000000..a8d397c --- /dev/null +++ b/src/components/general/copy-button.tsx @@ -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 ( + + ); +} diff --git a/src/components/general/copyable-value.tsx b/src/components/general/copyable-value.tsx new file mode 100644 index 0000000..9ac9ef1 --- /dev/null +++ b/src/components/general/copyable-value.tsx @@ -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 ( + + + {display ?? value} + + + + ); +} diff --git a/src/components/general/data-table/data-table-cell.tsx b/src/components/general/data-table/data-table-cell.tsx new file mode 100644 index 0000000..1906f0f --- /dev/null +++ b/src/components/general/data-table/data-table-cell.tsx @@ -0,0 +1,22 @@ +import { twMerge } from "tailwind-merge"; +import { HideBelowClasses, type DataTableColumn } from "./types"; + +type Props = { + column: DataTableColumn; + item: T; +}; + +export default function DataTableCell({ column, item }: Props) { + return ( + + {column.render({ item })} + + ); +} diff --git a/src/components/general/data-table/data-table-head-cell.tsx b/src/components/general/data-table/data-table-head-cell.tsx new file mode 100644 index 0000000..42f9e7d --- /dev/null +++ b/src/components/general/data-table/data-table-head-cell.tsx @@ -0,0 +1,21 @@ +import { twMerge } from "tailwind-merge"; +import { HideBelowClasses, type DataTableColumn } from "./types"; + +type Props = { + column: DataTableColumn; +}; + +export default function DataTableHeadCell({ column }: Props) { + return ( + + {column.header} + + ); +} diff --git a/src/components/general/data-table/data-table-mobile-list.tsx b/src/components/general/data-table/data-table-mobile-list.tsx new file mode 100644 index 0000000..1b9a5fa --- /dev/null +++ b/src/components/general/data-table/data-table-mobile-list.tsx @@ -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 = Pick, "items" | "getKey" | "MobileItem"> & { + loading: boolean; + skeleton_keys: number[]; +}; + +export default function DataTableMobileList({ + items, + getKey, + MobileItem, + loading, + skeleton_keys, +}: Props) { + return ( + + {loading + ? skeleton_keys.map((key) => ) + : items?.map((item) => ( + + ))} + + ); +} diff --git a/src/components/general/data-table/data-table-mobile-skeleton.tsx b/src/components/general/data-table/data-table-mobile-skeleton.tsx new file mode 100644 index 0000000..7cf3aa6 --- /dev/null +++ b/src/components/general/data-table/data-table-mobile-skeleton.tsx @@ -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 ( + + + + + ); +} diff --git a/src/components/general/data-table/data-table-row.tsx b/src/components/general/data-table/data-table-row.tsx new file mode 100644 index 0000000..2502096 --- /dev/null +++ b/src/components/general/data-table/data-table-row.tsx @@ -0,0 +1,17 @@ +import DataTableCell from "./data-table-cell"; +import type { DataTableColumn } from "./types"; + +type Props = { + columns: DataTableColumn[]; + item: T; +}; + +export default function DataTableRow({ columns, item }: Props) { + return ( + + {columns.map((column) => ( + + ))} + + ); +} diff --git a/src/components/general/data-table/data-table-skeleton-cell.tsx b/src/components/general/data-table/data-table-skeleton-cell.tsx new file mode 100644 index 0000000..21904d0 --- /dev/null +++ b/src/components/general/data-table/data-table-skeleton-cell.tsx @@ -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; +}; + +export default function DataTableSkeletonCell({ column }: Props) { + return ( + + + + ); +} diff --git a/src/components/general/data-table/data-table-skeleton-row.tsx b/src/components/general/data-table/data-table-skeleton-row.tsx new file mode 100644 index 0000000..df7b24f --- /dev/null +++ b/src/components/general/data-table/data-table-skeleton-row.tsx @@ -0,0 +1,16 @@ +import DataTableSkeletonCell from "./data-table-skeleton-cell"; +import type { DataTableColumn } from "./types"; + +type Props = { + columns: DataTableColumn[]; +}; + +export default function DataTableSkeletonRow({ columns }: Props) { + return ( + + {columns.map((column) => ( + + ))} + + ); +} diff --git a/src/components/general/data-table/index.tsx b/src/components/general/data-table/index.tsx new file mode 100644 index 0000000..c82f927 --- /dev/null +++ b/src/components/general/data-table/index.tsx @@ -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 `` 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({ + items, + columns, + getKey, + loading, + skeleton_rows = 4, + empty, + MobileItem, + caption, +}: DataTableProps) { + 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 ( + +
+
+ {caption ? : null} + + + {columns.map((column) => ( + + ))} + + + + {is_loading + ? skeleton_keys.map((key) => ( + + )) + : items?.map((item) => ( + + ))} + +
{caption}
+ + + + ); +} diff --git a/src/components/general/data-table/types.ts b/src/components/general/data-table/types.ts new file mode 100644 index 0000000..d4d7a12 --- /dev/null +++ b/src/components/general/data-table/types.ts @@ -0,0 +1,29 @@ +import type { ComponentType, ReactNode } from "react"; + +export type DataTableColumn = { + 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 = { + items?: T[]; + columns: DataTableColumn[]; + 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["hide_below"]>, string> = { + lg: "hidden lg:table-cell", + xl: "hidden xl:table-cell", +}; diff --git a/src/components/general/delete-host-button.tsx b/src/components/general/delete-host-button.tsx deleted file mode 100644 index 23a72c1..0000000 --- a/src/components/general/delete-host-button.tsx +++ /dev/null @@ -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( - `/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 ( - + ) : null} + + + + ); +} diff --git a/src/components/general/form-section.tsx b/src/components/general/form-section.tsx new file mode 100644 index 0000000..09a6b2d --- /dev/null +++ b/src/components/general/form-section.tsx @@ -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 ( + + +

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ {children} +
+ ); +} diff --git a/src/components/general/grab-next-subnet-button.tsx b/src/components/general/grab-next-subnet-button.tsx index 94a82e3..ffae3e1 100644 --- a/src/components/general/grab-next-subnet-button.tsx +++ b/src/components/general/grab-next-subnet-button.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Wand2 } from "lucide-react"; import Button from "../twui/layout/Button"; import fetchApi from "../twui/utils/fetch/fetchApi"; +import useToast from "@/src/hooks/use-toast"; import type { ApiReqParams } from "@/src/types"; type Props = { @@ -10,6 +11,7 @@ type Props = { export default function GrabNextSubnetButton({ onGrab }: Props) { const [grabbing, setGrabbing] = useState(false); + const { toast } = useToast(); function grabNextSubnet() { setGrabbing(true); @@ -21,6 +23,8 @@ export default function GrabNextSubnetButton({ onGrab }: Props) { .then((res) => { if (res?.success && res.msg) { onGrab?.(res.msg); + } else { + toast({ msg: "Couldn't find a free subnet", color: "error" }); } }) .finally(() => { @@ -30,17 +34,16 @@ export default function GrabNextSubnetButton({ onGrab }: Props) { return ( ); } diff --git a/src/components/general/host-clients-panel.tsx b/src/components/general/host-clients-panel.tsx new file mode 100644 index 0000000..0242c9b --- /dev/null +++ b/src/components/general/host-clients-panel.tsx @@ -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({ + 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 ( + + + Add client + + } + > + } + title="No clients on this host" + description="Clients get a tunnel IP from this host's subnet." + action={ + + } + /> + } + /> + + ); +} diff --git a/src/components/general/host-wg-ip-field.tsx b/src/components/general/host-wg-ip-field.tsx index 69a53dc..1e2b336 100644 --- a/src/components/general/host-wg-ip-field.tsx +++ b/src/components/general/host-wg-ip-field.tsx @@ -1,22 +1,16 @@ 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 Row from "../twui/layout/Row"; -import Span from "../twui/layout/Span"; -import Tag from "../twui/elements/Tag"; import Stack from "../twui/layout/Stack"; import fetchApi from "../twui/utils/fetch/fetchApi"; import type { ApiReqParams } from "@/src/types"; 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 { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db"; -export type HostWgIpFieldStatus = - | "checking" - | "available" - | "not_available" - | "invalid" - | "current"; +export type { HostWgIpFieldStatus }; type Props = ReturnType> & { value: string; @@ -43,6 +37,7 @@ export default function HostWgIpField({ setForm, }: Props) { const [availability, setAvailability] = useState(); + const [field_key, setFieldKey] = useState(0); useEffect(() => { setAvailability(undefined); @@ -85,91 +80,34 @@ export default function HostWgIpField({ }, [ip_status, onStatus]); return ( - - + + { onChange?.(v); }} - prefix={} - suffix={ - - WireGuard IP Address - - } - title="Wireguard IP Address" + prefix={} + title="Host tunnel address" + placeholder="e.g. 10.8.0.1" showLabel componentRef={inputRef} autoFocus={autoFocus} + wrapperWrapperProps={{ className: "grow" }} /> - { setForm((prev) => ({ ...prev, wg_ip_address: ip, })); + setFieldKey((k) => k + 1); }} /> - - {ip_status === "invalid" ? ( - - - - Invalid IP - - - ) : ip_status === "current" ? ( - - - - Current value - - - ) : ip_status === "not_available" ? ( - - - - Not available - - - ) : ip_status === "available" ? ( - - - - Available - - - ) : ( - - - - Checking availability… - - - )} + ); } diff --git a/src/components/general/host-wg-ip-status.tsx b/src/components/general/host-wg-ip-status.tsx new file mode 100644 index 0000000..041cf28 --- /dev/null +++ b/src/components/general/host-wg-ip-status.tsx @@ -0,0 +1,30 @@ +import StatusDot, { type StatusTone } from "./status-dot"; + +export type HostWgIpFieldStatus = + | "checking" + | "available" + | "not_available" + | "invalid" + | "current"; + +const StatusDisplay: Record = { + 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 ( +
+ +
+ ); +} diff --git a/src/components/general/image-bg.tsx b/src/components/general/image-bg.tsx deleted file mode 100755 index d003dc6..0000000 --- a/src/components/general/image-bg.tsx +++ /dev/null @@ -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, "alt">; -}; - -export default function ImageBG({ src, img_props }: Props) { - return ( - bg image - ); -} diff --git a/src/components/general/inline-alert.tsx b/src/components/general/inline-alert.tsx new file mode 100644 index 0000000..0284a20 --- /dev/null +++ b/src/components/general/inline-alert.tsx @@ -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 = { + 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 ( + + + {msg} + + ); +} diff --git a/src/components/general/inner-page-hero-section.tsx b/src/components/general/inner-page-hero-section.tsx deleted file mode 100644 index 47b8c65..0000000 --- a/src/components/general/inner-page-hero-section.tsx +++ /dev/null @@ -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 ( -
- - - {sub_title ? ( - {sub_title} - ) : null} -

{title}

- {description ? ( - - {description} - - ) : null} -
-
-
- ); -} diff --git a/src/components/general/key-value-list.tsx b/src/components/general/key-value-list.tsx new file mode 100644 index 0000000..a2fb2f6 --- /dev/null +++ b/src/components/general/key-value-list.tsx @@ -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 ( + + {items.map((item) => ( + + ))} + + ); +} diff --git a/src/components/general/key-value-row.tsx b/src/components/general/key-value-row.tsx new file mode 100644 index 0000000..2b75ccb --- /dev/null +++ b/src/components/general/key-value-row.tsx @@ -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 ( + + {label} + {copy_value ? ( + + ) : ( + + {value ?? "—"} + + )} + + ); +} diff --git a/src/components/general/login-shell.tsx b/src/components/general/login-shell.tsx deleted file mode 100644 index 413dec8..0000000 --- a/src/components/general/login-shell.tsx +++ /dev/null @@ -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 ( - - - - - {status?.error && status.msg ? ( - - - - {status.msg} - - - ) : null} - {status?.success && status.msg ? ( - - - - {status.msg} - - - ) : null} - {children} - - - ); -} diff --git a/src/components/general/logo-icon.tsx b/src/components/general/logo-icon.tsx index ea9b1e1..0052776 100644 --- a/src/components/general/logo-icon.tsx +++ b/src/components/general/logo-icon.tsx @@ -1,21 +1,33 @@ import type { ComponentProps } from "react"; +import { twMerge } from "tailwind-merge"; import Img from "../twui/layout/Img"; +import Span from "../twui/layout/Span"; type Props = { icon_size?: number; img_props?: Omit, "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 ( - Logo Icon + + wg-ui + + ); } diff --git a/src/components/general/logo.tsx b/src/components/general/logo.tsx index 90eb9c7..c391a45 100644 --- a/src/components/general/logo.tsx +++ b/src/components/general/logo.tsx @@ -1,57 +1,24 @@ +import Link from "../twui/layout/Link"; import Row from "../twui/layout/Row"; import Span from "../twui/layout/Span"; -import type { ComponentProps, PropsWithChildren } from "react"; import { twMerge } from "tailwind-merge"; -import Stack from "../twui/layout/Stack"; import LogoIcon from "./logo-icon"; -import { SiteData } from "@/src/data/site-data"; type Props = { icon_size?: number; - text_wrapper_props?: ComponentProps; - text_props?: ComponentProps; - white?: boolean; + href?: string; + className?: string; }; -export default function Logo({ - icon_size = 45, - text_props, - text_wrapper_props, - white, -}: Props) { +export default function Logo({ icon_size = 28, href = "/admin", className }: Props) { return ( - - - - - - wg-ui - - + + + + + wg-ui + - - ); -} - -function HeroText({ - text_props, - children, -}: PropsWithChildren & { - text_props?: ComponentProps; -}) { - return ( - - {children} - + ); } diff --git a/src/components/general/main-hero-section.tsx b/src/components/general/main-hero-section.tsx deleted file mode 100755 index 0f6e9a2..0000000 --- a/src/components/general/main-hero-section.tsx +++ /dev/null @@ -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, "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 ? ( - - ) : null; - - return ( - -
- - - -

- {title} -

- {sub_title ? ( - - {sub_title} - - ) : null} - {cta_buttons} -
- {/* */} -
-
-
-
- ); -} diff --git a/src/components/general/mono-text.tsx b/src/components/general/mono-text.tsx new file mode 100644 index 0000000..e7dc603 --- /dev/null +++ b/src/components/general/mono-text.tsx @@ -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 & { + truncate_text?: boolean; +}; + +/** IPs, keys, paths, ports. */ +export default function MonoText({ truncate_text, className, ...props }: Props) { + return ( + + ); +} diff --git a/src/components/general/muted-text.tsx b/src/components/general/muted-text.tsx new file mode 100644 index 0000000..ecc15ef --- /dev/null +++ b/src/components/general/muted-text.tsx @@ -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, "size"> & { + text_size?: "xs" | "sm"; + tone?: "muted" | "subtle" | "error" | "success" | "warning"; + mono?: boolean; +}; + +const ToneClasses: Record, 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 ( + + ); +} diff --git a/src/components/general/page-body.tsx b/src/components/general/page-body.tsx new file mode 100644 index 0000000..de86d87 --- /dev/null +++ b/src/components/general/page-body.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/components/general/page-header/index.tsx b/src/components/general/page-header/index.tsx new file mode 100644 index 0000000..aeaf5c0 --- /dev/null +++ b/src/components/general/page-header/index.tsx @@ -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 ( + + {back ? ( + + + + {back.title} + + + ) : null} + + +

{title}

+ {description ? ( + typeof description == "string" ? ( +

+ {description} +

+ ) : ( + description + ) + ) : null} + {meta ? {meta} : null} +
+ +
+ {nav ?
{nav}
: null} +
+ ); +} diff --git a/src/components/general/page-header/page-header-action.tsx b/src/components/general/page-header/page-header-action.tsx new file mode 100644 index 0000000..f299987 --- /dev/null +++ b/src/components/general/page-header/page-header-action.tsx @@ -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 ( + + ); +} diff --git a/src/components/general/page-header/page-header-actions.tsx b/src/components/general/page-header/page-header-actions.tsx new file mode 100644 index 0000000..e19a5d2 --- /dev/null +++ b/src/components/general/page-header/page-header-actions.tsx @@ -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 ( + + + {secondary.map((action) => ( + + ))} + + {secondary.length ? ( + + ) : null} + {primary_action ? ( + + ) : null} + + ); +} diff --git a/src/components/general/panel/index.tsx b/src/components/general/panel/index.tsx new file mode 100644 index 0000000..68f00d1 --- /dev/null +++ b/src/components/general/panel/index.tsx @@ -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 ( + + {has_header ? ( + + ) : null} + + {children} + + {footer ? ( + + {footer} + + ) : null} + + ); +} diff --git a/src/components/general/panel/panel-header.tsx b/src/components/general/panel/panel-header.tsx new file mode 100644 index 0000000..33085bf --- /dev/null +++ b/src/components/general/panel/panel-header.tsx @@ -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 ( + + + {title ?

{title}

: null} + {description ? {description} : null} +
+ {actions ? {actions} : null} +
+ ); +} diff --git a/src/components/general/rect-image.tsx b/src/components/general/rect-image.tsx deleted file mode 100755 index 91ffaff..0000000 --- a/src/components/general/rect-image.tsx +++ /dev/null @@ -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 = ( - {alt - ); - - return ( - <> - - - {img_component} - - - ); -} diff --git a/src/components/general/section-label.tsx b/src/components/general/section-label.tsx new file mode 100644 index 0000000..35378b5 --- /dev/null +++ b/src/components/general/section-label.tsx @@ -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 ( + + {children} + + ); +} diff --git a/src/components/general/setup-main-host-button.tsx b/src/components/general/setup-main-host-button.tsx deleted file mode 100755 index adcff92..0000000 --- a/src/components/general/setup-main-host-button.tsx +++ /dev/null @@ -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, "title">; -}; - -export default function SetupMainHostButton({ button_props }: Props) { - const { loading, setLoading, status, setStatus } = useStatus(); - - const [wgIP, setWgIP] = useState(AppData["DefaultPrivateIP"]); - const [ipStatus, setIpStatus] = useState("checking"); - - return ( - - {status?.error && status.msg ? ( - - - - {status.msg} - - - ) : null} - {status?.success && status.msg ? ( - - - - {status.msg} - - - ) : null} - - - - - - - - ); -} diff --git a/src/components/general/stat-tile.tsx b/src/components/general/stat-tile.tsx new file mode 100644 index 0000000..1c872e4 --- /dev/null +++ b/src/components/general/stat-tile.tsx @@ -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 ( + + {label} + + {is_loading ? ( + + ) : ( + + {value} + + )} + {hint ? {hint} : null} + + + ); +} diff --git a/src/components/general/status-dot.tsx b/src/components/general/status-dot.tsx new file mode 100644 index 0000000..4b74df7 --- /dev/null +++ b/src/components/general/status-dot.tsx @@ -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 = { + 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 ( + + + {label ? ( + + {label} + + ) : null} + + ); +} diff --git a/src/components/general/sub-nav/index.tsx b/src/components/general/sub-nav/index.tsx new file mode 100644 index 0000000..49c569d --- /dev/null +++ b/src/components/general/sub-nav/index.tsx @@ -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 ( + + ); +} diff --git a/src/components/general/sub-nav/sub-nav-item.tsx b/src/components/general/sub-nav/sub-nav-item.tsx new file mode 100644 index 0000000..8c5cc92 --- /dev/null +++ b/src/components/general/sub-nav/sub-nav-item.tsx @@ -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 ( + + + {Icon ? : null} + {title} + + + ); +} diff --git a/src/components/general/tagline-section.tsx b/src/components/general/tagline-section.tsx deleted file mode 100755 index e875639..0000000 --- a/src/components/general/tagline-section.tsx +++ /dev/null @@ -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 ( -
- - - {icon_name && ( - - )} -

- {tagline} -

- {sub_title ? ( - - {sub_title} - - ) : null} - {post_component} -
-
-
- ); -} diff --git a/src/components/general/theme/theme-script.tsx b/src/components/general/theme/theme-script.tsx new file mode 100644 index 0000000..de053d9 --- /dev/null +++ b/src/components/general/theme/theme-script.tsx @@ -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