From cad1d32ae4b3f621806e4e1f93dc4091f0440672 Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Fri, 16 Oct 2026 06:29:21 +0100 Subject: [PATCH] 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 --- AGENTS.md | 15 +- public/icons/google.png | Bin 20865 -> 0 bytes public/images/logo.webp | Bin 9602 -> 0 bytes .../general/action-menu/action-menu-item.tsx | 45 ++ src/components/general/action-menu/index.tsx | 64 +++ src/components/general/action-menu/types.ts | 12 + src/components/general/admin-button.tsx | 40 -- src/components/general/admin-card.tsx | 25 -- src/components/general/admin-hero.tsx | 37 -- src/components/general/app-providers.tsx | 11 + src/components/general/auth-heading.tsx | 19 + src/components/general/blured-image-bg.tsx | 24 -- .../client-form/build-host-option-title.ts | 18 + .../client-form/client-form-allowed-ips.tsx | 41 ++ .../client-form/client-form-host-select.tsx | 26 ++ .../general/client-form/client-form-name.tsx | 19 + .../general/client-form/client-form-notes.tsx | 19 + .../general/client-form/client-form-types.ts | 4 + .../general/client-form/client-form-wg-ip.tsx | 88 ++++ .../grab-next-available-client-ip.ts | 0 src/components/general/client-form/index.tsx | 145 +++++++ .../general/client-form/submit-client-form.ts | 129 ++++++ src/components/general/client-row.tsx | 131 ------ .../client-rules/build-client-rule-drafts.ts | 30 ++ .../client-rule-destination-field.tsx | 0 .../client-rules/client-rule-draft-item.tsx | 16 + .../client-rules/client-rule-draft.ts | 0 .../client-rules/client-rule-fields.tsx | 37 ++ .../general/client-rules/client-rule-item.tsx | 35 ++ .../client-rules/client-rule-modal.tsx | 123 ++++++ .../client-rules/client-rule-ports-field.tsx | 0 .../client-rule-protocol-field.tsx | 0 .../client-rules/client-rule-type-field.tsx | 23 + .../client-rules/client-rules-editor.tsx | 45 ++ .../client-rules/client-rules-empty.tsx | 13 + src/components/general/clients-table.tsx | 55 --- .../clients-table/client-access-tag.tsx | 24 ++ .../clients-table/client-mobile-card.tsx | 33 ++ .../clients-table/client-name-cell.tsx | 24 ++ .../clients-table/client-row-actions.tsx | 38 ++ .../clients-table/clients-table-columns.tsx | 108 +++++ .../clients-table/derive-client-host-name.ts | 10 + .../general/clients-table/index.tsx | 57 +++ .../clients-table/use-delete-client.ts | 50 +++ .../general/confirm/confirm-context.ts | 17 + .../general/confirm/confirm-dialog.tsx | 97 +++++ .../general/confirm/confirm-provider.tsx | 32 ++ src/components/general/copy-button.tsx | 50 +++ src/components/general/copyable-value.tsx | 24 ++ .../general/data-table/data-table-cell.tsx | 22 + .../data-table/data-table-head-cell.tsx | 21 + .../data-table/data-table-mobile-list.tsx | 26 ++ .../data-table/data-table-mobile-skeleton.tsx | 11 + .../general/data-table/data-table-row.tsx | 17 + .../data-table/data-table-skeleton-cell.tsx | 20 + .../data-table/data-table-skeleton-row.tsx | 16 + src/components/general/data-table/index.tsx | 68 +++ src/components/general/data-table/types.ts | 29 ++ src/components/general/delete-host-button.tsx | 50 --- src/components/general/empty-state.tsx | 44 ++ src/components/general/form-actions.tsx | 58 +++ src/components/general/form-section.tsx | 35 ++ .../general/grab-next-subnet-button.tsx | 19 +- src/components/general/host-clients-panel.tsx | 80 ++++ src/components/general/host-wg-ip-field.tsx | 90 +--- src/components/general/host-wg-ip-status.tsx | 30 ++ src/components/general/image-bg.tsx | 23 - src/components/general/inline-alert.tsx | 50 +++ .../general/inner-page-hero-section.tsx | 39 -- src/components/general/key-value-list.tsx | 18 + src/components/general/key-value-row.tsx | 35 ++ src/components/general/login-shell.tsx | 59 --- src/components/general/logo-icon.tsx | 32 +- src/components/general/logo.tsx | 55 +-- src/components/general/main-hero-section.tsx | 66 --- src/components/general/mono-text.tsx | 21 + src/components/general/muted-text.tsx | 40 ++ src/components/general/page-body.tsx | 20 + src/components/general/page-header/index.tsx | 67 +++ .../page-header/page-header-action.tsx | 34 ++ .../page-header/page-header-actions.tsx | 37 ++ src/components/general/panel/index.tsx | 64 +++ src/components/general/panel/panel-header.tsx | 23 + src/components/general/rect-image.tsx | 42 -- src/components/general/section-label.tsx | 20 + .../general/setup-main-host-button.tsx | 93 ---- src/components/general/stat-tile.tsx | 56 +++ src/components/general/status-dot.tsx | 52 +++ src/components/general/sub-nav/index.tsx | 32 ++ .../general/sub-nav/sub-nav-item.tsx | 33 ++ src/components/general/tagline-section.tsx | 53 --- src/components/general/theme/theme-script.tsx | 11 + .../general/theme/theme-toggle-option.tsx | 38 ++ src/components/general/theme/theme-toggle.tsx | 40 ++ src/components/general/toast/toast-context.ts | 16 + .../general/toast/toast-provider.tsx | 50 +++ src/components/general/use-delete-host.ts | 53 +++ src/components/general/user-form/index.tsx | 74 ++-- .../general/user-form/submit-user-form.ts | 53 +-- .../general/user-form/user-form-action.tsx | 27 +- .../general/user-form/user-form-bio.tsx | 41 +- .../general/user-form/user-form-email.tsx | 28 +- .../general/user-form/user-form-image.tsx | 4 +- .../general/user-form/user-form-name.tsx | 28 +- .../general/user-form/user-form-password.tsx | 68 +-- .../general/user-form/user-form-username.tsx | 24 +- src/dict/main-links-dict.ts | 8 - src/dict/subsidiaries-dict.ts | 71 ---- src/dict/theme-dict.ts | 12 + .../frontend/build-rte-content-style.ts | 30 ++ src/functions/frontend/theme/apply-theme.ts | 18 + .../frontend/theme/set-theme-cookie.ts | 13 + src/hooks/use-admin-crud-get.ts | 4 + src/hooks/use-confirm.ts | 6 + src/hooks/use-is-dark-mode.ts | 20 + src/hooks/use-theme.ts | 32 ++ src/hooks/use-toast.ts | 6 + src/layouts/admin/(data)/admin-nav-dict.ts | 57 +++ src/layouts/admin/(partials)/MobileNav.tsx | 47 ++- .../(partials)/admin-aside-links-dict.tsx | 102 ----- src/layouts/admin/(partials)/admin-aside.tsx | 70 +-- .../admin/(partials)/admin-nav-group.tsx | 21 + .../admin/(partials)/admin-nav-item.tsx | 37 ++ src/layouts/admin/(partials)/admin-nav.tsx | 35 ++ .../(partials)/header-user-menu-item.tsx | 24 ++ src/layouts/admin/(partials)/header-user.tsx | 66 +++ src/layouts/admin/(sections)/Footer.tsx | 32 -- src/layouts/admin/(sections)/Header.tsx | 90 ++-- src/layouts/admin/index.tsx | 59 +-- src/layouts/centered/index.tsx | 29 ++ src/layouts/main/(partials)/MobileNav.tsx | 95 ----- src/layouts/main/(partials)/NavList.tsx | 28 -- src/layouts/main/(partials)/header-cta.tsx | 17 - src/layouts/main/(partials)/header-user.tsx | 61 --- src/layouts/main/(sections)/Footer.tsx | 74 ---- src/layouts/main/(sections)/Header.tsx | 58 --- src/layouts/main/index.tsx | 31 -- src/pages/(partials)/cta-buttons.tsx | 21 - src/pages/(partials)/hero-banner.tsx | 30 -- src/pages/(sections)/hero.tsx | 44 -- src/pages/404.tsx | 46 +- src/pages/__root.server.ts | 6 + src/pages/__root.tsx | 157 +++---- src/pages/about/index.tsx | 20 - src/pages/admin/(hooks)/use-dashboard-data.ts | 12 +- src/pages/admin/(partials)/stat-card.tsx | 80 ---- .../admin/(sections)/kpi-hero-section.tsx | 18 - .../(sections)/kpi-secondary-section.tsx | 39 -- src/pages/admin/(sections)/kpi-section.tsx | 43 ++ .../admin/(sections)/peers-table-section.tsx | 83 ++-- .../clients/(functions)/filter-clients.ts | 17 + .../clients/(partials)/client-form-action.tsx | 19 - .../(partials)/client-form-host-select.tsx | 27 -- .../clients/(partials)/client-form-modal.tsx | 47 --- .../clients/(partials)/client-form-title.tsx | 17 - .../(sections)/clients-table-section.tsx | 146 ++++--- .../(sections)/add-client-form-section.tsx | 16 + src/pages/admin/clients/add/index.server.ts | 47 +++ src/pages/admin/clients/add/index.tsx | 25 ++ src/pages/admin/clients/index.tsx | 36 +- .../(functions)/build-hosts-table-columns.tsx | 66 +++ .../admin/hosts/(hooks)/use-host-data.ts | 10 +- .../admin/hosts/(partials)/host-card.tsx | 88 ---- .../hosts/(partials)/host-mobile-card.tsx | 30 ++ .../admin/hosts/(partials)/host-name-cell.tsx | 39 ++ .../hosts/(partials)/host-row-actions.tsx | 41 ++ .../admin/hosts/(partials)/host-stat-cell.tsx | 20 - .../hosts/(partials)/interface-config-row.tsx | 20 - .../(sections)/host-public-ip-section.tsx | 127 ------ .../hosts/(sections)/hosts-table-section.tsx | 68 +++ .../(sections)/interface-config-section.tsx | 107 ----- .../(sections)/main-host-status-section.tsx | 80 ---- .../(functions)/build-interface-summary.ts | 21 + .../(hooks)/use-host-header-actions.tsx | 43 ++ .../[host_id]/(partials)/host-header-meta.tsx | 26 ++ .../(sections)/host-interface-section.tsx | 38 ++ .../(sections)/host-stats-section.tsx | 27 ++ .../client-rules/add-client-rule-modal.tsx | 105 ----- .../client-rules/client-rule-draft-item.tsx | 42 -- .../client-rules/client-rule-fields.tsx | 25 -- .../client-rules/client-rule-type-field.tsx | 45 -- .../client-rules/client-rules-editor.tsx | 76 ---- .../client-rules/client-rules-empty.tsx | 9 - .../clients/(partials)/clients-list.tsx | 60 --- .../(sections)/clients-list-section.tsx | 18 - .../(functions)/rerun-client-setup.ts | 15 + .../(hooks)/use-client-header-actions.tsx | 41 ++ .../[client_id]/(hooks)/use-client-rules.ts | 96 +++++ .../(partials)/add-client-rule-form.tsx | 73 ---- .../(partials)/client-config-panel.tsx | 43 ++ .../(partials)/client-detail-actions.tsx | 65 --- .../(partials)/client-header-meta.tsx | 29 ++ .../(partials)/client-info-row.tsx | 54 --- .../(partials)/client-qr-panel.tsx | 40 ++ .../(partials)/client-rule-row.tsx | 57 --- .../(partials)/client-saved-rule-item.tsx | 24 ++ .../(partials)/download-config-button.tsx | 41 ++ .../(sections)/client-config-section.tsx | 61 --- .../(sections)/client-config-tab.tsx | 11 + .../(sections)/client-detail-tabs.tsx | 45 ++ .../(sections)/client-info-section.tsx | 66 --- .../(sections)/client-overview-tab.tsx | 49 +++ .../(sections)/client-qr-section.tsx | 61 --- .../(sections)/client-rules-section.tsx | 87 ---- .../(sections)/client-rules-tab.tsx | 34 ++ .../(sections)/edit-client-form-section.tsx | 24 +- .../clients/[client_id]/edit/index.server.ts | 8 + .../clients/[client_id]/edit/index.tsx | 57 +-- .../[host_id]/clients/[client_id]/index.tsx | 76 ++-- .../(functions)/submit-add-client-form.ts | 135 ------ .../client-form/add-client-form-action.tsx | 20 - .../add-client-form-allowed-ips.tsx | 46 -- .../client-form/add-client-form-notes.tsx | 24 -- .../client-form/add-client-form-title.tsx | 24 -- .../add-client-form-wg-ip-address.tsx | 85 ---- .../client-form/add-client-form.tsx | 83 ---- .../(sections)/add-client-form-section.tsx | 16 - .../clients/add-client/index.server.ts | 38 +- .../[host_id]/clients/add-client/index.tsx | 31 +- .../admin/hosts/[host_id]/clients/index.tsx | 51 ++- .../admin/hosts/[host_id]/edit/index.tsx | 68 ++- .../admin/hosts/[host_id]/index.server.ts | 12 +- src/pages/admin/hosts/[host_id]/index.tsx | 99 ++--- .../add/(functions)/submit-add-host-form.ts | 34 +- .../add-host-form/add-host-form-action.tsx | 23 +- .../add-host-form-description.tsx | 27 +- .../add-host-form/add-host-form-interface.tsx | 43 +- .../add-host-form-listen-port.tsx | 77 ++-- .../add-host-form/add-host-form-name.tsx | 31 +- .../add-host-form/add-host-form-public-ip.tsx | 17 +- .../add-host-form/add-host-form-subnet.tsx | 20 - .../add-host-form-wg-ip-address.tsx | 4 +- .../add-host-form/add-host-form.tsx | 81 ++-- .../add/(sections)/add-host-form-section.tsx | 6 +- src/pages/admin/hosts/add/index.tsx | 55 +-- src/pages/admin/hosts/index.tsx | 65 +-- src/pages/admin/index.tsx | 40 +- .../settings/(data)/settings-nav-dict.ts | 7 + .../settings/(functions)/change-password.ts | 23 + .../(sections)/change-password-section.tsx | 193 ++++----- .../(sections)/settings-form-section.tsx | 57 +-- .../(sections)/settings-page-frame.tsx | 24 ++ src/pages/admin/settings/index.server.ts | 22 +- src/pages/admin/settings/index.tsx | 30 +- src/pages/admin/settings/password/index.tsx | 20 + .../admin/settings/profile/index.server.ts | 27 ++ src/pages/admin/settings/profile/index.tsx | 17 + .../admin/setup/(data)/setup-nav-dict.ts | 8 + .../(functions)/build-setup-overview.tsx | 49 +++ .../(functions)/build-setup-runtime-checks.ts | 32 ++ .../setup/(functions)/guard-setup-page.ts | 24 ++ .../setup/(partials)/firewall-rule-row.tsx | 82 +--- .../setup/(partials)/setup-action-card.tsx | 114 ----- .../setup/(partials)/setup-action-row.tsx | 105 +++++ .../setup/(partials)/setup-check-group.tsx | 19 + .../(partials)/setup-check-status-item.tsx | 58 +-- .../admin/setup/(partials)/setup-console.tsx | 22 +- .../setup/(partials)/setup-stat-cell.tsx | 39 -- .../(partials)/setup-update-wg-ui-action.tsx | 100 ++--- .../setup/(sections)/firewall-section.tsx | 179 ++++---- .../(sections)/setup-actions-section.tsx | 75 ++-- .../setup/(sections)/setup-page-frame.tsx | 36 ++ .../(sections)/system-status-section.tsx | 206 ++------- .../admin/setup/firewall/index.server.ts | 16 + src/pages/admin/setup/firewall/index.tsx | 20 + src/pages/admin/setup/index.server.ts | 33 +- src/pages/admin/setup/index.tsx | 41 +- .../admin/setup/maintenance/index.server.ts | 20 + src/pages/admin/setup/maintenance/index.tsx | 27 ++ src/pages/admin/setup/status/index.server.ts | 20 + src/pages/admin/setup/status/index.tsx | 29 ++ src/pages/auth/index.tsx | 26 +- .../login/(functions)/submit-login-form.ts | 4 +- .../login/(partials)/login-form/index.tsx | 32 +- .../login-form/login-form-action.tsx | 15 +- .../login-form/login-form-email-username.tsx | 5 +- .../login-form/login-form-password.tsx | 5 +- src/pages/auth/login/(sections)/hero.tsx | 22 - .../login/(sections)/login-form-section.tsx | 12 +- src/pages/auth/login/index.tsx | 15 +- src/pages/auth/logout/index.tsx | 25 +- src/pages/auth/signup/index.tsx | 18 +- src/pages/index.server.tsx | 8 +- src/pages/index.tsx | 28 +- .../privacy/(sections)/privacy-section.tsx | 245 ----------- src/pages/privacy/index.tsx | 19 - src/pages/terms/(sections)/terms-section.tsx | 338 --------------- src/pages/terms/index.tsx | 22 - src/styles/main.css | 399 +++++++++++------- src/types/index.ts | 27 +- src/utils/derive-host-display-name.ts | 9 + 291 files changed, 6436 insertions(+), 6612 deletions(-) delete mode 100644 public/icons/google.png delete mode 100644 public/images/logo.webp create mode 100644 src/components/general/action-menu/action-menu-item.tsx create mode 100644 src/components/general/action-menu/index.tsx create mode 100644 src/components/general/action-menu/types.ts delete mode 100644 src/components/general/admin-button.tsx delete mode 100644 src/components/general/admin-card.tsx delete mode 100644 src/components/general/admin-hero.tsx create mode 100644 src/components/general/app-providers.tsx create mode 100644 src/components/general/auth-heading.tsx delete mode 100755 src/components/general/blured-image-bg.tsx create mode 100644 src/components/general/client-form/build-host-option-title.ts create mode 100644 src/components/general/client-form/client-form-allowed-ips.tsx create mode 100644 src/components/general/client-form/client-form-host-select.tsx create mode 100644 src/components/general/client-form/client-form-name.tsx create mode 100644 src/components/general/client-form/client-form-notes.tsx create mode 100644 src/components/general/client-form/client-form-types.ts create mode 100644 src/components/general/client-form/client-form-wg-ip.tsx rename src/{pages/admin/hosts/[host_id]/clients/add-client/(functions) => components/general/client-form}/grab-next-available-client-ip.ts (100%) create mode 100644 src/components/general/client-form/index.tsx create mode 100644 src/components/general/client-form/submit-client-form.ts delete mode 100644 src/components/general/client-row.tsx create mode 100644 src/components/general/client-rules/build-client-rule-drafts.ts rename src/{pages/admin/hosts/[host_id]/clients/(partials) => components/general}/client-rules/client-rule-destination-field.tsx (100%) create mode 100644 src/components/general/client-rules/client-rule-draft-item.tsx rename src/{pages/admin/hosts/[host_id]/clients/(partials) => components/general}/client-rules/client-rule-draft.ts (100%) create mode 100644 src/components/general/client-rules/client-rule-fields.tsx create mode 100644 src/components/general/client-rules/client-rule-item.tsx create mode 100644 src/components/general/client-rules/client-rule-modal.tsx rename src/{pages/admin/hosts/[host_id]/clients/(partials) => components/general}/client-rules/client-rule-ports-field.tsx (100%) rename src/{pages/admin/hosts/[host_id]/clients/(partials) => components/general}/client-rules/client-rule-protocol-field.tsx (100%) create mode 100644 src/components/general/client-rules/client-rule-type-field.tsx create mode 100644 src/components/general/client-rules/client-rules-editor.tsx create mode 100644 src/components/general/client-rules/client-rules-empty.tsx delete mode 100644 src/components/general/clients-table.tsx create mode 100644 src/components/general/clients-table/client-access-tag.tsx create mode 100644 src/components/general/clients-table/client-mobile-card.tsx create mode 100644 src/components/general/clients-table/client-name-cell.tsx create mode 100644 src/components/general/clients-table/client-row-actions.tsx create mode 100644 src/components/general/clients-table/clients-table-columns.tsx create mode 100644 src/components/general/clients-table/derive-client-host-name.ts create mode 100644 src/components/general/clients-table/index.tsx create mode 100644 src/components/general/clients-table/use-delete-client.ts create mode 100644 src/components/general/confirm/confirm-context.ts create mode 100644 src/components/general/confirm/confirm-dialog.tsx create mode 100644 src/components/general/confirm/confirm-provider.tsx create mode 100644 src/components/general/copy-button.tsx create mode 100644 src/components/general/copyable-value.tsx create mode 100644 src/components/general/data-table/data-table-cell.tsx create mode 100644 src/components/general/data-table/data-table-head-cell.tsx create mode 100644 src/components/general/data-table/data-table-mobile-list.tsx create mode 100644 src/components/general/data-table/data-table-mobile-skeleton.tsx create mode 100644 src/components/general/data-table/data-table-row.tsx create mode 100644 src/components/general/data-table/data-table-skeleton-cell.tsx create mode 100644 src/components/general/data-table/data-table-skeleton-row.tsx create mode 100644 src/components/general/data-table/index.tsx create mode 100644 src/components/general/data-table/types.ts delete mode 100644 src/components/general/delete-host-button.tsx create mode 100644 src/components/general/empty-state.tsx create mode 100644 src/components/general/form-actions.tsx create mode 100644 src/components/general/form-section.tsx create mode 100644 src/components/general/host-clients-panel.tsx create mode 100644 src/components/general/host-wg-ip-status.tsx delete mode 100755 src/components/general/image-bg.tsx create mode 100644 src/components/general/inline-alert.tsx delete mode 100644 src/components/general/inner-page-hero-section.tsx create mode 100644 src/components/general/key-value-list.tsx create mode 100644 src/components/general/key-value-row.tsx delete mode 100644 src/components/general/login-shell.tsx delete mode 100755 src/components/general/main-hero-section.tsx create mode 100644 src/components/general/mono-text.tsx create mode 100644 src/components/general/muted-text.tsx create mode 100644 src/components/general/page-body.tsx create mode 100644 src/components/general/page-header/index.tsx create mode 100644 src/components/general/page-header/page-header-action.tsx create mode 100644 src/components/general/page-header/page-header-actions.tsx create mode 100644 src/components/general/panel/index.tsx create mode 100644 src/components/general/panel/panel-header.tsx delete mode 100755 src/components/general/rect-image.tsx create mode 100644 src/components/general/section-label.tsx delete mode 100755 src/components/general/setup-main-host-button.tsx create mode 100644 src/components/general/stat-tile.tsx create mode 100644 src/components/general/status-dot.tsx create mode 100644 src/components/general/sub-nav/index.tsx create mode 100644 src/components/general/sub-nav/sub-nav-item.tsx delete mode 100755 src/components/general/tagline-section.tsx create mode 100644 src/components/general/theme/theme-script.tsx create mode 100644 src/components/general/theme/theme-toggle-option.tsx create mode 100644 src/components/general/theme/theme-toggle.tsx create mode 100644 src/components/general/toast/toast-context.ts create mode 100644 src/components/general/toast/toast-provider.tsx create mode 100644 src/components/general/use-delete-host.ts delete mode 100644 src/dict/main-links-dict.ts delete mode 100644 src/dict/subsidiaries-dict.ts create mode 100644 src/dict/theme-dict.ts create mode 100644 src/functions/frontend/build-rte-content-style.ts create mode 100644 src/functions/frontend/theme/apply-theme.ts create mode 100644 src/functions/frontend/theme/set-theme-cookie.ts create mode 100644 src/hooks/use-confirm.ts create mode 100644 src/hooks/use-is-dark-mode.ts create mode 100644 src/hooks/use-theme.ts create mode 100644 src/hooks/use-toast.ts create mode 100644 src/layouts/admin/(data)/admin-nav-dict.ts delete mode 100644 src/layouts/admin/(partials)/admin-aside-links-dict.tsx create mode 100644 src/layouts/admin/(partials)/admin-nav-group.tsx create mode 100644 src/layouts/admin/(partials)/admin-nav-item.tsx create mode 100644 src/layouts/admin/(partials)/admin-nav.tsx create mode 100644 src/layouts/admin/(partials)/header-user-menu-item.tsx create mode 100644 src/layouts/admin/(partials)/header-user.tsx delete mode 100755 src/layouts/admin/(sections)/Footer.tsx create mode 100644 src/layouts/centered/index.tsx delete mode 100644 src/layouts/main/(partials)/MobileNav.tsx delete mode 100644 src/layouts/main/(partials)/NavList.tsx delete mode 100644 src/layouts/main/(partials)/header-cta.tsx delete mode 100644 src/layouts/main/(partials)/header-user.tsx delete mode 100755 src/layouts/main/(sections)/Footer.tsx delete mode 100755 src/layouts/main/(sections)/Header.tsx delete mode 100644 src/layouts/main/index.tsx delete mode 100755 src/pages/(partials)/cta-buttons.tsx delete mode 100755 src/pages/(partials)/hero-banner.tsx delete mode 100755 src/pages/(sections)/hero.tsx delete mode 100644 src/pages/about/index.tsx delete mode 100644 src/pages/admin/(partials)/stat-card.tsx delete mode 100644 src/pages/admin/(sections)/kpi-hero-section.tsx delete mode 100644 src/pages/admin/(sections)/kpi-secondary-section.tsx create mode 100644 src/pages/admin/(sections)/kpi-section.tsx create mode 100644 src/pages/admin/clients/(functions)/filter-clients.ts delete mode 100644 src/pages/admin/clients/(partials)/client-form-action.tsx delete mode 100644 src/pages/admin/clients/(partials)/client-form-host-select.tsx delete mode 100644 src/pages/admin/clients/(partials)/client-form-modal.tsx delete mode 100644 src/pages/admin/clients/(partials)/client-form-title.tsx create mode 100644 src/pages/admin/clients/add/(sections)/add-client-form-section.tsx create mode 100644 src/pages/admin/clients/add/index.server.ts create mode 100644 src/pages/admin/clients/add/index.tsx create mode 100644 src/pages/admin/hosts/(functions)/build-hosts-table-columns.tsx delete mode 100644 src/pages/admin/hosts/(partials)/host-card.tsx create mode 100644 src/pages/admin/hosts/(partials)/host-mobile-card.tsx create mode 100644 src/pages/admin/hosts/(partials)/host-name-cell.tsx create mode 100644 src/pages/admin/hosts/(partials)/host-row-actions.tsx delete mode 100644 src/pages/admin/hosts/(partials)/host-stat-cell.tsx delete mode 100644 src/pages/admin/hosts/(partials)/interface-config-row.tsx delete mode 100644 src/pages/admin/hosts/(sections)/host-public-ip-section.tsx create mode 100644 src/pages/admin/hosts/(sections)/hosts-table-section.tsx delete mode 100644 src/pages/admin/hosts/(sections)/interface-config-section.tsx delete mode 100644 src/pages/admin/hosts/(sections)/main-host-status-section.tsx create mode 100644 src/pages/admin/hosts/[host_id]/(functions)/build-interface-summary.ts create mode 100644 src/pages/admin/hosts/[host_id]/(hooks)/use-host-header-actions.tsx create mode 100644 src/pages/admin/hosts/[host_id]/(partials)/host-header-meta.tsx create mode 100644 src/pages/admin/hosts/[host_id]/(sections)/host-interface-section.tsx create mode 100644 src/pages/admin/hosts/[host_id]/(sections)/host-stats-section.tsx delete mode 100644 src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/add-client-rule-modal.tsx delete mode 100644 src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-draft-item.tsx delete mode 100644 src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-fields.tsx delete mode 100644 src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-type-field.tsx delete mode 100644 src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rules-editor.tsx delete mode 100644 src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rules-empty.tsx delete mode 100644 src/pages/admin/hosts/[host_id]/clients/(partials)/clients-list.tsx delete mode 100644 src/pages/admin/hosts/[host_id]/clients/(sections)/clients-list-section.tsx create mode 100644 src/pages/admin/hosts/[host_id]/clients/[client_id]/(functions)/rerun-client-setup.ts create mode 100644 src/pages/admin/hosts/[host_id]/clients/[client_id]/(hooks)/use-client-header-actions.tsx create mode 100644 src/pages/admin/hosts/[host_id]/clients/[client_id]/(hooks)/use-client-rules.ts delete mode 100644 src/pages/admin/hosts/[host_id]/clients/[client_id]/(partials)/add-client-rule-form.tsx create mode 100644 src/pages/admin/hosts/[host_id]/clients/[client_id]/(partials)/client-config-panel.tsx delete mode 100644 src/pages/admin/hosts/[host_id]/clients/[client_id]/(partials)/client-detail-actions.tsx create mode 100644 src/pages/admin/hosts/[host_id]/clients/[client_id]/(partials)/client-header-meta.tsx delete mode 100644 src/pages/admin/hosts/[host_id]/clients/[client_id]/(partials)/client-info-row.tsx create mode 100644 src/pages/admin/hosts/[host_id]/clients/[client_id]/(partials)/client-qr-panel.tsx delete mode 100644 src/pages/admin/hosts/[host_id]/clients/[client_id]/(partials)/client-rule-row.tsx create mode 100644 src/pages/admin/hosts/[host_id]/clients/[client_id]/(partials)/client-saved-rule-item.tsx create mode 100644 src/pages/admin/hosts/[host_id]/clients/[client_id]/(partials)/download-config-button.tsx delete mode 100644 src/pages/admin/hosts/[host_id]/clients/[client_id]/(sections)/client-config-section.tsx create mode 100644 src/pages/admin/hosts/[host_id]/clients/[client_id]/(sections)/client-config-tab.tsx create mode 100644 src/pages/admin/hosts/[host_id]/clients/[client_id]/(sections)/client-detail-tabs.tsx delete mode 100644 src/pages/admin/hosts/[host_id]/clients/[client_id]/(sections)/client-info-section.tsx create mode 100644 src/pages/admin/hosts/[host_id]/clients/[client_id]/(sections)/client-overview-tab.tsx delete mode 100644 src/pages/admin/hosts/[host_id]/clients/[client_id]/(sections)/client-qr-section.tsx delete mode 100644 src/pages/admin/hosts/[host_id]/clients/[client_id]/(sections)/client-rules-section.tsx create mode 100644 src/pages/admin/hosts/[host_id]/clients/[client_id]/(sections)/client-rules-tab.tsx delete mode 100644 src/pages/admin/hosts/[host_id]/clients/add-client/(functions)/submit-add-client-form.ts delete mode 100644 src/pages/admin/hosts/[host_id]/clients/add-client/(partials)/client-form/add-client-form-action.tsx delete mode 100644 src/pages/admin/hosts/[host_id]/clients/add-client/(partials)/client-form/add-client-form-allowed-ips.tsx delete mode 100644 src/pages/admin/hosts/[host_id]/clients/add-client/(partials)/client-form/add-client-form-notes.tsx delete mode 100644 src/pages/admin/hosts/[host_id]/clients/add-client/(partials)/client-form/add-client-form-title.tsx delete mode 100644 src/pages/admin/hosts/[host_id]/clients/add-client/(partials)/client-form/add-client-form-wg-ip-address.tsx delete mode 100644 src/pages/admin/hosts/[host_id]/clients/add-client/(partials)/client-form/add-client-form.tsx delete mode 100644 src/pages/admin/hosts/[host_id]/clients/add-client/(sections)/add-client-form-section.tsx delete mode 100644 src/pages/admin/hosts/add/(partials)/add-host-form/add-host-form-subnet.tsx create mode 100644 src/pages/admin/settings/(data)/settings-nav-dict.ts create mode 100644 src/pages/admin/settings/(functions)/change-password.ts create mode 100644 src/pages/admin/settings/(sections)/settings-page-frame.tsx create mode 100644 src/pages/admin/settings/password/index.tsx create mode 100644 src/pages/admin/settings/profile/index.server.ts create mode 100644 src/pages/admin/settings/profile/index.tsx create mode 100644 src/pages/admin/setup/(data)/setup-nav-dict.ts create mode 100644 src/pages/admin/setup/(functions)/build-setup-overview.tsx create mode 100644 src/pages/admin/setup/(functions)/build-setup-runtime-checks.ts create mode 100644 src/pages/admin/setup/(functions)/guard-setup-page.ts delete mode 100644 src/pages/admin/setup/(partials)/setup-action-card.tsx create mode 100644 src/pages/admin/setup/(partials)/setup-action-row.tsx create mode 100644 src/pages/admin/setup/(partials)/setup-check-group.tsx delete mode 100644 src/pages/admin/setup/(partials)/setup-stat-cell.tsx create mode 100644 src/pages/admin/setup/(sections)/setup-page-frame.tsx create mode 100644 src/pages/admin/setup/firewall/index.server.ts create mode 100644 src/pages/admin/setup/firewall/index.tsx create mode 100644 src/pages/admin/setup/maintenance/index.server.ts create mode 100644 src/pages/admin/setup/maintenance/index.tsx create mode 100644 src/pages/admin/setup/status/index.server.ts create mode 100644 src/pages/admin/setup/status/index.tsx delete mode 100644 src/pages/auth/login/(sections)/hero.tsx delete mode 100644 src/pages/privacy/(sections)/privacy-section.tsx delete mode 100644 src/pages/privacy/index.tsx delete mode 100644 src/pages/terms/(sections)/terms-section.tsx delete mode 100644 src/pages/terms/index.tsx create mode 100644 src/utils/derive-host-display-name.ts diff --git a/AGENTS.md b/AGENTS.md index 0aacf02..17f32bd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,8 +46,9 @@ No linter or formatter configured. Type-check and `bun test` are the verificatio ## Architecture -- `src/pages/__root.tsx` — picks the layout (`src/layouts/main` or `src/layouts/admin`, or `LoginShell` for auth pages) and provides `AppContext`. -- **Pages:** `/` + `about`, `privacy`, `terms`; `/auth/{login,logout,signup}`; `/admin` (dashboard), `/admin/setup`, `/admin/settings`, `/admin/clients`, `/admin/hosts/*` (add/edit hosts, `[host_id]/clients/*` add/edit/view clients). +- `src/pages/__root.tsx` — picks the layout (`src/layouts/admin` for `/admin*`, `src/layouts/centered` for auth, 404 and everything else), mounts `AppProviders` (toast + confirm dialog) and provides `AppContext`. Its `Head` renders `ThemeScript` so the theme applies before first paint. +- **Pages:** `/` redirects to `/admin` or `/auth/login`; `/auth/{login,logout,signup}`; `/admin` (dashboard), `/admin/setup/{status,maintenance,firewall}` (super admin only, guarded per page by `guard-setup-page`; `/admin/setup` redirects to `status`), `/admin/settings/{profile,password}` (`/admin/settings` redirects to `profile`), `/admin/clients`, `/admin/clients/add` (the only add-client form — `?host_id=` preselects the host), `/admin/hosts/*` (add/edit hosts, `[host_id]/clients` list, `[host_id]/clients/[client_id]` tabbed detail + `edit`). `[host_id]/clients/add-client` is a legacy redirect. +- **Theme:** light/dark/system, stored in the `wgui_theme` cookie (`src/dict/theme-dict.ts`). twui dark styles key off a `.dark` class on `` — toggle it only via `applyTheme`, never replace `className`. - **API:** `src/pages/api/admin/*` (host/client setup, network info, firewall status, service restart/update, media), generic CRUD at `src/pages/api/admin/crud/[[...paths]].ts` with per-table handlers in `crud/(tables)//{get,post,put,del}.ts`, plus `api/auth/login.ts` and `api/create-first-user.ts`. - **Every `/api/admin/*` route must authenticate** — nothing guards the prefix globally. Use `adminApiGuard({ req })` (`src/functions/backend/auth/admin-api-guard.ts`) or `userAuth` + `checkUserAccess`. - **Shell safety:** never interpolate request/DB values into `execSync` strings. Use `execFileSync` with an args array or `node:fs` APIs, and validate values before they reach a WireGuard config (newlines there can inject `PostUp` commands). Validators live in `src/utils/` (`validate-ipv4`, `validate-ipv4-cidr`, `validate-allowed-ips`, `validate-endpoint-host`, `validate-interface-name`). @@ -78,11 +79,17 @@ Server functions use `index.server.ts` alongside `index.tsx` (e.g. `single-docum - **`src/functions/backend/`** — Server-only: `auth/`, `db/`, `firewall/` (firewalld/ufw rules, status), `media/`, `setup/` (host/client setup, iptables script builder, network detection). - **`src/functions/frontend/`** — Client-safe: CRUD/fetch handlers, media upload/delete, client rule label formatting. - **`src/functions/server/`** — Request-level server handlers (media server). -- **`src/hooks/`** — `use-admin-crud-get`, `use-client-rules-map`, `use-fetch-get`, `use-form-init`, `use-users-get`. +- **`src/hooks/`** — `use-admin-crud-get` (with `refetch`), `use-client-rules-map`, `use-confirm`, `use-toast`, `use-theme`, `use-fetch-get`, `use-form-init`, `use-users-get`. - **`src/utils/`** — Cookies, crypto, dir names, IP/CIDR/port/interface validators, WireGuard interface naming. Most have `.test.ts` files. - **`src/dict/`**, **`src/data/`** — Constants (`app-data.ts`, `site-data.ts`), user types, client rules, links. - **`src/scripts/`** — Shell scripts: `install-wg-ui.sh`, `setup-wireguard.sh`, `wg-quick-manage.sh`. -- **`src/components/general/`** — Shared app components (admin cards/buttons, clients table, host IP field, login shell, user form). +- **`src/components/general/`** — Shared app components, all built from twui. Reach for these before writing page markup: + - Page frame: `page-header` (title, meta, primary action + overflow menu), `page-body`, `panel` (the one card style), `form-section`, `form-actions` (submit is always rendered). + - Text & status: `muted-text`, `mono-text` (IPs, keys, paths), `section-label`, `status-dot`, `inline-alert`, `empty-state`, `stat-tile`, `key-value-list`, `copy-button`, `copyable-value`. + - Data: `data-table` (desktop table + mobile card list; the only place raw `
` is allowed), `clients-table`, `host-clients-panel`, `action-menu`, `sub-nav` (link tabs between sibling routes). + - Flows: `client-form` + `client-rules` (shared by add/edit client and the client detail Rules tab), `user-form`, `host-wg-ip-field`, `use-delete-host`. + - Feedback: `confirm/` + `useConfirm()` and `toast/` + `useToast()` (`src/hooks`). Never use `window.confirm`/`alert`; update lists in place instead of `location.reload()`. +- **Design tokens** live in `src/styles/main.css`: one accent, WireGuard red `#89161b` (`primary` = `secondary` = `accent`; `error` is a separate brighter red), zinc neutrals, and theme-flipping semantic colours (`bg-surface`, `bg-surface-2`, `border-line`, `text-fg`, `text-muted`, `text-subtle`) that need no `dark:` pair. ## Conventions diff --git a/public/icons/google.png b/public/icons/google.png deleted file mode 100644 index 494acede0c520f847f75982ca1f671fc6eaa889e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 20865 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 G$o~QRsPyvy diff --git a/public/images/logo.webp b/public/images/logo.webp deleted file mode 100644 index 40c81c0d3cfe9ed97d8b54b0edb565616df85bc9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9602 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 diff --git a/src/components/general/action-menu/action-menu-item.tsx b/src/components/general/action-menu/action-menu-item.tsx new file mode 100644 index 0000000..8640a56 --- /dev/null +++ b/src/components/general/action-menu/action-menu-item.tsx @@ -0,0 +1,45 @@ +import Button from "@/src/components/twui/layout/Button"; +import { twMerge } from "tailwind-merge"; +import type { ActionMenuItem as ActionMenuItemType } from "./types"; + +type Props = ActionMenuItemType & { + onDone: () => void; +}; + +export default function ActionMenuItem({ + title, + href, + onClick, + icon, + tone, + disabled, + loading, + onDone, +}: Props) { + return ( + + ); +} diff --git a/src/components/general/action-menu/index.tsx b/src/components/general/action-menu/index.tsx new file mode 100644 index 0000000..e11f939 --- /dev/null +++ b/src/components/general/action-menu/index.tsx @@ -0,0 +1,64 @@ +import Dropdown from "@/src/components/twui/elements/Dropdown"; +import Paper from "@/src/components/twui/elements/Paper"; +import Button from "@/src/components/twui/layout/Button"; +import { MoreHorizontal } from "lucide-react"; +import { useState, type ReactNode } from "react"; +import { twMerge } from "tailwind-merge"; +import ActionMenuItem from "./action-menu-item"; +import type { ActionMenuItem as ActionMenuItemType } from "./types"; + +type Props = { + /** Accessible label of the trigger */ + label: string; + items: ActionMenuItemType[]; + trigger_icon?: ReactNode; + className?: string; +}; + +export default function ActionMenu({ + label, + items, + trigger_icon, + className, +}: Props) { + const [open, setOpen] = useState(false); + + if (!items.length) return null; + + return ( + + {trigger_icon || } + + } + > + + {items.map((item) => ( + setOpen(false)} + /> + ))} + + + ); +} diff --git a/src/components/general/action-menu/types.ts b/src/components/general/action-menu/types.ts new file mode 100644 index 0000000..500efb2 --- /dev/null +++ b/src/components/general/action-menu/types.ts @@ -0,0 +1,12 @@ +import type { ReactNode } from "react"; + +export type ActionMenuItem = { + key: string; + title: string; + href?: string; + onClick?: () => void; + icon?: ReactNode; + tone?: "default" | "danger"; + disabled?: boolean; + loading?: boolean; +}; diff --git a/src/components/general/admin-button.tsx b/src/components/general/admin-button.tsx deleted file mode 100644 index 34b4f2a..0000000 --- a/src/components/general/admin-button.tsx +++ /dev/null @@ -1,40 +0,0 @@ -import type { ButtonHTMLAttributes } from "react"; -import type { LucideIcon } from "lucide-react"; -import { twMerge } from "tailwind-merge"; - -type Props = ButtonHTMLAttributes & { - variant?: "primary" | "ghost"; - Icon?: LucideIcon; -}; - -const baseClass = - "h-[30px] px-3 rounded-[5px] inline-flex items-center justify-center gap-2 " + - "text-[13px] font-medium cursor-pointer transition-colors whitespace-nowrap"; - -const variantClass = { - primary: "bg-primary text-white font-semibold hover:opacity-90", - ghost: - "text-foreground-light/70 dark:text-foreground-dark/70 " + - "hover:text-foreground-light dark:hover:text-foreground-dark " + - "border border-slate-200 dark:border-white/10 " + - "hover:border-secondary/60 dark:hover:border-secondary/60", -}; - -export default function AdminButton({ - variant = "ghost", - Icon, - className, - children, - ...props -}: Props) { - return ( - - ); -} diff --git a/src/components/general/admin-card.tsx b/src/components/general/admin-card.tsx deleted file mode 100644 index 30ede84..0000000 --- a/src/components/general/admin-card.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import type { HTMLAttributes } from "react"; -import { twMerge } from "tailwind-merge"; - -type Props = HTMLAttributes & { - tier?: "default" | "secondary"; -}; - -export default function AdminCard({ - tier = "default", - className, - ...props -}: Props) { - return ( -
- ); -} diff --git a/src/components/general/admin-hero.tsx b/src/components/general/admin-hero.tsx deleted file mode 100644 index a99cbb3..0000000 --- a/src/components/general/admin-hero.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import H1 from "@/src/components/twui/layout/H1"; -import Section from "@/src/components/twui/layout/Section"; -import { useContext, type ReactNode } from "react"; -import Row from "../twui/layout/Row"; -import Stack from "../twui/layout/Stack"; -import Divider from "../twui/layout/Divider"; -import Breadcrumbs from "../twui/elements/Breadcrumbs"; -import { AppContext } from "@/src/pages/__root"; -import Span from "../twui/layout/Span"; - -type Props = { - title: string; - description?: string | ReactNode; - buttons?: ReactNode; -}; - -export default function AdminHero({ title, description, buttons }: Props) { - return ( -
- - - -

{title}

- {description ? ( - typeof description == "string" ? ( - {description} - ) : ( - description - ) - ) : null} -
- {buttons} -
-
-
- ); -} diff --git a/src/components/general/app-providers.tsx b/src/components/general/app-providers.tsx new file mode 100644 index 0000000..7c9bba9 --- /dev/null +++ b/src/components/general/app-providers.tsx @@ -0,0 +1,11 @@ +import type { PropsWithChildren } from "react"; +import ToastProvider from "./toast/toast-provider"; +import ConfirmProvider from "./confirm/confirm-provider"; + +export default function AppProviders({ children }: PropsWithChildren) { + return ( + + {children} + + ); +} diff --git a/src/components/general/auth-heading.tsx b/src/components/general/auth-heading.tsx new file mode 100644 index 0000000..b8bc2e2 --- /dev/null +++ b/src/components/general/auth-heading.tsx @@ -0,0 +1,19 @@ +import H1 from "@/src/components/twui/layout/H1"; +import P from "@/src/components/twui/layout/P"; +import Stack from "@/src/components/twui/layout/Stack"; +import type { ReactNode } from "react"; + +type Props = { + title: string; + description?: ReactNode; +}; + +/** Title block at the top of a `CenteredShell` card. */ +export default function AuthHeading({ title, description }: Props) { + return ( + +

{title}

+ {description ?

{description}

: null} +
+ ); +} diff --git a/src/components/general/blured-image-bg.tsx b/src/components/general/blured-image-bg.tsx deleted file mode 100755 index 43eb819..0000000 --- a/src/components/general/blured-image-bg.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import Img from "@/src/components/twui/layout/Img"; -import { twMerge } from "tailwind-merge"; - -type Props = { - src: string; - opacity?: number; -}; - -export default function BlurredImageBG({ src, opacity = 0.1 }: Props) { - /** - * Render component - */ - return ( - bg image - ); -} diff --git a/src/components/general/client-form/build-host-option-title.ts b/src/components/general/client-form/build-host-option-title.ts new file mode 100644 index 0000000..dfb591f --- /dev/null +++ b/src/components/general/client-form/build-host-option-title.ts @@ -0,0 +1,18 @@ +import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db"; +import deriveHostDisplayName from "@/src/utils/derive-host-display-name"; +import deriveWireguardInterfaceName from "@/src/utils/derive-wireguard-interface-name"; + +type Params = { + host: BUN_SQLITE_WGUI_HOSTS; +}; + +/** e.g. `Main host · wgui0 · 10.8.0.1` */ +export default function buildHostOptionTitle({ host }: Params) { + return [ + deriveHostDisplayName({ host }), + deriveWireguardInterfaceName({ host_id: Number(host.id || 0) }), + host.wg_ip_address, + ] + .filter(Boolean) + .join(" · "); +} diff --git a/src/components/general/client-form/client-form-allowed-ips.tsx b/src/components/general/client-form/client-form-allowed-ips.tsx new file mode 100644 index 0000000..55c7562 --- /dev/null +++ b/src/components/general/client-form/client-form-allowed-ips.tsx @@ -0,0 +1,41 @@ +import Checkbox from "@/src/components/twui/form/Checkbox"; +import Input from "@/src/components/twui/form/Input"; +import Stack from "@/src/components/twui/layout/Stack"; +import type { ClientFormInit } from "./client-form-types"; + +type Props = ClientFormInit & { + field_key: number; +}; + +export default function ClientFormAllowedIps({ form, setForm, field_key }: Props) { + return ( + + {form.allow_all_ips == 1 ? null : ( + { + setForm((prev) => ({ ...prev, allowed_ips: e.target.value })); + }} + defaultValue={form.allowed_ips} + showLabel + /> + )} + { + setForm((prev) => ({ ...prev, allow_all_ips: value ? 1 : 0 })); + }} + info={ + + Sets AllowedIPs to 0.0.0.0/0, ::/0 + + } + /> + + ); +} diff --git a/src/components/general/client-form/client-form-host-select.tsx b/src/components/general/client-form/client-form-host-select.tsx new file mode 100644 index 0000000..be286bc --- /dev/null +++ b/src/components/general/client-form/client-form-host-select.tsx @@ -0,0 +1,26 @@ +import Select from "@/src/components/twui/form/Select"; +import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db"; +import buildHostOptionTitle from "./build-host-option-title"; + +type Props = { + hosts: BUN_SQLITE_WGUI_HOSTS[]; + host_id: number; + onChange: ({ host_id }: { host_id: number }) => void; + disabled?: boolean; +}; + +export default function ClientFormHostSelect({ hosts, host_id, onChange, disabled }: Props) { + return ( + + title="Host" + showLabel + disabled={disabled} + options={hosts.map((host) => ({ + title: buildHostOptionTitle({ host }), + value: String(host.id), + default: Number(host.id) == host_id, + }))} + changeHandler={(value) => onChange({ host_id: Number(value) })} + /> + ); +} diff --git a/src/components/general/client-form/client-form-name.tsx b/src/components/general/client-form/client-form-name.tsx new file mode 100644 index 0000000..1e5eb74 --- /dev/null +++ b/src/components/general/client-form/client-form-name.tsx @@ -0,0 +1,19 @@ +import Input from "@/src/components/twui/form/Input"; +import type { ClientFormInit } from "./client-form-types"; + +export default function ClientFormName({ form, setForm, existing_full }: ClientFormInit) { + return ( + { + setForm((prev) => ({ ...prev, name: e.target.value })); + }} + defaultValue={form.name} + showLabel + required + /> + ); +} diff --git a/src/components/general/client-form/client-form-notes.tsx b/src/components/general/client-form/client-form-notes.tsx new file mode 100644 index 0000000..ff32c46 --- /dev/null +++ b/src/components/general/client-form/client-form-notes.tsx @@ -0,0 +1,19 @@ +import Input from "@/src/components/twui/form/Input"; +import type { ClientFormInit } from "./client-form-types"; + +export default function ClientFormNotes({ form, setForm }: ClientFormInit) { + return ( + { + setForm((prev) => ({ ...prev, notes: e.target.value })); + }} + defaultValue={form.notes} + showLabel + /> + ); +} diff --git a/src/components/general/client-form/client-form-types.ts b/src/components/general/client-form/client-form-types.ts new file mode 100644 index 0000000..a1bd7c5 --- /dev/null +++ b/src/components/general/client-form/client-form-types.ts @@ -0,0 +1,4 @@ +import type { BUN_SQLITE_WGUI_CLIENTS } from "@/db/types/db"; +import type useFormInit from "@/src/hooks/use-form-init"; + +export type ClientFormInit = ReturnType>; diff --git a/src/components/general/client-form/client-form-wg-ip.tsx b/src/components/general/client-form/client-form-wg-ip.tsx new file mode 100644 index 0000000..a25b154 --- /dev/null +++ b/src/components/general/client-form/client-form-wg-ip.tsx @@ -0,0 +1,88 @@ +import { useState } from "react"; +import { Wand2 } from "lucide-react"; +import Input from "@/src/components/twui/form/Input"; +import Button from "@/src/components/twui/layout/Button"; +import Row from "@/src/components/twui/layout/Row"; +import Stack from "@/src/components/twui/layout/Stack"; +import MutedText from "../muted-text"; +import grabNextAvailableClientIp from "./grab-next-available-client-ip"; +import type { ClientFormInit } from "./client-form-types"; + +type Props = ClientFormInit & { + host_id: number; + /** Bump to remount the input after the value is set by code */ + field_key: number; + onGrabbed: () => void; +}; + +export default function ClientFormWgIp({ + form, + setForm, + existing_full, + host_id, + field_key, + onGrabbed, +}: Props) { + const [grabbing, setGrabbing] = useState(false); + const [grab_error, setGrabError] = useState(); + const is_edit = Boolean(existing_full?.id); + + async function handleGrab() { + setGrabbing(true); + setGrabError(undefined); + const res = await grabNextAvailableClientIp({ host_id, setForm }); + setGrabbing(false); + + if (!res.success) { + setGrabError(res.msg); + return; + } + + onGrabbed(); + } + + return ( + + + { + setForm((prev) => ({ + ...prev, + wg_ip_address: v, + allowed_ips: `${v.replace(/\.\d+$/, "")}.0/24`, + })); + }} + defaultValue={form.wg_ip_address} + readOnly={is_edit} + showLabel + wrapperWrapperProps={{ className: "grow" }} + /> + {is_edit ? null : ( + + )} + + {grab_error ? ( + + {grab_error} + + ) : is_edit ? ( + The tunnel IP can't change after creation. + ) : null} + + ); +} diff --git a/src/pages/admin/hosts/[host_id]/clients/add-client/(functions)/grab-next-available-client-ip.ts b/src/components/general/client-form/grab-next-available-client-ip.ts similarity index 100% rename from src/pages/admin/hosts/[host_id]/clients/add-client/(functions)/grab-next-available-client-ip.ts rename to src/components/general/client-form/grab-next-available-client-ip.ts diff --git a/src/components/general/client-form/index.tsx b/src/components/general/client-form/index.tsx new file mode 100644 index 0000000..6e7f465 --- /dev/null +++ b/src/components/general/client-form/index.tsx @@ -0,0 +1,145 @@ +import { useState } from "react"; +import type { + BUN_SQLITE_WGUI_CLIENTS, + BUN_SQLITE_WGUI_CLIENT_RULES, + BUN_SQLITE_WGUI_HOSTS, +} from "@/db/types/db"; +import Form from "@/src/components/twui/form/Form"; +import LoadingBlock from "@/src/components/twui/elements/LoadingBlock"; +import useFormInit from "@/src/hooks/use-form-init"; +import useConfirm from "@/src/hooks/use-confirm"; +import useToast from "@/src/hooks/use-toast"; +import Panel from "../panel"; +import FormSection from "../form-section"; +import FormActions from "../form-actions"; +import InlineAlert from "../inline-alert"; +import ClientRulesEditor from "../client-rules/client-rules-editor"; +import { clientRuleToDraft, type ClientRuleDraft } from "../client-rules/client-rule-draft"; +import ClientFormHostSelect from "./client-form-host-select"; +import ClientFormName from "./client-form-name"; +import ClientFormNotes from "./client-form-notes"; +import ClientFormWgIp from "./client-form-wg-ip"; +import ClientFormAllowedIps from "./client-form-allowed-ips"; +import grabNextAvailableClientIp from "./grab-next-available-client-ip"; +import submitClientForm from "./submit-client-form"; + +type Props = { + hosts: BUN_SQLITE_WGUI_HOSTS[]; + host_id: number; + existing_client?: BUN_SQLITE_WGUI_CLIENTS; + existing_client_rules?: BUN_SQLITE_WGUI_CLIENT_RULES[]; + next_available_client_ip?: string | null; + cancel_href: string; +}; + +export default function ClientForm({ + hosts, + host_id: initial_host_id, + existing_client, + existing_client_rules, + next_available_client_ip, + cancel_href, +}: Props) { + const { confirm } = useConfirm(); + const { toast } = useToast(); + + const init = useFormInit({ + existing: existing_client, + existing_full: existing_client, + // Separate drafts so an edit session never pre-fills the add form + title: existing_client?.id ? `Edit Client ${existing_client.id}` : "Add Client", + async before_ready_function({ setForm }) { + setForm((prev) => ({ + ...prev, + wg_ip_address: + existing_client?.wg_ip_address || next_available_client_ip || "", + })); + }, + }); + + const [host_id, setHostId] = useState(initial_host_id); + const [field_key, setFieldKey] = useState(0); + const [rules, setRules] = useState( + (existing_client_rules || []).map((rule) => clientRuleToDraft({ rule })), + ); + + const { loading, status, ready, setForm } = init; + const is_edit = Boolean(existing_client?.id); + + async function changeHost({ host_id: next }: { host_id: number }) { + setHostId(next); + + const url = new URL(window.location.href); + url.searchParams.set("host_id", String(next)); + window.history.replaceState(null, "", url); + + const res = await grabNextAvailableClientIp({ host_id: next, setForm }); + if (res.success) { + setFieldKey((k) => k + 1); + } else { + toast({ msg: res.msg || "Couldn't find a free IP on that host", color: "error" }); + } + } + + if (!ready) { + return ; + } + + return ( + +
{ + submitClientForm({ init, host_id, rules, confirm, toast }); + }} + > + {status?.error ? ( + + ) : null} + + + + + + + + + + + + setFieldKey((k) => k + 1)} + /> + + + + + + + + + +
+ ); +} diff --git a/src/components/general/client-form/submit-client-form.ts b/src/components/general/client-form/submit-client-form.ts new file mode 100644 index 0000000..e7096ba --- /dev/null +++ b/src/components/general/client-form/submit-client-form.ts @@ -0,0 +1,129 @@ +import type { + BUN_SQLITE_WGUI_CLIENT_RULES, + BUN_SQLITE_WGUI_CLIENTS, +} from "@/db/types/db"; +import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler"; +import fetchApi from "@/src/components/twui/utils/fetch/fetchApi"; +import type { ApiReqParams } from "@/src/types"; +import type { APIResponseObject } from "@moduletrace/bunext/types"; +import _ from "lodash"; +import { clientRuleDraftToRecord, type ClientRuleDraft } from "../client-rules/client-rule-draft"; +import type { ConfirmContextValue } from "../confirm/confirm-context"; +import type { ToastContextValue } from "../toast/toast-context"; +import type { ClientFormInit } from "./client-form-types"; + +type Params = { + init: ClientFormInit; + host_id: number; + rules: ClientRuleDraft[]; + confirm: ConfirmContextValue["confirm"]; + toast: ToastContextValue["toast"]; +}; + +export default async function submitClientForm({ + init, + host_id, + rules, + confirm, + toast, +}: Params) { + const { setLoading, setStatus, form, existing_full, app_context, clearLocalForm } = init; + const is_edit = Boolean(existing_full?.id); + + if (is_edit) { + const confirmed = await confirm({ + title: "Save changes?", + message: "The client's config is regenerated. Devices may need the updated config.", + confirm_label: "Save changes", + }); + if (!confirmed) return; + } + + setLoading(true); + + try { + const client_data: BUN_SQLITE_WGUI_CLIENTS = _.omitBy( + { + name: form.name || "", + wg_ip_address: form.wg_ip_address || "", + allowed_ips: form.allowed_ips || "", + notes: form.notes || "", + host_id, + user_id: app_context?.user?.id, + allow_all_ips: form.allow_all_ips, + }, + (value) => value === undefined, + ); + + const res = is_edit + ? await adminCrudHandler({ + action: "update", + table: "clients", + update_data: client_data, + id: existing_full?.id, + }) + : await adminCrudHandler({ + action: "insert", + table: "clients", + insert_data: [client_data], + }); + + if (!res.success) { + throw new Error(res.msg || (is_edit ? "Couldn't update the client" : "Couldn't create the client")); + } + + const client_id = existing_full?.id || res.postInsertReturn?.insertId; + + if (!client_id) { + throw new Error("The client was saved but no ID came back"); + } + + await adminCrudHandler({ + action: "delete", + table: "client_rules", + sql_query: { + query: { + client_id: { value: String(client_id) }, + host_id: { value: String(host_id) }, + }, + }, + }); + + if (rules.length) { + const rules_res = await adminCrudHandler({ + action: "insert", + table: "client_rules", + insert_data: rules.map((draft) => ({ + ...clientRuleDraftToRecord({ draft }), + host_id, + client_id: Number(client_id), + user_id: app_context.user?.id, + })), + }); + + if (!rules_res.success) { + toast({ msg: rules_res.msg || "Client saved, but its rules failed to save", color: "error" }); + } + } + + const setup_res = await fetchApi( + `/api/admin/run-client-setup`, + { method: "POST", body: { client_id } }, + ); + + if (!setup_res?.success) { + toast({ msg: setup_res?.msg || "Client saved, but its config failed to generate", color: "error" }); + } + + clearLocalForm(); + + const base = `/admin/hosts/${host_id}/clients/${client_id}`; + window.location.href = is_edit ? base : `${base}?tab=config`; + } catch (error: any) { + setStatus({ + error: true, + msg: error.message || "Couldn't save the client", + }); + setLoading(false); + } +} diff --git a/src/components/general/client-row.tsx b/src/components/general/client-row.tsx deleted file mode 100644 index a25c3fd..0000000 --- a/src/components/general/client-row.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { useState } from "react"; -import { Pencil, Trash2 } from "lucide-react"; -import Link from "@/src/components/twui/layout/Link"; -import Row from "@/src/components/twui/layout/Row"; -import Span from "@/src/components/twui/layout/Span"; -import formatUnixTimestamp from "@/src/utils/format-unix-timestamp"; -import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler"; -import type { - BUN_SQLITE_WGUI_CLIENTS, - BUN_SQLITE_WGUI_CLIENT_RULES, -} from "@/db/types/db"; -import deriveClientAccessLabel from "@/src/functions/frontend/derive-client-access-label"; -import type { BUN_SQLITE_WGUI_CLIENTS_JOIN } from "@/src/types/sql-joins"; - -type Props = { - client: BUN_SQLITE_WGUI_CLIENTS_JOIN; - rules?: BUN_SQLITE_WGUI_CLIENT_RULES[]; -}; - -export default function ClientRow({ client, rules }: Props) { - const [deleting, setDeleting] = useState(false); - - const host_id = Number(client.host_id || 0); - const access = deriveClientAccessLabel({ rules }); - const host_name = - host_id == 0 ? "Main" : client.host_name || `Host #${host_id}`; - - async function handleDelete() { - if ( - !window.confirm( - `Delete client "${client?.name || `#${client?.id}`}"? The client will be removed from the host and its config file deleted.`, - ) - ) { - return; - } - - setDeleting(true); - - const res = await adminCrudHandler({ - action: "delete", - table: "clients", - id: client?.id, - }); - - if (!res.success) { - setDeleting(false); - window.alert(res.msg || "Could not delete client"); - return; - } - - window.location.reload(); - } - - return ( -
- - - - - - - - - - ); -} diff --git a/src/components/general/client-rules/build-client-rule-drafts.ts b/src/components/general/client-rules/build-client-rule-drafts.ts new file mode 100644 index 0000000..a540158 --- /dev/null +++ b/src/components/general/client-rules/build-client-rule-drafts.ts @@ -0,0 +1,30 @@ +import expandClientRuleDestinations from "@/src/functions/backend/setup/expand-client-rule-destinations"; +import sanitizeClientRule from "@/src/functions/backend/setup/sanitize-client-rule"; +import validateClientRule from "@/src/functions/backend/setup/validate-client-rule"; +import { + clientRuleDraftToRecord, + clientRuleToDraft, + type ClientRuleDraft, +} from "./client-rule-draft"; + +type Params = { + draft: ClientRuleDraft; +}; + +/** Sanitizes and validates a draft, splitting comma-separated destinations. */ +export default function buildClientRuleDrafts({ draft }: Params) { + const sanitized = sanitizeClientRule({ + rule: clientRuleDraftToRecord({ draft }), + }); + const valid = validateClientRule({ rule: sanitized }); + + if (!valid.success || !sanitized) { + return { success: false as const, msg: valid.msg || "Invalid rule" }; + } + + const drafts = expandClientRuleDestinations({ rule: sanitized }).map( + (rule) => clientRuleToDraft({ rule }), + ); + + return { success: true as const, drafts }; +} diff --git a/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-destination-field.tsx b/src/components/general/client-rules/client-rule-destination-field.tsx similarity index 100% rename from src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-destination-field.tsx rename to src/components/general/client-rules/client-rule-destination-field.tsx diff --git a/src/components/general/client-rules/client-rule-draft-item.tsx b/src/components/general/client-rules/client-rule-draft-item.tsx new file mode 100644 index 0000000..3fafc6b --- /dev/null +++ b/src/components/general/client-rules/client-rule-draft-item.tsx @@ -0,0 +1,16 @@ +import ClientRuleItem from "./client-rule-item"; +import { clientRuleDraftToRecord, type ClientRuleDraft } from "./client-rule-draft"; + +type Props = { + draft: ClientRuleDraft; + onRemove: ({ local_id }: { local_id: string }) => void; +}; + +export default function ClientRuleDraftItem({ draft, onRemove }: Props) { + return ( + onRemove({ local_id: draft.local_id })} + /> + ); +} diff --git a/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-draft.ts b/src/components/general/client-rules/client-rule-draft.ts similarity index 100% rename from src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-draft.ts rename to src/components/general/client-rules/client-rule-draft.ts diff --git a/src/components/general/client-rules/client-rule-fields.tsx b/src/components/general/client-rules/client-rule-fields.tsx new file mode 100644 index 0000000..d46d23e --- /dev/null +++ b/src/components/general/client-rules/client-rule-fields.tsx @@ -0,0 +1,37 @@ +import Stack from "@/src/components/twui/layout/Stack"; +import type { ClientRuleDraft } from "./client-rule-draft"; +import ClientRuleDestinationField from "./client-rule-destination-field"; +import ClientRulePortsField from "./client-rule-ports-field"; +import ClientRuleProtocolField from "./client-rule-protocol-field"; +import ClientRuleTypeField from "./client-rule-type-field"; + +type Props = { + draft: ClientRuleDraft; + setDraft: (draft: ClientRuleDraft) => void; + onEnter: () => void; +}; + +/** + * Not a `
`: this renders inside a portalled modal, and React submit + * events bubble through portals into whatever form hosts the modal. + */ +export default function ClientRuleFields({ draft, setDraft, onEnter }: Props) { + return ( + { + if (e.key == "Enter" && (e.target as HTMLElement).tagName == "INPUT") { + e.preventDefault(); + onEnter(); + } + }} + > + + + + + + + + ); +} diff --git a/src/components/general/client-rules/client-rule-item.tsx b/src/components/general/client-rules/client-rule-item.tsx new file mode 100644 index 0000000..45036e4 --- /dev/null +++ b/src/components/general/client-rules/client-rule-item.tsx @@ -0,0 +1,35 @@ +import { ShieldCheck, Trash2 } from "lucide-react"; +import Button from "@/src/components/twui/layout/Button"; +import Row from "@/src/components/twui/layout/Row"; +import MonoText from "../mono-text"; +import formatClientRuleLabel from "@/src/functions/frontend/format-client-rule-label"; +import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db"; + +type Props = { + rule: BUN_SQLITE_WGUI_CLIENT_RULES; + onRemove: () => void; + removing?: boolean; +}; + +export default function ClientRuleItem({ rule, onRemove, removing }: Props) { + return ( + + + + {formatClientRuleLabel({ rule })} + + + + ); +} diff --git a/src/components/general/client-rules/client-rule-modal.tsx b/src/components/general/client-rules/client-rule-modal.tsx new file mode 100644 index 0000000..fa10ce5 --- /dev/null +++ b/src/components/general/client-rules/client-rule-modal.tsx @@ -0,0 +1,123 @@ +import { useState } from "react"; +import { Plus } from "lucide-react"; +import Modal from "@/src/components/twui/elements/Modal"; +import Button from "@/src/components/twui/layout/Button"; +import H3 from "@/src/components/twui/layout/H3"; +import P from "@/src/components/twui/layout/P"; +import Row from "@/src/components/twui/layout/Row"; +import Stack from "@/src/components/twui/layout/Stack"; +import InlineAlert from "../inline-alert"; +import ClientRuleFields from "./client-rule-fields"; +import buildClientRuleDrafts from "./build-client-rule-drafts"; +import { createClientRuleDraft, type ClientRuleDraft } from "./client-rule-draft"; + +export type ClientRuleModalAddResult = { success: boolean; msg?: string }; + +type Props = { + onAdd: ({ rules }: { rules: ClientRuleDraft[] }) => Promise; + disabled?: boolean; +}; + +/** Validates a rule and hands it to `onAdd`. Closes only on success. */ +export default function ClientRuleModal({ onAdd, disabled }: Props) { + const [open, setOpen] = useState(false); + const [draft, setDraft] = useState(createClientRuleDraft()); + const [error, setError] = useState(); + const [saving, setSaving] = useState(false); + + function reset() { + setDraft(createClientRuleDraft()); + setError(undefined); + } + + async function handleAdd() { + const built = buildClientRuleDrafts({ draft }); + + if (!built.success) { + setError(built.msg); + return; + } + + setSaving(true); + const res = await onAdd({ rules: built.drafts }); + setSaving(false); + + if (!res.success) { + setError(res.msg || "Couldn't add the rule"); + return; + } + + reset(); + setOpen(false); + } + + return ( + { + setOpen(value); + if (value === false) reset(); + }} + className="max-w-lg p-5" + target={ + + } + > + + +

