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