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
|
## Architecture
|
||||||
|
|
||||||
- `src/pages/__root.tsx` — picks the layout (`src/layouts/main` or `src/layouts/admin`, or `LoginShell` for auth pages) and provides `AppContext`.
|
- `src/pages/__root.tsx` — picks the layout (`src/layouts/admin` for `/admin*`, `src/layouts/centered` for auth, 404 and everything else), mounts `AppProviders` (toast + confirm dialog) and provides `AppContext`. Its `Head` renders `ThemeScript` so the theme applies before first paint.
|
||||||
- **Pages:** `/` + `about`, `privacy`, `terms`; `/auth/{login,logout,signup}`; `/admin` (dashboard), `/admin/setup`, `/admin/settings`, `/admin/clients`, `/admin/hosts/*` (add/edit hosts, `[host_id]/clients/*` add/edit/view clients).
|
- **Pages:** `/` redirects to `/admin` or `/auth/login`; `/auth/{login,logout,signup}`; `/admin` (dashboard), `/admin/setup/{status,maintenance,firewall}` (super admin only, guarded per page by `guard-setup-page`; `/admin/setup` redirects to `status`), `/admin/settings/{profile,password}` (`/admin/settings` redirects to `profile`), `/admin/clients`, `/admin/clients/add` (the only add-client form — `?host_id=` preselects the host), `/admin/hosts/*` (add/edit hosts, `[host_id]/clients` list, `[host_id]/clients/[client_id]` tabbed detail + `edit`). `[host_id]/clients/add-client` is a legacy redirect.
|
||||||
|
- **Theme:** light/dark/system, stored in the `wgui_theme` cookie (`src/dict/theme-dict.ts`). twui dark styles key off a `.dark` class on `<html>` — toggle it only via `applyTheme`, never replace `className`.
|
||||||
- **API:** `src/pages/api/admin/*` (host/client setup, network info, firewall status, service restart/update, media), generic CRUD at `src/pages/api/admin/crud/[[...paths]].ts` with per-table handlers in `crud/(tables)/<table>/{get,post,put,del}.ts`, plus `api/auth/login.ts` and `api/create-first-user.ts`.
|
- **API:** `src/pages/api/admin/*` (host/client setup, network info, firewall status, service restart/update, media), generic CRUD at `src/pages/api/admin/crud/[[...paths]].ts` with per-table handlers in `crud/(tables)/<table>/{get,post,put,del}.ts`, plus `api/auth/login.ts` and `api/create-first-user.ts`.
|
||||||
- **Every `/api/admin/*` route must authenticate** — nothing guards the prefix globally. Use `adminApiGuard({ req })` (`src/functions/backend/auth/admin-api-guard.ts`) or `userAuth` + `checkUserAccess`.
|
- **Every `/api/admin/*` route must authenticate** — nothing guards the prefix globally. Use `adminApiGuard({ req })` (`src/functions/backend/auth/admin-api-guard.ts`) or `userAuth` + `checkUserAccess`.
|
||||||
- **Shell safety:** never interpolate request/DB values into `execSync` strings. Use `execFileSync` with an args array or `node:fs` APIs, and validate values before they reach a WireGuard config (newlines there can inject `PostUp` commands). Validators live in `src/utils/` (`validate-ipv4`, `validate-ipv4-cidr`, `validate-allowed-ips`, `validate-endpoint-host`, `validate-interface-name`).
|
- **Shell safety:** never interpolate request/DB values into `execSync` strings. Use `execFileSync` with an args array or `node:fs` APIs, and validate values before they reach a WireGuard config (newlines there can inject `PostUp` commands). Validators live in `src/utils/` (`validate-ipv4`, `validate-ipv4-cidr`, `validate-allowed-ips`, `validate-endpoint-host`, `validate-interface-name`).
|
||||||
@@ -78,11 +79,17 @@ Server functions use `index.server.ts` alongside `index.tsx` (e.g. `single-docum
|
|||||||
- **`src/functions/backend/`** — Server-only: `auth/`, `db/`, `firewall/` (firewalld/ufw rules, status), `media/`, `setup/` (host/client setup, iptables script builder, network detection).
|
- **`src/functions/backend/`** — Server-only: `auth/`, `db/`, `firewall/` (firewalld/ufw rules, status), `media/`, `setup/` (host/client setup, iptables script builder, network detection).
|
||||||
- **`src/functions/frontend/`** — Client-safe: CRUD/fetch handlers, media upload/delete, client rule label formatting.
|
- **`src/functions/frontend/`** — Client-safe: CRUD/fetch handlers, media upload/delete, client rule label formatting.
|
||||||
- **`src/functions/server/`** — Request-level server handlers (media server).
|
- **`src/functions/server/`** — Request-level server handlers (media server).
|
||||||
- **`src/hooks/`** — `use-admin-crud-get`, `use-client-rules-map`, `use-fetch-get`, `use-form-init`, `use-users-get`.
|
- **`src/hooks/`** — `use-admin-crud-get` (with `refetch`), `use-client-rules-map`, `use-confirm`, `use-toast`, `use-theme`, `use-fetch-get`, `use-form-init`, `use-users-get`.
|
||||||
- **`src/utils/`** — Cookies, crypto, dir names, IP/CIDR/port/interface validators, WireGuard interface naming. Most have `.test.ts` files.
|
- **`src/utils/`** — Cookies, crypto, dir names, IP/CIDR/port/interface validators, WireGuard interface naming. Most have `.test.ts` files.
|
||||||
- **`src/dict/`**, **`src/data/`** — Constants (`app-data.ts`, `site-data.ts`), user types, client rules, links.
|
- **`src/dict/`**, **`src/data/`** — Constants (`app-data.ts`, `site-data.ts`), user types, client rules, links.
|
||||||
- **`src/scripts/`** — Shell scripts: `install-wg-ui.sh`, `setup-wireguard.sh`, `wg-quick-manage.sh`.
|
- **`src/scripts/`** — Shell scripts: `install-wg-ui.sh`, `setup-wireguard.sh`, `wg-quick-manage.sh`.
|
||||||
- **`src/components/general/`** — Shared app components (admin cards/buttons, clients table, host IP field, login shell, user form).
|
- **`src/components/general/`** — Shared app components, all built from twui. Reach for these before writing page markup:
|
||||||
|
- Page frame: `page-header` (title, meta, primary action + overflow menu), `page-body`, `panel` (the one card style), `form-section`, `form-actions` (submit is always rendered).
|
||||||
|
- Text & status: `muted-text`, `mono-text` (IPs, keys, paths), `section-label`, `status-dot`, `inline-alert`, `empty-state`, `stat-tile`, `key-value-list`, `copy-button`, `copyable-value`.
|
||||||
|
- Data: `data-table` (desktop table + mobile card list; the only place raw `<table>` is allowed), `clients-table`, `host-clients-panel`, `action-menu`, `sub-nav` (link tabs between sibling routes).
|
||||||
|
- Flows: `client-form` + `client-rules` (shared by add/edit client and the client detail Rules tab), `user-form`, `host-wg-ip-field`, `use-delete-host`.
|
||||||
|
- Feedback: `confirm/` + `useConfirm()` and `toast/` + `useToast()` (`src/hooks`). Never use `window.confirm`/`alert`; update lists in place instead of `location.reload()`.
|
||||||
|
- **Design tokens** live in `src/styles/main.css`: one accent, WireGuard red `#89161b` (`primary` = `secondary` = `accent`; `error` is a separate brighter red), zinc neutrals, and theme-flipping semantic colours (`bg-surface`, `bg-surface-2`, `border-line`, `text-fg`, `text-muted`, `text-subtle`) that need no `dark:` pair.
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 9.4 KiB |
@@ -0,0 +1,45 @@
|
|||||||
|
import Button from "@/src/components/twui/layout/Button";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import type { ActionMenuItem as ActionMenuItemType } from "./types";
|
||||||
|
|
||||||
|
type Props = ActionMenuItemType & {
|
||||||
|
onDone: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ActionMenuItem({
|
||||||
|
title,
|
||||||
|
href,
|
||||||
|
onClick,
|
||||||
|
icon,
|
||||||
|
tone,
|
||||||
|
disabled,
|
||||||
|
loading,
|
||||||
|
onDone,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
title={title}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
color={tone == "danger" ? "error" : "text"}
|
||||||
|
size="small"
|
||||||
|
href={href}
|
||||||
|
disabled={disabled}
|
||||||
|
loading={loading}
|
||||||
|
role="menuitem"
|
||||||
|
linkProps={{ className: "w-full no-underline!" }}
|
||||||
|
className={twMerge(
|
||||||
|
"w-full justify-start px-2.5! py-1.5! rounded-default-sm font-normal",
|
||||||
|
"hover:bg-gray! dark:hover:bg-gray-dark!",
|
||||||
|
)}
|
||||||
|
buttonContentProps={{ className: "gap-2.5 [&_svg]:opacity-70" }}
|
||||||
|
onClick={() => {
|
||||||
|
onClick?.();
|
||||||
|
onDone();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{title}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import Dropdown from "@/src/components/twui/elements/Dropdown";
|
||||||
|
import Paper from "@/src/components/twui/elements/Paper";
|
||||||
|
import Button from "@/src/components/twui/layout/Button";
|
||||||
|
import { MoreHorizontal } from "lucide-react";
|
||||||
|
import { useState, type ReactNode } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import ActionMenuItem from "./action-menu-item";
|
||||||
|
import type { ActionMenuItem as ActionMenuItemType } from "./types";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/** Accessible label of the trigger */
|
||||||
|
label: string;
|
||||||
|
items: ActionMenuItemType[];
|
||||||
|
trigger_icon?: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ActionMenu({
|
||||||
|
label,
|
||||||
|
items,
|
||||||
|
trigger_icon,
|
||||||
|
className,
|
||||||
|
}: Props) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
if (!items.length) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
position="bottom-right"
|
||||||
|
externalOpen={open}
|
||||||
|
externalSetOpen={setOpen}
|
||||||
|
className={className}
|
||||||
|
target={
|
||||||
|
<Button
|
||||||
|
title={label}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
color="gray"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={open}
|
||||||
|
className="p-1.5! rounded-default-sm hover:bg-gray! dark:hover:bg-gray-dark!"
|
||||||
|
>
|
||||||
|
{trigger_icon || <MoreHorizontal size={16} />}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Paper
|
||||||
|
role="menu"
|
||||||
|
className={twMerge(
|
||||||
|
"min-w-[180px] p-1! gap-0.5 shadow-lg rounded-default-lg",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{items.map((item) => (
|
||||||
|
<ActionMenuItem
|
||||||
|
{...item}
|
||||||
|
key={item.key}
|
||||||
|
onDone={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Paper>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export type ActionMenuItem = {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
href?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
icon?: ReactNode;
|
||||||
|
tone?: "default" | "danger";
|
||||||
|
disabled?: boolean;
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
@@ -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 { Wand2 } from "lucide-react";
|
||||||
import Button from "../twui/layout/Button";
|
import Button from "../twui/layout/Button";
|
||||||
import fetchApi from "../twui/utils/fetch/fetchApi";
|
import fetchApi from "../twui/utils/fetch/fetchApi";
|
||||||
|
import useToast from "@/src/hooks/use-toast";
|
||||||
import type { ApiReqParams } from "@/src/types";
|
import type { ApiReqParams } from "@/src/types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -10,6 +11,7 @@ type Props = {
|
|||||||
|
|
||||||
export default function GrabNextSubnetButton({ onGrab }: Props) {
|
export default function GrabNextSubnetButton({ onGrab }: Props) {
|
||||||
const [grabbing, setGrabbing] = useState(false);
|
const [grabbing, setGrabbing] = useState(false);
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
function grabNextSubnet() {
|
function grabNextSubnet() {
|
||||||
setGrabbing(true);
|
setGrabbing(true);
|
||||||
@@ -21,6 +23,8 @@ export default function GrabNextSubnetButton({ onGrab }: Props) {
|
|||||||
.then((res) => {
|
.then((res) => {
|
||||||
if (res?.success && res.msg) {
|
if (res?.success && res.msg) {
|
||||||
onGrab?.(res.msg);
|
onGrab?.(res.msg);
|
||||||
|
} else {
|
||||||
|
toast({ msg: "Couldn't find a free subnet", color: "error" });
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
@@ -30,17 +34,16 @@ export default function GrabNextSubnetButton({ onGrab }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
title="Grab Next Available Subnet"
|
title="Use the next free subnet"
|
||||||
|
type="button"
|
||||||
variant="outlined"
|
variant="outlined"
|
||||||
color="primary"
|
color="gray"
|
||||||
beforeIcon={<Wand2 size={16} />}
|
|
||||||
loading={grabbing}
|
loading={grabbing}
|
||||||
onClick={(e) => {
|
className="py-2.5"
|
||||||
e.preventDefault();
|
onClick={grabNextSubnet}
|
||||||
grabNextSubnet();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Grab Next Subnet
|
<Wand2 size={14} />
|
||||||
|
Next free subnet
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useCallback, useContext } from "react";
|
||||||
|
import { Plus, Users } from "lucide-react";
|
||||||
|
import Button from "@/src/components/twui/layout/Button";
|
||||||
|
import Panel from "./panel";
|
||||||
|
import ClientsTable from "./clients-table";
|
||||||
|
import EmptyState from "./empty-state";
|
||||||
|
import useClientRulesMap from "@/src/hooks/use-client-rules-map";
|
||||||
|
import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get";
|
||||||
|
import { AppContext } from "@/src/pages/__root";
|
||||||
|
import type { BUN_SQLITE_WGUI_CLIENTS_JOIN } from "@/src/types/sql-joins";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
host_id: number;
|
||||||
|
title?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Clients of one host. Seeds from `pageProps.clients`/`client_rules`. */
|
||||||
|
export default function HostClientsPanel({ host_id, title = "Clients" }: Props) {
|
||||||
|
const { pageProps } = useContext(AppContext);
|
||||||
|
|
||||||
|
const { res: clients, setRes } = useAdminCrudGet<BUN_SQLITE_WGUI_CLIENTS_JOIN>({
|
||||||
|
table: "clients",
|
||||||
|
sql_query: {
|
||||||
|
query: {
|
||||||
|
host_id: { value: host_id },
|
||||||
|
},
|
||||||
|
order: { field: "created_at", strategy: "DESC" },
|
||||||
|
},
|
||||||
|
initial_res: pageProps?.clients || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rules_by_client_id = useClientRulesMap(pageProps?.client_rules);
|
||||||
|
|
||||||
|
const onDeleted = useCallback(
|
||||||
|
({ id }: { id: number }) =>
|
||||||
|
setRes((prev) => prev?.filter((client) => client.id != id)),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const add_href = `/admin/clients/add?host_id=${host_id}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Panel
|
||||||
|
flush
|
||||||
|
title={title}
|
||||||
|
description={clients ? `${clients.length} on this host` : undefined}
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
title="Add client"
|
||||||
|
href={add_href}
|
||||||
|
variant="outlined"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
Add client
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ClientsTable
|
||||||
|
clients={clients}
|
||||||
|
rules_by_client_id={rules_by_client_id}
|
||||||
|
hide_host
|
||||||
|
onDeleted={onDeleted}
|
||||||
|
empty={
|
||||||
|
<EmptyState
|
||||||
|
icon={<Users size={18} />}
|
||||||
|
title="No clients on this host"
|
||||||
|
description="Clients get a tunnel IP from this host's subnet."
|
||||||
|
action={
|
||||||
|
<Button title="Add client" href={add_href} size="small">
|
||||||
|
Add client
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,22 +1,16 @@
|
|||||||
import { useEffect, useState, type RefObject } from "react";
|
import { useEffect, useState, type RefObject } from "react";
|
||||||
import { CircleCheck, Loader2, Network, TriangleAlert } from "lucide-react";
|
import { Network } from "lucide-react";
|
||||||
import Input from "../twui/form/Input";
|
import Input from "../twui/form/Input";
|
||||||
import Row from "../twui/layout/Row";
|
import Row from "../twui/layout/Row";
|
||||||
import Span from "../twui/layout/Span";
|
|
||||||
import Tag from "../twui/elements/Tag";
|
|
||||||
import Stack from "../twui/layout/Stack";
|
import Stack from "../twui/layout/Stack";
|
||||||
import fetchApi from "../twui/utils/fetch/fetchApi";
|
import fetchApi from "../twui/utils/fetch/fetchApi";
|
||||||
import type { ApiReqParams } from "@/src/types";
|
import type { ApiReqParams } from "@/src/types";
|
||||||
import GrabNextSubnetButton from "./grab-next-subnet-button";
|
import GrabNextSubnetButton from "./grab-next-subnet-button";
|
||||||
|
import HostWgIpStatus, { type HostWgIpFieldStatus } from "./host-wg-ip-status";
|
||||||
import type useFormInit from "@/src/hooks/use-form-init";
|
import type useFormInit from "@/src/hooks/use-form-init";
|
||||||
import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db";
|
import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db";
|
||||||
|
|
||||||
export type HostWgIpFieldStatus =
|
export type { HostWgIpFieldStatus };
|
||||||
| "checking"
|
|
||||||
| "available"
|
|
||||||
| "not_available"
|
|
||||||
| "invalid"
|
|
||||||
| "current";
|
|
||||||
|
|
||||||
type Props = ReturnType<typeof useFormInit<BUN_SQLITE_WGUI_HOSTS>> & {
|
type Props = ReturnType<typeof useFormInit<BUN_SQLITE_WGUI_HOSTS>> & {
|
||||||
value: string;
|
value: string;
|
||||||
@@ -43,6 +37,7 @@ export default function HostWgIpField({
|
|||||||
setForm,
|
setForm,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [availability, setAvailability] = useState<AvailabilityType>();
|
const [availability, setAvailability] = useState<AvailabilityType>();
|
||||||
|
const [field_key, setFieldKey] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAvailability(undefined);
|
setAvailability(undefined);
|
||||||
@@ -85,91 +80,34 @@ export default function HostWgIpField({
|
|||||||
}, [ip_status, onStatus]);
|
}, [ip_status, onStatus]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack className="w-full gap-2">
|
<Stack className="w-full gap-1.5 items-stretch">
|
||||||
<Row className="w-full flex-nowrap">
|
<Row className="w-full gap-2 items-end flex-wrap sm:flex-nowrap">
|
||||||
<Input
|
<Input
|
||||||
|
key={field_key}
|
||||||
|
name="wg_ip_address"
|
||||||
defaultValue={value}
|
defaultValue={value}
|
||||||
changeHandler={(v) => {
|
changeHandler={(v) => {
|
||||||
onChange?.(v);
|
onChange?.(v);
|
||||||
}}
|
}}
|
||||||
prefix={<Network size={17} opacity={0.5} />}
|
prefix={<Network size={15} className="text-subtle shrink-0" />}
|
||||||
suffix={
|
title="Host tunnel address"
|
||||||
<Span className="text-sm opacity-50 whitespace-nowrap">
|
placeholder="e.g. 10.8.0.1"
|
||||||
WireGuard IP Address
|
|
||||||
</Span>
|
|
||||||
}
|
|
||||||
title="Wireguard IP Address"
|
|
||||||
showLabel
|
showLabel
|
||||||
componentRef={inputRef}
|
componentRef={inputRef}
|
||||||
autoFocus={autoFocus}
|
autoFocus={autoFocus}
|
||||||
|
wrapperWrapperProps={{ className: "grow" }}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<GrabNextSubnetButton
|
<GrabNextSubnetButton
|
||||||
onGrab={(ip) => {
|
onGrab={(ip) => {
|
||||||
setForm((prev) => ({
|
setForm((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
wg_ip_address: ip,
|
wg_ip_address: ip,
|
||||||
}));
|
}));
|
||||||
|
setFieldKey((k) => k + 1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Row>
|
</Row>
|
||||||
|
<HostWgIpStatus status={ip_status} />
|
||||||
{ip_status === "invalid" ? (
|
|
||||||
<Tag
|
|
||||||
variant="outlined"
|
|
||||||
color="error"
|
|
||||||
className="w-full py-1.5 outline-error/50 bg-error/5!"
|
|
||||||
>
|
|
||||||
<Row>
|
|
||||||
<TriangleAlert size={15} />
|
|
||||||
<Span>Invalid IP</Span>
|
|
||||||
</Row>
|
|
||||||
</Tag>
|
|
||||||
) : ip_status === "current" ? (
|
|
||||||
<Tag
|
|
||||||
variant="outlined"
|
|
||||||
color="gray"
|
|
||||||
className="w-full py-1.5 outline-gray/50 bg-gray/5!"
|
|
||||||
>
|
|
||||||
<Row>
|
|
||||||
<CircleCheck size={15} />
|
|
||||||
<Span>Current value</Span>
|
|
||||||
</Row>
|
|
||||||
</Tag>
|
|
||||||
) : ip_status === "not_available" ? (
|
|
||||||
<Tag
|
|
||||||
variant="outlined"
|
|
||||||
color="error"
|
|
||||||
className="w-full py-1.5 outline-error/50 bg-error/5!"
|
|
||||||
>
|
|
||||||
<Row>
|
|
||||||
<TriangleAlert size={15} />
|
|
||||||
<Span>Not available</Span>
|
|
||||||
</Row>
|
|
||||||
</Tag>
|
|
||||||
) : ip_status === "available" ? (
|
|
||||||
<Tag
|
|
||||||
variant="outlined"
|
|
||||||
color="success"
|
|
||||||
className="w-full py-1.5 outline-success/50 bg-success/5!"
|
|
||||||
>
|
|
||||||
<Row>
|
|
||||||
<CircleCheck size={15} />
|
|
||||||
<Span>Available</Span>
|
|
||||||
</Row>
|
|
||||||
</Tag>
|
|
||||||
) : (
|
|
||||||
<Tag
|
|
||||||
variant="outlined"
|
|
||||||
color="gray"
|
|
||||||
className="w-full py-1.5 outline-gray/50 bg-gray/5!"
|
|
||||||
>
|
|
||||||
<Row>
|
|
||||||
<Loader2 size={15} className="animate-spin" />
|
|
||||||
<Span>Checking availability…</Span>
|
|
||||||
</Row>
|
|
||||||
</Tag>
|
|
||||||
)}
|
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import StatusDot, { type StatusTone } from "./status-dot";
|
||||||
|
|
||||||
|
export type HostWgIpFieldStatus =
|
||||||
|
| "checking"
|
||||||
|
| "available"
|
||||||
|
| "not_available"
|
||||||
|
| "invalid"
|
||||||
|
| "current";
|
||||||
|
|
||||||
|
const StatusDisplay: Record<HostWgIpFieldStatus, { tone: StatusTone; label: string }> = {
|
||||||
|
checking: { tone: "neutral", label: "Checking availability…" },
|
||||||
|
available: { tone: "success", label: "Subnet available" },
|
||||||
|
not_available: { tone: "error", label: "Subnet already used by another host" },
|
||||||
|
invalid: { tone: "error", label: "Use a host address ending in .1, e.g. 10.8.0.1" },
|
||||||
|
current: { tone: "neutral", label: "Current value" },
|
||||||
|
};
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
status: HostWgIpFieldStatus;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function HostWgIpStatus({ status }: Props) {
|
||||||
|
const { tone, label } = StatusDisplay[status];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div aria-live="polite">
|
||||||
|
<StatusDot tone={tone} label={label} pulse={status == "checking"} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 type { ComponentProps } from "react";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
import Img from "../twui/layout/Img";
|
import Img from "../twui/layout/Img";
|
||||||
|
import Span from "../twui/layout/Span";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
icon_size?: number;
|
icon_size?: number;
|
||||||
img_props?: Omit<ComponentProps<typeof Img>, "alt">;
|
img_props?: Omit<ComponentProps<typeof Img>, "alt">;
|
||||||
white?: boolean;
|
className?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LogoIcon({ icon_size = 45, img_props, white }: Props) {
|
/** Swaps to the white mark in dark mode. */
|
||||||
|
export default function LogoIcon({ icon_size = 32, img_props, className }: Props) {
|
||||||
return (
|
return (
|
||||||
<Img
|
<Span className={twMerge("inline-flex shrink-0", className)}>
|
||||||
size={icon_size}
|
<Img
|
||||||
{...img_props}
|
size={icon_size}
|
||||||
alt="Logo Icon"
|
{...img_props}
|
||||||
src={
|
alt="wg-ui"
|
||||||
white ? "/logos/wgui-logo-white.webp" : "/logos/wgui-logo.webp"
|
src="/logos/wgui-logo-red.png"
|
||||||
}
|
className={twMerge("dark:hidden", img_props?.className)}
|
||||||
/>
|
/>
|
||||||
|
<Img
|
||||||
|
size={icon_size}
|
||||||
|
{...img_props}
|
||||||
|
alt=""
|
||||||
|
aria-hidden
|
||||||
|
src="/logos/wgui-logo-white.webp"
|
||||||
|
className={twMerge("hidden dark:block", img_props?.className)}
|
||||||
|
/>
|
||||||
|
</Span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,24 @@
|
|||||||
|
import Link from "../twui/layout/Link";
|
||||||
import Row from "../twui/layout/Row";
|
import Row from "../twui/layout/Row";
|
||||||
import Span from "../twui/layout/Span";
|
import Span from "../twui/layout/Span";
|
||||||
import type { ComponentProps, PropsWithChildren } from "react";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import Stack from "../twui/layout/Stack";
|
|
||||||
import LogoIcon from "./logo-icon";
|
import LogoIcon from "./logo-icon";
|
||||||
import { SiteData } from "@/src/data/site-data";
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
icon_size?: number;
|
icon_size?: number;
|
||||||
text_wrapper_props?: ComponentProps<typeof Stack>;
|
href?: string;
|
||||||
text_props?: ComponentProps<typeof Span>;
|
className?: string;
|
||||||
white?: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Logo({
|
export default function Logo({ icon_size = 28, href = "/admin", className }: Props) {
|
||||||
icon_size = 45,
|
|
||||||
text_props,
|
|
||||||
text_wrapper_props,
|
|
||||||
white,
|
|
||||||
}: Props) {
|
|
||||||
return (
|
return (
|
||||||
<a href="/" className="logo-link">
|
<Link href={href} className={twMerge("no-underline! text-fg!", className)}>
|
||||||
<Row className="gap-2! flex-nowrap">
|
<Row className="gap-2 flex-nowrap">
|
||||||
<LogoIcon icon_size={icon_size} white={white} />
|
<LogoIcon icon_size={icon_size} />
|
||||||
<Stack
|
<Span className="text-lg font-semibold tracking-tight whitespace-nowrap">
|
||||||
{...text_wrapper_props}
|
wg-ui
|
||||||
className={twMerge("gap-1!", text_wrapper_props?.className)}
|
</Span>
|
||||||
>
|
|
||||||
<Stack className="gap-0!">
|
|
||||||
<HeroText text_props={text_props}>wg-ui</HeroText>
|
|
||||||
</Stack>
|
|
||||||
</Stack>
|
|
||||||
</Row>
|
</Row>
|
||||||
</a>
|
</Link>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HeroText({
|
|
||||||
text_props,
|
|
||||||
children,
|
|
||||||
}: PropsWithChildren & {
|
|
||||||
text_props?: ComponentProps<typeof Span>;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Span
|
|
||||||
{...text_props}
|
|
||||||
className={twMerge(
|
|
||||||
"text-xl text-dark font-serif whitespace-nowrap",
|
|
||||||
"heading leading-[24px]",
|
|
||||||
text_props?.className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Span>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
import React, { type ComponentProps, type ReactNode } from "react";
|
|
||||||
import Section from "@/src/components/twui/layout/Section";
|
|
||||||
import Container from "@/src/components/twui/layout/Container";
|
|
||||||
import Stack from "@/src/components/twui/layout/Stack";
|
|
||||||
import H1 from "@/src/components/twui/layout/H1";
|
|
||||||
import Row from "@/src/components/twui/layout/Row";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
import Span from "@/src/components/twui/layout/Span";
|
|
||||||
import BlurredImageBG from "@/src/components/general/blured-image-bg";
|
|
||||||
import ImageBG from "@/src/components/general/image-bg";
|
|
||||||
import type Img from "../twui/layout/Img";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
title: string;
|
|
||||||
image_url?: string;
|
|
||||||
sub_title?: string;
|
|
||||||
cta_buttons?: ReactNode;
|
|
||||||
img_props?: Omit<ComponentProps<typeof Img>, "alt">;
|
|
||||||
img_component?: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function MainHeroSection({
|
|
||||||
title,
|
|
||||||
cta_buttons,
|
|
||||||
sub_title,
|
|
||||||
image_url,
|
|
||||||
img_props,
|
|
||||||
img_component,
|
|
||||||
}: Props) {
|
|
||||||
const img_comp = img_component ? (
|
|
||||||
img_component
|
|
||||||
) : image_url ? (
|
|
||||||
<ImageBG src={image_url} img_props={img_props} />
|
|
||||||
) : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<React.Fragment>
|
|
||||||
<Section className={twMerge("relative")}>
|
|
||||||
<Container>
|
|
||||||
<Row className="grid xl:grid-cols-2 relative z-10 max-w-xl xl:max-w-none lg:pb-20 pt-60 lg:pt-20">
|
|
||||||
<Stack className="w-full gap-8">
|
|
||||||
<H1
|
|
||||||
className={twMerge(
|
|
||||||
"text-center md:text-left w-full",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</H1>
|
|
||||||
{sub_title ? (
|
|
||||||
<Span
|
|
||||||
className={twMerge(
|
|
||||||
"text-lg sm:text-2xl leading-snug text-center md:text-left",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{sub_title}
|
|
||||||
</Span>
|
|
||||||
) : null}
|
|
||||||
{cta_buttons}
|
|
||||||
</Stack>
|
|
||||||
{/* <Stack className="relative w-full xl:w-[700px] xl:h-full overflow-visible"></Stack> */}
|
|
||||||
</Row>
|
|
||||||
</Container>
|
|
||||||
</Section>
|
|
||||||
</React.Fragment>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 Form from "@/src/components/twui/form/Form";
|
||||||
import LoadingOverlay from "@/src/components/twui/elements/LoadingOverlay";
|
|
||||||
import useFormInit from "@/src/hooks/use-form-init";
|
import useFormInit from "@/src/hooks/use-form-init";
|
||||||
|
import useToast from "@/src/hooks/use-toast";
|
||||||
|
import type { UserFormObject } from "@/src/types";
|
||||||
|
import type { BUN_SQLITE_WGUI_USERS_JOIN } from "@/src/types/sql-joins";
|
||||||
|
import InlineAlert from "../inline-alert";
|
||||||
import UserFormName from "./user-form-name";
|
import UserFormName from "./user-form-name";
|
||||||
import Tag from "@/src/components/twui/elements/Tag";
|
|
||||||
import UserFormAction from "./user-form-action";
|
import UserFormAction from "./user-form-action";
|
||||||
import UserFormEmail from "./user-form-email";
|
import UserFormEmail from "./user-form-email";
|
||||||
import UserFormUsername from "./user-form-username";
|
import UserFormUsername from "./user-form-username";
|
||||||
import type { UserFormObject } from "@/src/types";
|
|
||||||
import submitUserForm from "./submit-user-form";
|
|
||||||
import UserFormPassword from "./user-form-password";
|
import UserFormPassword from "./user-form-password";
|
||||||
import UserFormImage from "./user-form-image";
|
import UserFormImage from "./user-form-image";
|
||||||
import UserFormBio from "./user-form-bio";
|
import UserFormBio from "./user-form-bio";
|
||||||
import type { BUN_SQLITE_WGUI_USERS_JOIN } from "@/src/types/sql-joins";
|
import submitUserForm from "./submit-user-form";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
existing_user?: BUN_SQLITE_WGUI_USERS_JOIN;
|
existing_user?: BUN_SQLITE_WGUI_USERS_JOIN;
|
||||||
@@ -19,44 +20,49 @@ type Props = {
|
|||||||
is_first_user?: boolean;
|
is_first_user?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function UserForm({
|
export default function UserForm({ existing_user, existing_user_full, is_first_user }: Props) {
|
||||||
existing_user,
|
const { toast } = useToast();
|
||||||
existing_user_full,
|
|
||||||
is_first_user,
|
|
||||||
}: Props) {
|
|
||||||
const init = useFormInit<UserFormObject>({
|
const init = useFormInit<UserFormObject>({
|
||||||
existing: existing_user,
|
existing: existing_user,
|
||||||
existing_full: existing_user_full,
|
existing_full: existing_user_full,
|
||||||
is_first_user,
|
is_first_user,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { loading, status } = init;
|
const [confirm_password, setConfirmPassword] = useState("");
|
||||||
|
|
||||||
|
const { status, form } = init;
|
||||||
|
const needs_password = !existing_user_full?.id;
|
||||||
|
const passwords_match = !needs_password || (form.password || "") === confirm_password;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<Form
|
||||||
<Form
|
className="w-full gap-5"
|
||||||
className="w-full gap-6"
|
onSubmit={() => {
|
||||||
onSubmit={() => {
|
if (!passwords_match) return;
|
||||||
submitUserForm(init);
|
submitUserForm({ init, toast });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{status?.error && <Tag color="error">{status.msg}</Tag>}
|
{status?.error ? <InlineAlert tone="error" msg={status.msg} /> : null}
|
||||||
{loading && <LoadingOverlay />}
|
|
||||||
|
|
||||||
<UserFormName {...init} />
|
<UserFormName {...init} />
|
||||||
<UserFormEmail {...init} />
|
<UserFormEmail {...init} />
|
||||||
<UserFormUsername {...init} />
|
<UserFormUsername {...init} />
|
||||||
<UserFormPassword {...init} />
|
<UserFormPassword
|
||||||
{/* <UserFormUserTypes {...init} /> */}
|
{...init}
|
||||||
|
confirm_password={confirm_password}
|
||||||
|
setConfirmPassword={setConfirmPassword}
|
||||||
|
passwords_match={passwords_match}
|
||||||
|
/>
|
||||||
|
|
||||||
{is_first_user ? null : (
|
{is_first_user ? null : (
|
||||||
<>
|
<>
|
||||||
<UserFormBio {...init} />
|
<UserFormBio {...init} />
|
||||||
<UserFormImage {...init} />
|
<UserFormImage {...init} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<UserFormAction {...init} />
|
|
||||||
</Form>
|
<UserFormAction {...init} passwords_match={passwords_match} />
|
||||||
</>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,24 +11,17 @@ import _ from "lodash";
|
|||||||
import fetchApi from "../../twui/utils/fetch/fetchApi";
|
import fetchApi from "../../twui/utils/fetch/fetchApi";
|
||||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
export default async function submitUserForm({
|
import type { ToastContextValue } from "../toast/toast-context";
|
||||||
setLoading,
|
|
||||||
setStatus,
|
type Params = {
|
||||||
form,
|
init: ReturnType<typeof useFormInit<UserFormObject>>;
|
||||||
existing_full,
|
toast: ToastContextValue["toast"];
|
||||||
existing,
|
};
|
||||||
image,
|
|
||||||
is_first_user,
|
export default async function submitUserForm({ init, toast }: Params) {
|
||||||
}: ReturnType<typeof useFormInit<UserFormObject>>) {
|
const { setLoading, setStatus, form, existing_full, image, is_first_user } = init;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const confirm_msg = existing_full?.id
|
|
||||||
? `Update this user?`
|
|
||||||
: `Create new user?`;
|
|
||||||
|
|
||||||
if (!window.confirm(confirm_msg)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const new_user_data: BUN_SQLITE_WGUI_USERS = _.pick<
|
const new_user_data: BUN_SQLITE_WGUI_USERS = _.pick<
|
||||||
UserFormObject,
|
UserFormObject,
|
||||||
keyof UserFormObject
|
keyof UserFormObject
|
||||||
@@ -72,8 +65,6 @@ export default async function submitUserForm({
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!delete_media_res.success) {
|
if (!delete_media_res.success) {
|
||||||
console.log("delete_media_res", delete_media_res);
|
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
delete_media_res.msg ||
|
delete_media_res.msg ||
|
||||||
delete_media_res.error ||
|
delete_media_res.error ||
|
||||||
@@ -98,16 +89,26 @@ export default async function submitUserForm({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (create_or_update_user_res.success && target_user_id) {
|
if (!create_or_update_user_res.success || !target_user_id) {
|
||||||
window.location.reload();
|
|
||||||
} else {
|
|
||||||
console.log("res", create_or_update_user_res);
|
|
||||||
console.log("form", form);
|
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
create_or_update_user_res.msg || "Person Creation Failed",
|
create_or_update_user_res.msg || "Couldn't save the account",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (is_first_user) {
|
||||||
|
window.location.href = "/auth/login";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The header avatar comes from the server-rendered user, so a new
|
||||||
|
// image needs a reload to show up.
|
||||||
|
if (image?.imageBase64Full) {
|
||||||
|
window.location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
toast({ msg: "Profile saved", color: "success" });
|
||||||
|
setLoading(false);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
setStatus({
|
setStatus({
|
||||||
error: true,
|
error: true,
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
import type { BUN_SQLITE_WGUI_USERS } from "@/db/types/db";
|
import type { BUN_SQLITE_WGUI_USERS } from "@/db/types/db";
|
||||||
import Button from "@/src/components/twui/layout/Button";
|
import FormActions from "../form-actions";
|
||||||
import useFormInit from "@/src/hooks/use-form-init";
|
import useFormInit from "@/src/hooks/use-form-init";
|
||||||
|
|
||||||
export default function UserFormAction({
|
type Props = ReturnType<typeof useFormInit<BUN_SQLITE_WGUI_USERS>> & {
|
||||||
status,
|
passwords_match: boolean;
|
||||||
existing_full,
|
};
|
||||||
}: ReturnType<typeof useFormInit<BUN_SQLITE_WGUI_USERS>>) {
|
|
||||||
const title = existing_full?.id ? "Update User" : "Create New User";
|
|
||||||
|
|
||||||
if (status?.error) {
|
export default function UserFormAction({ existing_full, loading, passwords_match, is_first_user }: Props) {
|
||||||
return null;
|
const title = existing_full?.id
|
||||||
}
|
? "Save profile"
|
||||||
|
: is_first_user
|
||||||
|
? "Create account"
|
||||||
|
: "Create user";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<FormActions
|
||||||
<Button title={title}>{title}</Button>
|
submit_title={title}
|
||||||
</>
|
loading={loading}
|
||||||
|
disabled={!passwords_match}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user