Add access rule

+

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

+
+ + {error ? : null} + + { + setDraft(next); + setError(undefined); + }} + onEnter={handleAdd} + /> + + + + + +
+
+ ); +} diff --git a/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-ports-field.tsx b/src/components/general/client-rules/client-rule-ports-field.tsx similarity index 100% rename from src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-ports-field.tsx rename to src/components/general/client-rules/client-rule-ports-field.tsx diff --git a/src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-protocol-field.tsx b/src/components/general/client-rules/client-rule-protocol-field.tsx similarity index 100% rename from src/pages/admin/hosts/[host_id]/clients/(partials)/client-rules/client-rule-protocol-field.tsx rename to src/components/general/client-rules/client-rule-protocol-field.tsx diff --git a/src/components/general/client-rules/client-rule-type-field.tsx b/src/components/general/client-rules/client-rule-type-field.tsx new file mode 100644 index 0000000..84e2125 --- /dev/null +++ b/src/components/general/client-rules/client-rule-type-field.tsx @@ -0,0 +1,23 @@ +import Checkbox from "@/src/components/twui/form/Checkbox"; +import type { ClientRuleDraft } from "./client-rule-draft"; + +type Props = { + draft: ClientRuleDraft; + setDraft: (draft: ClientRuleDraft) => void; +}; + +export default function ClientRuleTypeField({ draft, setDraft }: Props) { + return ( + { + setDraft({ + ...draft, + rule_type: checked ? "all" : "destination", + }); + }} + /> + ); +} diff --git a/src/components/general/client-rules/client-rules-editor.tsx b/src/components/general/client-rules/client-rules-editor.tsx new file mode 100644 index 0000000..136e30e --- /dev/null +++ b/src/components/general/client-rules/client-rules-editor.tsx @@ -0,0 +1,45 @@ +import Card from "@/src/components/twui/elements/Card"; +import Stack from "@/src/components/twui/layout/Stack"; +import ClientRuleDraftItem from "./client-rule-draft-item"; +import ClientRuleModal from "./client-rule-modal"; +import ClientRulesEmpty from "./client-rules-empty"; +import type { ClientRuleDraft } from "./client-rule-draft"; + +type Props = { + rules: ClientRuleDraft[]; + setRules: (rules: ClientRuleDraft[]) => void; +}; + +/** Draft rules held in the client form; saved together with the client. */ +export default function ClientRulesEditor({ rules, setRules }: Props) { + const has_all_access = rules.some((rule) => rule.rule_type == "all"); + + return ( + + + {rules.length ? ( + rules.map((rule) => ( + + setRules(rules.filter((item) => item.local_id != local_id)) + } + /> + )) + ) : ( + + )} + + + { + setRules([...rules, ...added]); + return { success: true }; + }} + /> + + + ); +} diff --git a/src/components/general/client-rules/client-rules-empty.tsx b/src/components/general/client-rules/client-rules-empty.tsx new file mode 100644 index 0000000..e68f883 --- /dev/null +++ b/src/components/general/client-rules/client-rules-empty.tsx @@ -0,0 +1,13 @@ +import { ShieldOff } from "lucide-react"; +import EmptyState from "../empty-state"; + +export default function ClientRulesEmpty() { + return ( + } + title="No access rules" + description="Zero trust: without rules this client can't reach the host, its LAN, the internet or other clients." + /> + ); +} diff --git a/src/components/general/clients-table.tsx b/src/components/general/clients-table.tsx deleted file mode 100644 index 3c1688e..0000000 --- a/src/components/general/clients-table.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import ClientRow from "@/src/components/general/client-row"; -import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db"; -import type { BUN_SQLITE_WGUI_CLIENTS_JOIN } from "@/src/types/sql-joins"; - -type Props = { - clients: BUN_SQLITE_WGUI_CLIENTS_JOIN[]; - rules_by_client_id: Map; - accessLabel?: string; - minWidthClass?: string; -}; - -const thClass = - "px-4 py-2 text-left text-[11px] font-semibold uppercase tracking-[0.08em] " + - "text-foreground-light/40 dark:text-foreground-dark/40 whitespace-nowrap"; - -const thRightClass = `${thClass} text-right`; - -export default function ClientsTable({ - clients, - rules_by_client_id, - accessLabel = "Access", - minWidthClass = "min-w-[860px]", -}: Props) { - return ( -
-
- - {client.name || "—"} - - - {typeof host_id == "number" ? ( - - {host_name} - - ) : ( - - — - - )} - - {client.wg_ip_address || "—"} - - {client.allow_all_ips - ? "0.0.0.0/0, ::/0" - : client.allowed_ips || "—"} - - - {access.text} - - - - {client.public_key - ? `${client.public_key.slice(0, 16)}…` - : "—"} - - - {formatUnixTimestamp({ timestamp: client.created_at })} - - - - - - - -
- - - - - - - - - - - - - - {clients.map((client) => ( - - ))} - -
ClientHostTunnel IPAllowed IPs{accessLabel}Public keyCreatedActions
- - ); -} diff --git a/src/components/general/clients-table/client-access-tag.tsx b/src/components/general/clients-table/client-access-tag.tsx new file mode 100644 index 0000000..d22e643 --- /dev/null +++ b/src/components/general/clients-table/client-access-tag.tsx @@ -0,0 +1,24 @@ +import StatusDot from "../status-dot"; +import deriveClientAccessLabel from "@/src/functions/frontend/derive-client-access-label"; +import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db"; + +type Props = { + rules?: BUN_SQLITE_WGUI_CLIENT_RULES[]; +}; + +export default function ClientAccessTag({ rules }: Props) { + const access = deriveClientAccessLabel({ rules }); + + return ( + + ); +} diff --git a/src/components/general/clients-table/client-mobile-card.tsx b/src/components/general/clients-table/client-mobile-card.tsx new file mode 100644 index 0000000..6a76972 --- /dev/null +++ b/src/components/general/clients-table/client-mobile-card.tsx @@ -0,0 +1,33 @@ +import Row from "@/src/components/twui/layout/Row"; +import Stack from "@/src/components/twui/layout/Stack"; +import MutedText from "../muted-text"; +import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db"; +import type { BUN_SQLITE_WGUI_CLIENTS_JOIN } from "@/src/types/sql-joins"; +import ClientAccessTag from "./client-access-tag"; +import ClientNameCell from "./client-name-cell"; +import ClientRowActions from "./client-row-actions"; +import deriveClientHostName from "./derive-client-host-name"; + +type Props = { + client: BUN_SQLITE_WGUI_CLIENTS_JOIN; + rules?: BUN_SQLITE_WGUI_CLIENT_RULES[]; + hide_host?: boolean; + onDeleted?: ({ id }: { id: number }) => void; +}; + +export default function ClientMobileCard({ client, rules, hide_host, onDeleted }: Props) { + return ( + + + + + {hide_host ? null : ( + {deriveClientHostName({ client })} + )} + + + + + + ); +} diff --git a/src/components/general/clients-table/client-name-cell.tsx b/src/components/general/clients-table/client-name-cell.tsx new file mode 100644 index 0000000..0712104 --- /dev/null +++ b/src/components/general/clients-table/client-name-cell.tsx @@ -0,0 +1,24 @@ +import Link from "@/src/components/twui/layout/Link"; +import Stack from "@/src/components/twui/layout/Stack"; +import MonoText from "../mono-text"; +import type { BUN_SQLITE_WGUI_CLIENTS_JOIN } from "@/src/types/sql-joins"; + +type Props = { + client: BUN_SQLITE_WGUI_CLIENTS_JOIN; +}; + +export default function ClientNameCell({ client }: Props) { + return ( + + + {client.name || `Client #${client.id}`} + + + {client.wg_ip_address || "—"} + + + ); +} diff --git a/src/components/general/clients-table/client-row-actions.tsx b/src/components/general/clients-table/client-row-actions.tsx new file mode 100644 index 0000000..dc55b87 --- /dev/null +++ b/src/components/general/clients-table/client-row-actions.tsx @@ -0,0 +1,38 @@ +import { Eye, Pencil, Trash2 } from "lucide-react"; +import ActionMenu from "../action-menu"; +import useDeleteClient from "./use-delete-client"; +import type { BUN_SQLITE_WGUI_CLIENTS_JOIN } from "@/src/types/sql-joins"; + +type Props = { + client: BUN_SQLITE_WGUI_CLIENTS_JOIN; + onDeleted?: ({ id }: { id: number }) => void; +}; + +export default function ClientRowActions({ client, onDeleted }: Props) { + const { deleteClient, deleting } = useDeleteClient({ onDeleted }); + const base = `/admin/hosts/${Number(client.host_id || 0)}/clients/${client.id}`; + + return ( + }, + { + key: "edit", + title: "Edit", + href: `${base}/edit`, + icon: , + }, + { + key: "delete", + title: "Delete", + tone: "danger", + icon: , + loading: deleting, + onClick: () => deleteClient({ client }), + }, + ]} + /> + ); +} diff --git a/src/components/general/clients-table/clients-table-columns.tsx b/src/components/general/clients-table/clients-table-columns.tsx new file mode 100644 index 0000000..15a5255 --- /dev/null +++ b/src/components/general/clients-table/clients-table-columns.tsx @@ -0,0 +1,108 @@ +import Link from "@/src/components/twui/layout/Link"; +import MonoText from "../mono-text"; +import MutedText from "../muted-text"; +import CopyableValue from "../copyable-value"; +import formatUnixTimestamp from "@/src/utils/format-unix-timestamp"; +import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db"; +import type { BUN_SQLITE_WGUI_CLIENTS_JOIN } from "@/src/types/sql-joins"; +import type { DataTableColumn } from "../data-table/types"; +import ClientAccessTag from "./client-access-tag"; +import ClientNameCell from "./client-name-cell"; +import ClientRowActions from "./client-row-actions"; +import deriveClientHostName from "./derive-client-host-name"; + +type Params = { + hide_host?: boolean; + rules_by_client_id: Map; + onDeleted?: ({ id }: { id: number }) => void; +}; + +export default function buildClientsTableColumns({ + hide_host, + rules_by_client_id, + onDeleted, +}: Params): DataTableColumn[] { + const columns: (DataTableColumn | null)[] = [ + { + key: "name", + header: "Client", + render: ({ item }) => , + }, + hide_host + ? null + : { + key: "host", + header: "Host", + render: ({ item }) => ( + + {deriveClientHostName({ client: item })} + + ), + }, + { + key: "ip", + header: "Tunnel IP", + hide_below: "lg", + render: ({ item }) => {item.wg_ip_address || "—"}, + }, + { + key: "allowed", + header: "Allowed IPs", + hide_below: "xl", + className: "max-w-[200px]", + render: ({ item }) => ( + + {item.allow_all_ips ? "0.0.0.0/0, ::/0" : item.allowed_ips || "—"} + + ), + }, + { + key: "access", + header: "Access", + render: ({ item }) => ( + + ), + }, + { + key: "key", + header: "Public key", + hide_below: "xl", + className: "max-w-[180px]", + render: ({ item }) => + item.public_key ? ( + + ) : ( + — + ), + }, + { + key: "created", + header: "Created", + align: "right", + hide_below: "lg", + render: ({ item }) => ( + + {formatUnixTimestamp({ timestamp: item.created_at })} + + ), + }, + { + key: "actions", + header: Actions, + align: "right", + className: "w-10", + render: ({ item }) => ( + + ), + }, + ]; + + return columns.filter(Boolean) as DataTableColumn[]; +} diff --git a/src/components/general/clients-table/derive-client-host-name.ts b/src/components/general/clients-table/derive-client-host-name.ts new file mode 100644 index 0000000..d88b390 --- /dev/null +++ b/src/components/general/clients-table/derive-client-host-name.ts @@ -0,0 +1,10 @@ +import type { BUN_SQLITE_WGUI_CLIENTS_JOIN } from "@/src/types/sql-joins"; + +type Params = { + client: BUN_SQLITE_WGUI_CLIENTS_JOIN; +}; + +export default function deriveClientHostName({ client }: Params) { + const host_id = Number(client.host_id || 0); + return client.host_name || (host_id == 0 ? "Main" : `Host #${host_id}`); +} diff --git a/src/components/general/clients-table/index.tsx b/src/components/general/clients-table/index.tsx new file mode 100644 index 0000000..b3940a7 --- /dev/null +++ b/src/components/general/clients-table/index.tsx @@ -0,0 +1,57 @@ +import { useCallback, useMemo, type ReactNode } from "react"; +import DataTable from "../data-table"; +import buildClientsTableColumns from "./clients-table-columns"; +import ClientMobileCard from "./client-mobile-card"; +import type { BUN_SQLITE_WGUI_CLIENT_RULES } from "@/db/types/db"; +import type { BUN_SQLITE_WGUI_CLIENTS_JOIN } from "@/src/types/sql-joins"; + +type Props = { + /** `undefined` while loading */ + clients?: BUN_SQLITE_WGUI_CLIENTS_JOIN[]; + rules_by_client_id: Map; + loading?: boolean; + hide_host?: boolean; + empty?: ReactNode; + skeleton_rows?: number; + onDeleted?: ({ id }: { id: number }) => void; +}; + +export default function ClientsTable({ + clients, + rules_by_client_id, + loading, + hide_host, + empty, + skeleton_rows, + onDeleted, +}: Props) { + const columns = useMemo( + () => buildClientsTableColumns({ hide_host, rules_by_client_id, onDeleted }), + [hide_host, rules_by_client_id, onDeleted], + ); + + const MobileItem = useCallback( + ({ item }: { item: BUN_SQLITE_WGUI_CLIENTS_JOIN }) => ( + + ), + [rules_by_client_id, hide_host, onDeleted], + ); + + return ( + Number(item.id)} + loading={loading} + empty={empty} + skeleton_rows={skeleton_rows} + MobileItem={MobileItem} + /> + ); +} diff --git a/src/components/general/clients-table/use-delete-client.ts b/src/components/general/clients-table/use-delete-client.ts new file mode 100644 index 0000000..df726cf --- /dev/null +++ b/src/components/general/clients-table/use-delete-client.ts @@ -0,0 +1,50 @@ +import { useState } from "react"; +import adminCrudHandler from "@/src/functions/frontend/admin-crud-handler"; +import useConfirm from "@/src/hooks/use-confirm"; +import useToast from "@/src/hooks/use-toast"; +import type { BUN_SQLITE_WGUI_CLIENTS } from "@/db/types/db"; + +type Params = { + onDeleted?: ({ id }: { id: number }) => void; +}; + +export default function useDeleteClient({ onDeleted }: Params = {}) { + const { confirm } = useConfirm(); + const { toast } = useToast(); + const [deleting, setDeleting] = useState(false); + + async function deleteClient({ client }: { client: BUN_SQLITE_WGUI_CLIENTS }) { + const name = client.name || `#${client.id}`; + + const confirmed = await confirm({ + title: `Delete ${name}?`, + message: + "The peer is removed from its host and its config file is deleted. Devices using this config will lose access.", + confirm_label: "Delete client", + tone: "danger", + }); + + if (!confirmed || !client.id) return false; + + setDeleting(true); + + const res = await adminCrudHandler({ + action: "delete", + table: "clients", + id: client.id, + }); + + setDeleting(false); + + if (!res.success) { + toast({ msg: res.msg || "Couldn't delete the client", color: "error" }); + return false; + } + + toast({ msg: `${name} deleted`, color: "success" }); + onDeleted?.({ id: Number(client.id) }); + return true; + } + + return { deleteClient, deleting }; +} diff --git a/src/components/general/confirm/confirm-context.ts b/src/components/general/confirm/confirm-context.ts new file mode 100644 index 0000000..a2eab57 --- /dev/null +++ b/src/components/general/confirm/confirm-context.ts @@ -0,0 +1,17 @@ +import { createContext, type ReactNode } from "react"; + +export type ConfirmParams = { + title: string; + message?: ReactNode; + confirm_label?: string; + cancel_label?: string; + tone?: "default" | "danger"; +}; + +export type ConfirmContextValue = { + confirm: (params: ConfirmParams) => Promise; +}; + +export const ConfirmContext = createContext({ + confirm: async () => false, +}); diff --git a/src/components/general/confirm/confirm-dialog.tsx b/src/components/general/confirm/confirm-dialog.tsx new file mode 100644 index 0000000..511e4c4 --- /dev/null +++ b/src/components/general/confirm/confirm-dialog.tsx @@ -0,0 +1,97 @@ +import Modal from "@/src/components/twui/elements/Modal"; +import Button from "@/src/components/twui/layout/Button"; +import H3 from "@/src/components/twui/layout/H3"; +import P from "@/src/components/twui/layout/P"; +import Row from "@/src/components/twui/layout/Row"; +import Stack from "@/src/components/twui/layout/Stack"; +import { TriangleAlert } from "lucide-react"; +import React, { useEffect, useRef } from "react"; +import type { ConfirmParams } from "./confirm-context"; + +type Props = { + open: boolean; + params: ConfirmParams | null; + onResolve: ({ confirmed }: { confirmed: boolean }) => void; +}; + +export default function ConfirmDialog({ open, params, onResolve }: Props) { + const confirmRef = useRef(null); + const danger = params?.tone == "danger"; + + useEffect(() => { + if (!open) return; + + const onKey = (e: KeyboardEvent) => { + if (e.key == "Escape") onResolve({ confirmed: false }); + }; + + document.addEventListener("keydown", onKey); + const focusTimer = setTimeout(() => confirmRef.current?.focus(), 30); + + return () => { + document.removeEventListener("keydown", onKey); + clearTimeout(focusTimer); + }; + }, [open]); + + const setOpen = ((value: React.SetStateAction) => { + const next = typeof value == "function" ? value(open) : value; + if (!next && open) onResolve({ confirmed: false }); + }) as React.Dispatch>; + + return ( + + + + {danger ? ( + + + + ) : null} + +

+ {params?.title} +

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

+ {params.message} +

+ ) : ( + params.message + ) + ) : null} +
+
+ + + + +
+
+ ); +} diff --git a/src/components/general/confirm/confirm-provider.tsx b/src/components/general/confirm/confirm-provider.tsx new file mode 100644 index 0000000..5a08eff --- /dev/null +++ b/src/components/general/confirm/confirm-provider.tsx @@ -0,0 +1,32 @@ +import { useCallback, useRef, useState, type PropsWithChildren } from "react"; +import { ConfirmContext, type ConfirmParams } from "./confirm-context"; +import ConfirmDialog from "./confirm-dialog"; + +export default function ConfirmProvider({ children }: PropsWithChildren) { + const [open, setOpen] = useState(false); + const [params, setParams] = useState(null); + const resolver = useRef<((confirmed: boolean) => void) | null>(null); + + const confirm = useCallback((next: ConfirmParams) => { + resolver.current?.(false); + setParams(next); + setOpen(true); + + return new Promise((resolve) => { + resolver.current = resolve; + }); + }, []); + + function onResolve({ confirmed }: { confirmed: boolean }) { + resolver.current?.(confirmed); + resolver.current = null; + setOpen(false); + } + + return ( + + {children} + + + ); +} diff --git a/src/components/general/copy-button.tsx b/src/components/general/copy-button.tsx new file mode 100644 index 0000000..a8d397c --- /dev/null +++ b/src/components/general/copy-button.tsx @@ -0,0 +1,50 @@ +import Button from "@/src/components/twui/layout/Button"; +import useToast from "@/src/hooks/use-toast"; +import { Check, Copy } from "lucide-react"; +import { useState } from "react"; +import { twMerge } from "tailwind-merge"; + +type Props = { + value: string; + /** What is being copied, e.g. "Public key" */ + label: string; + /** Show the word "Copy" next to the icon */ + show_text?: boolean; + className?: string; +}; + +export default function CopyButton({ value, label, show_text, className }: Props) { + const { toast } = useToast(); + const [copied, setCopied] = useState(false); + + async function copy() { + try { + await navigator.clipboard.writeText(value); + setCopied(true); + toast({ msg: `${label} copied`, color: "success" }); + setTimeout(() => setCopied(false), 1500); + } catch { + toast({ msg: `Couldn't copy ${label.toLowerCase()}`, color: "error" }); + } + } + + const Icon = copied ? Check : Copy; + + return ( + + ); +} diff --git a/src/components/general/copyable-value.tsx b/src/components/general/copyable-value.tsx new file mode 100644 index 0000000..9ac9ef1 --- /dev/null +++ b/src/components/general/copyable-value.tsx @@ -0,0 +1,24 @@ +import Row from "@/src/components/twui/layout/Row"; +import type { ReactNode } from "react"; +import { twMerge } from "tailwind-merge"; +import CopyButton from "./copy-button"; +import MonoText from "./mono-text"; + +type Props = { + value: string; + label: string; + /** Rendered instead of the raw value (e.g. a shortened key) */ + display?: ReactNode; + className?: string; +}; + +export default function CopyableValue({ value, label, display, className }: Props) { + return ( + + + {display ?? value} + + + + ); +} diff --git a/src/components/general/data-table/data-table-cell.tsx b/src/components/general/data-table/data-table-cell.tsx new file mode 100644 index 0000000..1906f0f --- /dev/null +++ b/src/components/general/data-table/data-table-cell.tsx @@ -0,0 +1,22 @@ +import { twMerge } from "tailwind-merge"; +import { HideBelowClasses, type DataTableColumn } from "./types"; + +type Props = { + column: DataTableColumn; + item: T; +}; + +export default function DataTableCell({ column, item }: Props) { + return ( + + {column.render({ item })} + + ); +} diff --git a/src/components/general/data-table/data-table-head-cell.tsx b/src/components/general/data-table/data-table-head-cell.tsx new file mode 100644 index 0000000..42f9e7d --- /dev/null +++ b/src/components/general/data-table/data-table-head-cell.tsx @@ -0,0 +1,21 @@ +import { twMerge } from "tailwind-merge"; +import { HideBelowClasses, type DataTableColumn } from "./types"; + +type Props = { + column: DataTableColumn; +}; + +export default function DataTableHeadCell({ column }: Props) { + return ( + + {column.header} + + ); +} diff --git a/src/components/general/data-table/data-table-mobile-list.tsx b/src/components/general/data-table/data-table-mobile-list.tsx new file mode 100644 index 0000000..1b9a5fa --- /dev/null +++ b/src/components/general/data-table/data-table-mobile-list.tsx @@ -0,0 +1,26 @@ +import Stack from "@/src/components/twui/layout/Stack"; +import type { DataTableProps } from "./types"; +import DataTableMobileSkeleton from "./data-table-mobile-skeleton"; + +type Props = Pick, "items" | "getKey" | "MobileItem"> & { + loading: boolean; + skeleton_keys: number[]; +}; + +export default function DataTableMobileList({ + items, + getKey, + MobileItem, + loading, + skeleton_keys, +}: Props) { + return ( + + {loading + ? skeleton_keys.map((key) => ) + : items?.map((item) => ( + + ))} + + ); +} diff --git a/src/components/general/data-table/data-table-mobile-skeleton.tsx b/src/components/general/data-table/data-table-mobile-skeleton.tsx new file mode 100644 index 0000000..7cf3aa6 --- /dev/null +++ b/src/components/general/data-table/data-table-mobile-skeleton.tsx @@ -0,0 +1,11 @@ +import LoadingBlock from "@/src/components/twui/elements/LoadingBlock"; +import Stack from "@/src/components/twui/layout/Stack"; + +export default function DataTableMobileSkeleton() { + return ( + + + + + ); +} diff --git a/src/components/general/data-table/data-table-row.tsx b/src/components/general/data-table/data-table-row.tsx new file mode 100644 index 0000000..2502096 --- /dev/null +++ b/src/components/general/data-table/data-table-row.tsx @@ -0,0 +1,17 @@ +import DataTableCell from "./data-table-cell"; +import type { DataTableColumn } from "./types"; + +type Props = { + columns: DataTableColumn[]; + item: T; +}; + +export default function DataTableRow({ columns, item }: Props) { + return ( + + {columns.map((column) => ( + + ))} + + ); +} diff --git a/src/components/general/data-table/data-table-skeleton-cell.tsx b/src/components/general/data-table/data-table-skeleton-cell.tsx new file mode 100644 index 0000000..21904d0 --- /dev/null +++ b/src/components/general/data-table/data-table-skeleton-cell.tsx @@ -0,0 +1,20 @@ +import LoadingBlock from "@/src/components/twui/elements/LoadingBlock"; +import { twMerge } from "tailwind-merge"; +import { HideBelowClasses, type DataTableColumn } from "./types"; + +type Props = { + column: DataTableColumn; +}; + +export default function DataTableSkeletonCell({ column }: Props) { + return ( + + + + ); +} diff --git a/src/components/general/data-table/data-table-skeleton-row.tsx b/src/components/general/data-table/data-table-skeleton-row.tsx new file mode 100644 index 0000000..df7b24f --- /dev/null +++ b/src/components/general/data-table/data-table-skeleton-row.tsx @@ -0,0 +1,16 @@ +import DataTableSkeletonCell from "./data-table-skeleton-cell"; +import type { DataTableColumn } from "./types"; + +type Props = { + columns: DataTableColumn[]; +}; + +export default function DataTableSkeletonRow({ columns }: Props) { + return ( + + {columns.map((column) => ( + + ))} + + ); +} diff --git a/src/components/general/data-table/index.tsx b/src/components/general/data-table/index.tsx new file mode 100644 index 0000000..c82f927 --- /dev/null +++ b/src/components/general/data-table/index.tsx @@ -0,0 +1,68 @@ +import Stack from "@/src/components/twui/layout/Stack"; +import DataTableHeadCell from "./data-table-head-cell"; +import DataTableMobileList from "./data-table-mobile-list"; +import DataTableRow from "./data-table-row"; +import DataTableSkeletonRow from "./data-table-skeleton-row"; +import type { DataTableProps } from "./types"; + +/** + * Desktop table + mobile card list. + * + * Raw `` elements are used here on purpose: twui `Table` only renders + * plain object data and has no column/cell API. Keep all table markup inside + * this folder. + */ +export default function DataTable({ + items, + columns, + getKey, + loading, + skeleton_rows = 4, + empty, + MobileItem, + caption, +}: DataTableProps) { + const is_loading = Boolean(loading || !items); + const skeleton_keys = Array.from({ length: skeleton_rows }, (_, i) => i); + + if (!is_loading && !items?.length && empty) { + return <>{empty}; + } + + return ( + +
+
+ {caption ? : null} + + + {columns.map((column) => ( + + ))} + + + + {is_loading + ? skeleton_keys.map((key) => ( + + )) + : items?.map((item) => ( + + ))} + +
{caption}
+ + + + ); +} diff --git a/src/components/general/data-table/types.ts b/src/components/general/data-table/types.ts new file mode 100644 index 0000000..d4d7a12 --- /dev/null +++ b/src/components/general/data-table/types.ts @@ -0,0 +1,29 @@ +import type { ComponentType, ReactNode } from "react"; + +export type DataTableColumn = { + key: string; + header: ReactNode; + align?: "left" | "right"; + className?: string; + /** Hide the column below this breakpoint */ + hide_below?: "lg" | "xl"; + render: ({ item }: { item: T }) => ReactNode; +}; + +export type DataTableProps = { + items?: T[]; + columns: DataTableColumn[]; + getKey: ({ item }: { item: T }) => string | number; + loading?: boolean; + skeleton_rows?: number; + /** Rendered when not loading and there are no items */ + empty?: ReactNode; + /** Card rendered for each item below `md` */ + MobileItem: ComponentType<{ item: T }>; + caption?: string; +}; + +export const HideBelowClasses: Record["hide_below"]>, string> = { + lg: "hidden lg:table-cell", + xl: "hidden xl:table-cell", +}; diff --git a/src/components/general/delete-host-button.tsx b/src/components/general/delete-host-button.tsx deleted file mode 100644 index 23a72c1..0000000 --- a/src/components/general/delete-host-button.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import { useState } from "react"; -import { Trash2 } from "lucide-react"; -import Button from "@/src/components/twui/layout/Button"; -import fetchApi from "@/src/components/twui/utils/fetch/fetchApi"; -import type { ApiReqParams } from "@/src/types"; -import type { APIResponseObject } from "@moduletrace/bunext/types"; - -export default function DeleteHostButton({ host_id }: { host_id: number }) { - const [deleting, setDeleting] = useState(false); - - async function handleDelete() { - if ( - !window.confirm( - `Delete host #${host_id}? All clients and their configurations will be removed.`, - ) - ) { - return; - } - - setDeleting(true); - - const res = await fetchApi( - `/api/admin/delete-host`, - { - method: "DELETE", - body: { host_id }, - }, - ); - - if (!res.success) { - setDeleting(false); - window.alert(res.msg || "Could not delete the host"); - return; - } - - window.location.pathname = "/admin/hosts"; - } - - return ( - + ) : null} + + + + ); +} diff --git a/src/components/general/form-section.tsx b/src/components/general/form-section.tsx new file mode 100644 index 0000000..09a6b2d --- /dev/null +++ b/src/components/general/form-section.tsx @@ -0,0 +1,35 @@ +import H2 from "@/src/components/twui/layout/H2"; +import P from "@/src/components/twui/layout/P"; +import Stack from "@/src/components/twui/layout/Stack"; +import type { PropsWithChildren, ReactNode } from "react"; +import { twMerge } from "tailwind-merge"; + +type Props = PropsWithChildren & { + title: string; + description?: ReactNode; + className?: string; +}; + +/** + * A labelled group of fields: label column on the left from `lg`, stacked + * above the fields below that. + */ +export default function FormSection({ title, description, className, children }: Props) { + return ( + + +

{title}

+ {description ? ( +

{description}

+ ) : null} +
+ {children} +
+ ); +} diff --git a/src/components/general/grab-next-subnet-button.tsx b/src/components/general/grab-next-subnet-button.tsx index 94a82e3..ffae3e1 100644 --- a/src/components/general/grab-next-subnet-button.tsx +++ b/src/components/general/grab-next-subnet-button.tsx @@ -2,6 +2,7 @@ import { useState } from "react"; import { Wand2 } from "lucide-react"; import Button from "../twui/layout/Button"; import fetchApi from "../twui/utils/fetch/fetchApi"; +import useToast from "@/src/hooks/use-toast"; import type { ApiReqParams } from "@/src/types"; type Props = { @@ -10,6 +11,7 @@ type Props = { export default function GrabNextSubnetButton({ onGrab }: Props) { const [grabbing, setGrabbing] = useState(false); + const { toast } = useToast(); function grabNextSubnet() { setGrabbing(true); @@ -21,6 +23,8 @@ export default function GrabNextSubnetButton({ onGrab }: Props) { .then((res) => { if (res?.success && res.msg) { onGrab?.(res.msg); + } else { + toast({ msg: "Couldn't find a free subnet", color: "error" }); } }) .finally(() => { @@ -30,17 +34,16 @@ export default function GrabNextSubnetButton({ onGrab }: Props) { return ( ); } diff --git a/src/components/general/host-clients-panel.tsx b/src/components/general/host-clients-panel.tsx new file mode 100644 index 0000000..0242c9b --- /dev/null +++ b/src/components/general/host-clients-panel.tsx @@ -0,0 +1,80 @@ +import { useCallback, useContext } from "react"; +import { Plus, Users } from "lucide-react"; +import Button from "@/src/components/twui/layout/Button"; +import Panel from "./panel"; +import ClientsTable from "./clients-table"; +import EmptyState from "./empty-state"; +import useClientRulesMap from "@/src/hooks/use-client-rules-map"; +import { useAdminCrudGet } from "@/src/hooks/use-admin-crud-get"; +import { AppContext } from "@/src/pages/__root"; +import type { BUN_SQLITE_WGUI_CLIENTS_JOIN } from "@/src/types/sql-joins"; + +type Props = { + host_id: number; + title?: string; +}; + +/** Clients of one host. Seeds from `pageProps.clients`/`client_rules`. */ +export default function HostClientsPanel({ host_id, title = "Clients" }: Props) { + const { pageProps } = useContext(AppContext); + + const { res: clients, setRes } = useAdminCrudGet({ + table: "clients", + sql_query: { + query: { + host_id: { value: host_id }, + }, + order: { field: "created_at", strategy: "DESC" }, + }, + initial_res: pageProps?.clients || undefined, + }); + + const rules_by_client_id = useClientRulesMap(pageProps?.client_rules); + + const onDeleted = useCallback( + ({ id }: { id: number }) => + setRes((prev) => prev?.filter((client) => client.id != id)), + [], + ); + + const add_href = `/admin/clients/add?host_id=${host_id}`; + + return ( + + + Add client + + } + > + } + title="No clients on this host" + description="Clients get a tunnel IP from this host's subnet." + action={ + + } + /> + } + /> + + ); +} diff --git a/src/components/general/host-wg-ip-field.tsx b/src/components/general/host-wg-ip-field.tsx index 69a53dc..1e2b336 100644 --- a/src/components/general/host-wg-ip-field.tsx +++ b/src/components/general/host-wg-ip-field.tsx @@ -1,22 +1,16 @@ import { useEffect, useState, type RefObject } from "react"; -import { CircleCheck, Loader2, Network, TriangleAlert } from "lucide-react"; +import { Network } from "lucide-react"; import Input from "../twui/form/Input"; import Row from "../twui/layout/Row"; -import Span from "../twui/layout/Span"; -import Tag from "../twui/elements/Tag"; import Stack from "../twui/layout/Stack"; import fetchApi from "../twui/utils/fetch/fetchApi"; import type { ApiReqParams } from "@/src/types"; import GrabNextSubnetButton from "./grab-next-subnet-button"; +import HostWgIpStatus, { type HostWgIpFieldStatus } from "./host-wg-ip-status"; import type useFormInit from "@/src/hooks/use-form-init"; import type { BUN_SQLITE_WGUI_HOSTS } from "@/db/types/db"; -export type HostWgIpFieldStatus = - | "checking" - | "available" - | "not_available" - | "invalid" - | "current"; +export type { HostWgIpFieldStatus }; type Props = ReturnType> & { value: string; @@ -43,6 +37,7 @@ export default function HostWgIpField({ setForm, }: Props) { const [availability, setAvailability] = useState(); + const [field_key, setFieldKey] = useState(0); useEffect(() => { setAvailability(undefined); @@ -85,91 +80,34 @@ export default function HostWgIpField({ }, [ip_status, onStatus]); return ( - - + + { onChange?.(v); }} - prefix={} - suffix={ - - WireGuard IP Address - - } - title="Wireguard IP Address" + prefix={} + title="Host tunnel address" + placeholder="e.g. 10.8.0.1" showLabel componentRef={inputRef} autoFocus={autoFocus} + wrapperWrapperProps={{ className: "grow" }} /> - { setForm((prev) => ({ ...prev, wg_ip_address: ip, })); + setFieldKey((k) => k + 1); }} /> - - {ip_status === "invalid" ? ( - - - - Invalid IP - - - ) : ip_status === "current" ? ( - - - - Current value - - - ) : ip_status === "not_available" ? ( - - - - Not available - - - ) : ip_status === "available" ? ( - - - - Available - - - ) : ( - - - - Checking availability… - - - )} + ); } diff --git a/src/components/general/host-wg-ip-status.tsx b/src/components/general/host-wg-ip-status.tsx new file mode 100644 index 0000000..041cf28 --- /dev/null +++ b/src/components/general/host-wg-ip-status.tsx @@ -0,0 +1,30 @@ +import StatusDot, { type StatusTone } from "./status-dot"; + +export type HostWgIpFieldStatus = + | "checking" + | "available" + | "not_available" + | "invalid" + | "current"; + +const StatusDisplay: Record = { + checking: { tone: "neutral", label: "Checking availability…" }, + available: { tone: "success", label: "Subnet available" }, + not_available: { tone: "error", label: "Subnet already used by another host" }, + invalid: { tone: "error", label: "Use a host address ending in .1, e.g. 10.8.0.1" }, + current: { tone: "neutral", label: "Current value" }, +}; + +type Props = { + status: HostWgIpFieldStatus; +}; + +export default function HostWgIpStatus({ status }: Props) { + const { tone, label } = StatusDisplay[status]; + + return ( +
+ +
+ ); +} diff --git a/src/components/general/image-bg.tsx b/src/components/general/image-bg.tsx deleted file mode 100755 index d003dc6..0000000 --- a/src/components/general/image-bg.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import Img from "@/src/components/twui/layout/Img"; -import type { ComponentProps } from "react"; -import { twMerge } from "tailwind-merge"; - -type Props = { - src: string; - img_props?: Omit, "alt">; -}; - -export default function ImageBG({ src, img_props }: Props) { - return ( - bg image - ); -} diff --git a/src/components/general/inline-alert.tsx b/src/components/general/inline-alert.tsx new file mode 100644 index 0000000..0284a20 --- /dev/null +++ b/src/components/general/inline-alert.tsx @@ -0,0 +1,50 @@ +import Row from "@/src/components/twui/layout/Row"; +import Span from "@/src/components/twui/layout/Span"; +import { CircleCheck, Info, TriangleAlert, type LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import { twMerge } from "tailwind-merge"; + +type Tone = "error" | "success" | "warning" | "info"; + +type Props = { + tone: Tone; + msg: ReactNode; + className?: string; +}; + +const ToneStyles: Record = { + error: { + className: "border-error/30 bg-error/5 text-error dark:text-error-dark", + Icon: TriangleAlert, + }, + success: { + className: "border-success/30 bg-success/5 text-success dark:text-success-dark", + Icon: CircleCheck, + }, + warning: { + className: "border-warning/30 bg-warning/5 text-warning dark:text-warning-dark", + Icon: TriangleAlert, + }, + info: { + className: "border-line bg-surface-2 text-muted", + Icon: Info, + }, +}; + +export default function InlineAlert({ tone, msg, className }: Props) { + const { className: toneClassName, Icon } = ToneStyles[tone]; + + return ( + + + {msg} + + ); +} diff --git a/src/components/general/inner-page-hero-section.tsx b/src/components/general/inner-page-hero-section.tsx deleted file mode 100644 index 47b8c65..0000000 --- a/src/components/general/inner-page-hero-section.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import Section from "@/src/components/twui/layout/Section"; -import Container from "@/src/components/twui/layout/Container"; -import Stack from "@/src/components/twui/layout/Stack"; -import H1 from "@/src/components/twui/layout/H1"; -import Span from "@/src/components/twui/layout/Span"; -import BlurredImageBG from "@/src/components/general/blured-image-bg"; -import { twMerge } from "tailwind-merge"; - -type Props = { - title: string; - sub_title?: string; - description?: string; - bg_image?: string; -}; - -export default function InnerPageHeroSection({ - title, - bg_image, - description, - sub_title, -}: Props) { - return ( -
- - - {sub_title ? ( - {sub_title} - ) : null} -

{title}

- {description ? ( - - {description} - - ) : null} -
-
-
- ); -} diff --git a/src/components/general/key-value-list.tsx b/src/components/general/key-value-list.tsx new file mode 100644 index 0000000..a2fb2f6 --- /dev/null +++ b/src/components/general/key-value-list.tsx @@ -0,0 +1,18 @@ +import Stack from "@/src/components/twui/layout/Stack"; +import { twMerge } from "tailwind-merge"; +import KeyValueRow, { type KeyValueItem } from "./key-value-row"; + +type Props = { + items: KeyValueItem[]; + className?: string; +}; + +export default function KeyValueList({ items, className }: Props) { + return ( + + {items.map((item) => ( + + ))} + + ); +} diff --git a/src/components/general/key-value-row.tsx b/src/components/general/key-value-row.tsx new file mode 100644 index 0000000..2b75ccb --- /dev/null +++ b/src/components/general/key-value-row.tsx @@ -0,0 +1,35 @@ +import Stack from "@/src/components/twui/layout/Stack"; +import Span from "@/src/components/twui/layout/Span"; +import type { ReactNode } from "react"; +import { twMerge } from "tailwind-merge"; +import CopyableValue from "./copyable-value"; +import MutedText from "./muted-text"; + +export type KeyValueItem = { + key: string; + label: string; + value: ReactNode; + mono?: boolean; + /** Show a copy button for this raw string */ + copy_value?: string; +}; + +export default function KeyValueRow({ label, value, mono, copy_value }: KeyValueItem) { + return ( + + {label} + {copy_value ? ( + + ) : ( + + {value ?? "—"} + + )} + + ); +} diff --git a/src/components/general/login-shell.tsx b/src/components/general/login-shell.tsx deleted file mode 100644 index 413dec8..0000000 --- a/src/components/general/login-shell.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { twMerge } from "tailwind-merge"; -import Tag from "../twui/elements/Tag"; -import Row from "../twui/layout/Row"; -import { TriangleAlert, CircleCheck } from "lucide-react"; -import type { PropsWithChildren } from "react"; -import type { TWUIStatusType } from "../twui/hooks/useStatus"; -import Paper from "../twui/elements/Paper"; -import Center from "../twui/layout/Center"; -import Stack from "../twui/layout/Stack"; -import Border from "../twui/elements/Border"; -import Logo from "./logo"; - -type Props = PropsWithChildren & { - title?: string; - className?: string; - status?: TWUIStatusType; -}; - -export default function LoginShell({ children, className, status }: Props) { - return ( - - - - - {status?.error && status.msg ? ( - - - - {status.msg} - - - ) : null} - {status?.success && status.msg ? ( - - - - {status.msg} - - - ) : null} - {children} - - - ); -} diff --git a/src/components/general/logo-icon.tsx b/src/components/general/logo-icon.tsx index ea9b1e1..0052776 100644 --- a/src/components/general/logo-icon.tsx +++ b/src/components/general/logo-icon.tsx @@ -1,21 +1,33 @@ import type { ComponentProps } from "react"; +import { twMerge } from "tailwind-merge"; import Img from "../twui/layout/Img"; +import Span from "../twui/layout/Span"; type Props = { icon_size?: number; img_props?: Omit, "alt">; - white?: boolean; + className?: string; }; -export default function LogoIcon({ icon_size = 45, img_props, white }: Props) { +/** Swaps to the white mark in dark mode. */ +export default function LogoIcon({ icon_size = 32, img_props, className }: Props) { return ( - Logo Icon + + wg-ui + + ); } diff --git a/src/components/general/logo.tsx b/src/components/general/logo.tsx index 90eb9c7..c391a45 100644 --- a/src/components/general/logo.tsx +++ b/src/components/general/logo.tsx @@ -1,57 +1,24 @@ +import Link from "../twui/layout/Link"; import Row from "../twui/layout/Row"; import Span from "../twui/layout/Span"; -import type { ComponentProps, PropsWithChildren } from "react"; import { twMerge } from "tailwind-merge"; -import Stack from "../twui/layout/Stack"; import LogoIcon from "./logo-icon"; -import { SiteData } from "@/src/data/site-data"; type Props = { icon_size?: number; - text_wrapper_props?: ComponentProps; - text_props?: ComponentProps; - white?: boolean; + href?: string; + className?: string; }; -export default function Logo({ - icon_size = 45, - text_props, - text_wrapper_props, - white, -}: Props) { +export default function Logo({ icon_size = 28, href = "/admin", className }: Props) { return ( - - - - - - wg-ui - - + + + + + wg-ui + - - ); -} - -function HeroText({ - text_props, - children, -}: PropsWithChildren & { - text_props?: ComponentProps; -}) { - return ( - - {children} - + ); } diff --git a/src/components/general/main-hero-section.tsx b/src/components/general/main-hero-section.tsx deleted file mode 100755 index 0f6e9a2..0000000 --- a/src/components/general/main-hero-section.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import React, { type ComponentProps, type ReactNode } from "react"; -import Section from "@/src/components/twui/layout/Section"; -import Container from "@/src/components/twui/layout/Container"; -import Stack from "@/src/components/twui/layout/Stack"; -import H1 from "@/src/components/twui/layout/H1"; -import Row from "@/src/components/twui/layout/Row"; -import { twMerge } from "tailwind-merge"; -import Span from "@/src/components/twui/layout/Span"; -import BlurredImageBG from "@/src/components/general/blured-image-bg"; -import ImageBG from "@/src/components/general/image-bg"; -import type Img from "../twui/layout/Img"; - -type Props = { - title: string; - image_url?: string; - sub_title?: string; - cta_buttons?: ReactNode; - img_props?: Omit, "alt">; - img_component?: ReactNode; -}; - -export default function MainHeroSection({ - title, - cta_buttons, - sub_title, - image_url, - img_props, - img_component, -}: Props) { - const img_comp = img_component ? ( - img_component - ) : image_url ? ( - - ) : null; - - return ( - -
- - - -

- {title} -

- {sub_title ? ( - - {sub_title} - - ) : null} - {cta_buttons} -
- {/* */} -
-
-
-
- ); -} diff --git a/src/components/general/mono-text.tsx b/src/components/general/mono-text.tsx new file mode 100644 index 0000000..e7dc603 --- /dev/null +++ b/src/components/general/mono-text.tsx @@ -0,0 +1,21 @@ +import Span from "@/src/components/twui/layout/Span"; +import type { ComponentProps } from "react"; +import { twMerge } from "tailwind-merge"; + +type Props = ComponentProps & { + truncate_text?: boolean; +}; + +/** IPs, keys, paths, ports. */ +export default function MonoText({ truncate_text, className, ...props }: Props) { + return ( + + ); +} diff --git a/src/components/general/muted-text.tsx b/src/components/general/muted-text.tsx new file mode 100644 index 0000000..ecc15ef --- /dev/null +++ b/src/components/general/muted-text.tsx @@ -0,0 +1,40 @@ +import Span from "@/src/components/twui/layout/Span"; +import type { ComponentProps } from "react"; +import { twMerge } from "tailwind-merge"; + +type Props = Omit, "size"> & { + text_size?: "xs" | "sm"; + tone?: "muted" | "subtle" | "error" | "success" | "warning"; + mono?: boolean; +}; + +const ToneClasses: Record, string> = { + muted: "text-muted", + subtle: "text-subtle", + error: "text-error dark:text-error-dark", + success: "text-success dark:text-success-dark", + warning: "text-warning dark:text-warning-dark", +}; + +/** + * Secondary text. Use instead of hand-written `text-sm opacity…` strings. + */ +export default function MutedText({ + text_size = "sm", + tone = "muted", + mono, + className, + ...props +}: Props) { + return ( + + ); +} diff --git a/src/components/general/page-body.tsx b/src/components/general/page-body.tsx new file mode 100644 index 0000000..de86d87 --- /dev/null +++ b/src/components/general/page-body.tsx @@ -0,0 +1,20 @@ +import Stack from "@/src/components/twui/layout/Stack"; +import type { PropsWithChildren } from "react"; +import { twMerge } from "tailwind-merge"; + +type Props = PropsWithChildren & { + className?: string; +}; + +export default function PageBody({ children, className }: Props) { + return ( + + {children} + + ); +} diff --git a/src/components/general/page-header/index.tsx b/src/components/general/page-header/index.tsx new file mode 100644 index 0000000..aeaf5c0 --- /dev/null +++ b/src/components/general/page-header/index.tsx @@ -0,0 +1,67 @@ +import H1 from "@/src/components/twui/layout/H1"; +import Link from "@/src/components/twui/layout/Link"; +import P from "@/src/components/twui/layout/P"; +import Row from "@/src/components/twui/layout/Row"; +import Stack from "@/src/components/twui/layout/Stack"; +import { ArrowLeft } from "lucide-react"; +import type { ReactNode } from "react"; +import type { ActionMenuItem } from "../action-menu/types"; +import PageHeaderActions from "./page-header-actions"; + +type Props = { + title: ReactNode; + description?: ReactNode; + /** Inline status line under the title (status dots, mono values) */ + meta?: ReactNode; + primary_action?: ActionMenuItem; + actions?: ActionMenuItem[]; + back?: { href: string; title: string }; + /** Rendered under the title row, e.g. a `SubNav` */ + nav?: ReactNode; +}; + +export default function PageHeader({ + title, + description, + meta, + primary_action, + actions, + back, + nav, +}: Props) { + return ( + + {back ? ( + + + + {back.title} + + + ) : null} + + +

{title}

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

+ {description} +

+ ) : ( + description + ) + ) : null} + {meta ? {meta} : null} +
+ +
+ {nav ?
{nav}
: null} +
+ ); +} diff --git a/src/components/general/page-header/page-header-action.tsx b/src/components/general/page-header/page-header-action.tsx new file mode 100644 index 0000000..f299987 --- /dev/null +++ b/src/components/general/page-header/page-header-action.tsx @@ -0,0 +1,34 @@ +import Button from "@/src/components/twui/layout/Button"; +import type { ActionMenuItem } from "../action-menu/types"; + +type Props = ActionMenuItem & { + primary?: boolean; +}; + +export default function PageHeaderAction({ + title, + href, + onClick, + icon, + tone, + disabled, + loading, + primary, +}: Props) { + return ( + + ); +} diff --git a/src/components/general/page-header/page-header-actions.tsx b/src/components/general/page-header/page-header-actions.tsx new file mode 100644 index 0000000..e19a5d2 --- /dev/null +++ b/src/components/general/page-header/page-header-actions.tsx @@ -0,0 +1,37 @@ +import Row from "@/src/components/twui/layout/Row"; +import ActionMenu from "../action-menu"; +import type { ActionMenuItem } from "../action-menu/types"; +import PageHeaderAction from "./page-header-action"; + +type Props = { + primary_action?: ActionMenuItem; + actions?: ActionMenuItem[]; +}; + +/** + * From `sm` up every action is a button. Below `sm` only the primary action + * stays visible and the rest collapse into an overflow menu. + */ +export default function PageHeaderActions({ primary_action, actions }: Props) { + const secondary = actions || []; + + return ( + + + {secondary.map((action) => ( + + ))} + + {secondary.length ? ( + + ) : null} + {primary_action ? ( + + ) : null} + + ); +} diff --git a/src/components/general/panel/index.tsx b/src/components/general/panel/index.tsx new file mode 100644 index 0000000..68f00d1 --- /dev/null +++ b/src/components/general/panel/index.tsx @@ -0,0 +1,64 @@ +import Card from "@/src/components/twui/elements/Card"; +import Stack from "@/src/components/twui/layout/Stack"; +import type { PropsWithChildren, ReactNode } from "react"; +import { twMerge } from "tailwind-merge"; +import PanelHeader from "./panel-header"; + +type Props = PropsWithChildren & { + title?: ReactNode; + description?: ReactNode; + actions?: ReactNode; + /** Remove body padding (tables, lists) */ + flush?: boolean; + footer?: ReactNode; + className?: string; + body_class_name?: string; +}; + +/** + * Bordered surface with an optional header. The app's single card style. + */ +export default function Panel({ + title, + description, + actions, + flush, + footer, + className, + body_class_name, + children, +}: Props) { + const has_header = Boolean(title || description || actions); + + return ( + + {has_header ? ( + + ) : null} + + {children} + + {footer ? ( + + {footer} + + ) : null} + + ); +} diff --git a/src/components/general/panel/panel-header.tsx b/src/components/general/panel/panel-header.tsx new file mode 100644 index 0000000..33085bf --- /dev/null +++ b/src/components/general/panel/panel-header.tsx @@ -0,0 +1,23 @@ +import H2 from "@/src/components/twui/layout/H2"; +import Row from "@/src/components/twui/layout/Row"; +import Stack from "@/src/components/twui/layout/Stack"; +import type { ReactNode } from "react"; +import MutedText from "../muted-text"; + +type Props = { + title?: ReactNode; + description?: ReactNode; + actions?: ReactNode; +}; + +export default function PanelHeader({ title, description, actions }: Props) { + return ( + + + {title ?

{title}

: null} + {description ? {description} : null} +
+ {actions ? {actions} : null} +
+ ); +} diff --git a/src/components/general/rect-image.tsx b/src/components/general/rect-image.tsx deleted file mode 100755 index 91ffaff..0000000 --- a/src/components/general/rect-image.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import Img from "@/src/components/twui/layout/Img"; -import { twMerge } from "tailwind-merge"; -import Stack from "../twui/layout/Stack"; - -type Props = { - src: string; - width?: number; - height?: number; - alt?: string; -}; - -export default function RectImage({ - src, - height = 300, - width = 200, - alt, -}: Props) { - const img_component = ( - {alt - ); - - return ( - <> - - - {img_component} - - - ); -} diff --git a/src/components/general/section-label.tsx b/src/components/general/section-label.tsx new file mode 100644 index 0000000..35378b5 --- /dev/null +++ b/src/components/general/section-label.tsx @@ -0,0 +1,20 @@ +import Span from "@/src/components/twui/layout/Span"; +import type { PropsWithChildren } from "react"; +import { twMerge } from "tailwind-merge"; + +type Props = PropsWithChildren & { + className?: string; +}; + +export default function SectionLabel({ children, className }: Props) { + return ( + + {children} + + ); +} diff --git a/src/components/general/setup-main-host-button.tsx b/src/components/general/setup-main-host-button.tsx deleted file mode 100755 index adcff92..0000000 --- a/src/components/general/setup-main-host-button.tsx +++ /dev/null @@ -1,93 +0,0 @@ -import { useState, type ComponentProps } from "react"; -import Button from "../twui/layout/Button"; -import useStatus from "../twui/hooks/useStatus"; -import fetchApi from "../twui/utils/fetch/fetchApi"; -import type { ApiReqParams } from "@/src/types"; -import Row from "../twui/layout/Row"; -import Stack from "../twui/layout/Stack"; -import { CircleCheck, TriangleAlert } from "lucide-react"; -import Span from "../twui/layout/Span"; -import Tag from "../twui/elements/Tag"; -import { AppData } from "@/src/data/app-data"; -import type { APIResponseObject } from "@moduletrace/bunext/types"; -import HostWgIpField, { type HostWgIpFieldStatus } from "./host-wg-ip-field"; -import GrabNextSubnetButton from "./grab-next-subnet-button"; - -type Props = { - button_props?: Omit, "title">; -}; - -export default function SetupMainHostButton({ button_props }: Props) { - const { loading, setLoading, status, setStatus } = useStatus(); - - const [wgIP, setWgIP] = useState(AppData["DefaultPrivateIP"]); - const [ipStatus, setIpStatus] = useState("checking"); - - return ( - - {status?.error && status.msg ? ( - - - - {status.msg} - - - ) : null} - {status?.success && status.msg ? ( - - - - {status.msg} - - - ) : null} - - - - - - - - ); -} diff --git a/src/components/general/stat-tile.tsx b/src/components/general/stat-tile.tsx new file mode 100644 index 0000000..1c872e4 --- /dev/null +++ b/src/components/general/stat-tile.tsx @@ -0,0 +1,56 @@ +import Card from "@/src/components/twui/elements/Card"; +import LoadingBlock from "@/src/components/twui/elements/LoadingBlock"; +import Span from "@/src/components/twui/layout/Span"; +import Stack from "@/src/components/twui/layout/Stack"; +import type { ReactNode } from "react"; +import { twMerge } from "tailwind-merge"; +import MutedText from "./muted-text"; +import SectionLabel from "./section-label"; + +type Props = { + label: string; + value?: ReactNode; + hint?: ReactNode; + loading?: boolean; + mono?: boolean; + href?: string; + className?: string; +}; + +export default function StatTile({ + label, + value, + hint, + loading, + mono, + href, + className, +}: Props) { + const is_loading = loading || value === undefined; + + return ( + + {label} + + {is_loading ? ( + + ) : ( + + {value} + + )} + {hint ? {hint} : null} + + + ); +} diff --git a/src/components/general/status-dot.tsx b/src/components/general/status-dot.tsx new file mode 100644 index 0000000..4b74df7 --- /dev/null +++ b/src/components/general/status-dot.tsx @@ -0,0 +1,52 @@ +import Row from "@/src/components/twui/layout/Row"; +import Span from "@/src/components/twui/layout/Span"; +import { twMerge } from "tailwind-merge"; + +export type StatusTone = "success" | "warning" | "error" | "neutral"; + +type Props = { + tone: StatusTone; + label?: string; + /** Keep the label for screen readers only */ + hide_label?: boolean; + pulse?: boolean; + className?: string; +}; + +const DotClasses: Record = { + success: "bg-success dark:bg-success-dark", + warning: "bg-warning dark:bg-warning-dark", + error: "bg-error dark:bg-error-dark", + neutral: "bg-subtle", +}; + +export default function StatusDot({ + tone, + label, + hide_label, + pulse, + className, +}: Props) { + return ( + + + {label ? ( + + {label} + + ) : null} + + ); +} diff --git a/src/components/general/sub-nav/index.tsx b/src/components/general/sub-nav/index.tsx new file mode 100644 index 0000000..49c569d --- /dev/null +++ b/src/components/general/sub-nav/index.tsx @@ -0,0 +1,32 @@ +import { useContext } from "react"; +import Row from "@/src/components/twui/layout/Row"; +import { AppContext } from "@/src/pages/__root"; +import SubNavItem, { type SubNavItemType } from "./sub-nav-item"; + +type Props = { + label: string; + items: SubNavItemType[]; +}; + +/** Link tabs between sibling routes (unlike twui `Tabs`, each is a URL). */ +export default function SubNav({ label, items }: Props) { + const { pageProps } = useContext(AppContext); + const pathname = pageProps?.url?.pathname; + + return ( + + ); +} diff --git a/src/components/general/sub-nav/sub-nav-item.tsx b/src/components/general/sub-nav/sub-nav-item.tsx new file mode 100644 index 0000000..8c5cc92 --- /dev/null +++ b/src/components/general/sub-nav/sub-nav-item.tsx @@ -0,0 +1,33 @@ +import Link from "@/src/components/twui/layout/Link"; +import Row from "@/src/components/twui/layout/Row"; +import type { LucideIcon } from "lucide-react"; +import { twMerge } from "tailwind-merge"; + +export type SubNavItemType = { + title: string; + href: string; + Icon?: LucideIcon; +}; + +type Props = SubNavItemType & { + active: boolean; +}; + +export default function SubNavItem({ title, href, Icon, active }: Props) { + return ( + + + {Icon ? : null} + {title} + + + ); +} diff --git a/src/components/general/tagline-section.tsx b/src/components/general/tagline-section.tsx deleted file mode 100755 index e875639..0000000 --- a/src/components/general/tagline-section.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import Section from "@/src/components/twui/layout/Section"; -import Container from "@/src/components/twui/layout/Container"; -import H2 from "@/src/components/twui/layout/H2"; -import Stack from "@/src/components/twui/layout/Stack"; -import type { TWUILucideIconName } from "../twui/elements/lucide-icon"; -import LucideIcon from "../twui/elements/lucide-icon"; -import Span from "../twui/layout/Span"; -import type { ReactNode } from "react"; - -type Props = { - tagline: string; - icon_name?: TWUILucideIconName; - sub_title?: string; - post_component?: ReactNode; -}; - -export default function TaglineSection({ - tagline, - icon_name, - sub_title, - post_component, -}: Props) { - /** - * Render component - */ - return ( -
- - - {icon_name && ( - - )} -

- {tagline} -

- {sub_title ? ( - - {sub_title} - - ) : null} - {post_component} -
-
-
- ); -} diff --git a/src/components/general/theme/theme-script.tsx b/src/components/general/theme/theme-script.tsx new file mode 100644 index 0000000..de053d9 --- /dev/null +++ b/src/components/general/theme/theme-script.tsx @@ -0,0 +1,11 @@ +import { ThemeCookieName } from "@/src/dict/theme-dict"; + +/** + * Runs before first paint so the page never flashes the wrong theme. Reads + * the cookie in the browser; no server value is interpolated into the script. + */ +const script = `(function(){try{var m=(document.cookie.match(/(?:^|; )${ThemeCookieName}=(light|dark|system)/)||[])[1]||"system";var d=m==="dark"||(m==="system"&&window.matchMedia("(prefers-color-scheme: dark)").matches);document.documentElement.classList.toggle("dark",d);}catch(e){}})();`; + +export default function ThemeScript() { + return