feat: refined ui
This commit is contained in:
@@ -26,3 +26,4 @@ src/lib/paraglide
|
||||
project.inlang/cache/
|
||||
# SQLite
|
||||
*.db
|
||||
CONTEXT.md
|
||||
@@ -1,24 +1,23 @@
|
||||
# Nadir Web UI
|
||||
|
||||
SvelteKit dashboard for [nadir-agent](https://tea.urania.dev/urania/nadir-agent) -
|
||||
SvelteKit dashboard for [nadir-agent](https://tea.urania.dev/urania/nadir-agent) —
|
||||
a central web console that talks to one or many Nadir backend nodes over their
|
||||
typed REST API.
|
||||
|
||||
The agent does the system-administration work (systemd services, users,
|
||||
packages, networking, audit, terminal, ...). This UI is the operator's view of
|
||||
it: sign in, register machines with their bearer token, see live host metrics
|
||||
on the dashboard, and drive everyday tasks from the browser.
|
||||
The agent does the system-administration work (systemd services, users, packages,
|
||||
networking, storage, audit, terminal, ...). This UI is the operator's view of it:
|
||||
sign in, register machines with their bearer token, see live host metrics on the
|
||||
dashboard, and drive everyday tasks from the browser.
|
||||
|
||||
---
|
||||
|
||||
## Stack
|
||||
|
||||
- **SvelteKit** (Svelte 5, adapter-node) + **TailwindCSS 4** + **shadcn-svelte**
|
||||
- **SvelteKit 2** (Svelte 5, adapter-node) + **TailwindCSS 4** + **shadcn-svelte**
|
||||
- **Bun** as the runtime / package manager / dev server
|
||||
- **Drizzle ORM** on **SQLite** (libSQL driver) for the UI's own state (users,
|
||||
machines, encrypted tokens)
|
||||
- **Better Auth** with email/password, OAuth, optional 2FA, admin & username
|
||||
plugins
|
||||
machines, encrypted tokens, settings)
|
||||
- **Better Auth** with email/password, OAuth, optional 2FA, admin & username plugins
|
||||
- **Paraglide** for i18n (messages in `messages/`)
|
||||
- **openapi-fetch** + typed client generated from the nadir-agent OpenAPI spec
|
||||
(`src/lib/server/nadir-agent/schema.d.ts`)
|
||||
@@ -41,15 +40,21 @@ bun run dev # starts on http://localhost:5173
|
||||
|
||||
Set in `.env` (validated at startup via `src/lib/const/schema.ts`):
|
||||
|
||||
| Var | Default | Purpose |
|
||||
| --------------------------- | ----------------------- | ------------------------------------------------------ |
|
||||
| `CRYPTO_SECRET` | (required) | Encrypts machine bearer tokens at rest in the local DB |
|
||||
| `DATABASE_URL` | `file:db.sqlite` | libSQL connection string |
|
||||
| `ORIGIN` | `http://localhost:5173` | Public origin (used by Better Auth) |
|
||||
| `DISABLE_SIGNUP` | `false` | Lock down registration |
|
||||
| `ENABLE_2FA` | `false` | Enable the TOTP 2FA flow |
|
||||
| `ENABLE_EMAIL_AND_PASSWORD` | `true` | Toggle email/password auth |
|
||||
| `SMTP_*` | - | Outbound mail for verification / reset / 2FA |
|
||||
| Var | Default | Purpose |
|
||||
| --------------------------- | ----------------------- | ---------------------------------------------------------- |
|
||||
| `CRYPTO_SECRET` | (required) | Encrypts machine bearer tokens at rest in the local DB |
|
||||
| `DATABASE_URL` | `file:db.sqlite` | libSQL connection string |
|
||||
| `ORIGIN` | `http://localhost:5173` | Public origin (used by Better Auth) |
|
||||
| `REPOSITORY_URL` | (required) | Gitea API URL for the nadir-agent releases; enables `GET /install.sh` |
|
||||
| `DISABLE_SIGNUP` | `false` | Lock down registration |
|
||||
| `ENABLE_2FA` | `false` | Enable the TOTP 2FA flow |
|
||||
| `ENABLE_EMAIL_AND_PASSWORD` | `true` | Toggle email/password auth |
|
||||
| `SMTP_HOST` | - | Outbound mail server hostname |
|
||||
| `SMTP_PORT` | - | SMTP port |
|
||||
| `SMTP_USER` | - | SMTP username |
|
||||
| `SMTP_PASS` | - | SMTP password |
|
||||
| `SMTP_FROM` | - | From address for verification / reset / 2FA emails |
|
||||
| `SMTP_SSL` | - | Use SSL/TLS for SMTP |
|
||||
|
||||
OAuth providers (optional) live in `config/oauth.json` and are passed straight
|
||||
to Better Auth's `genericOAuth` plugin.
|
||||
@@ -59,19 +64,23 @@ to Better Auth's `genericOAuth` plugin.
|
||||
## Scripts
|
||||
|
||||
```sh
|
||||
bun run dev # vite dev server
|
||||
bun run type:generate # generate typed client for nadir-agent
|
||||
bun run dev # vite dev server (binds --host for LAN access)
|
||||
bun run dev:types # regenerate typed client, then start dev server
|
||||
bun run type:generate # generate typed client from live nadir-agent OpenAPI spec
|
||||
bun run build # production build (adapter-node -> build/)
|
||||
bun run preview # preview the production build
|
||||
bun run check # svelte-check
|
||||
bun run check # svelte-check (must pass 0 errors before done)
|
||||
bun run lint # prettier + eslint
|
||||
bun run format # prettier --write
|
||||
bun run db:push # apply schema to the DB
|
||||
bun run db:push # apply schema to the DB (dev)
|
||||
bun run db:generate # generate migration from schema changes
|
||||
bun run db:migrate # run pending migrations
|
||||
bun run db:studio # drizzle-kit studio
|
||||
```
|
||||
|
||||
`type:generate` fetches the OpenAPI spec from the live agent configured in the script
|
||||
(`http://100.64.0.189:9999/openapi.json` by default — adjust for your environment).
|
||||
|
||||
---
|
||||
|
||||
## Project layout
|
||||
@@ -79,27 +88,105 @@ bun run db:studio # drizzle-kit studio
|
||||
```
|
||||
src/
|
||||
routes/
|
||||
auth/ sign-in, sign-up, forgot/reset password, 2fa setup
|
||||
dashboard/ machine list and per-machine live dashboard
|
||||
system/ date/time, localization
|
||||
admin/ users, config
|
||||
api/ internal endpoints (e.g. emailer)
|
||||
auth/ sign-in, sign-up, forgot/reset password, 2fa, 2fa setup
|
||||
dashboard/ machine list; per-machine live dashboard
|
||||
[machineId]/
|
||||
networking/ interfaces (list/detail/configure), routes, DNS, /etc/hosts
|
||||
packages/ installed packages, available updates
|
||||
services/ service list + per-service control and logs
|
||||
storage/ active mounts, fstab entries
|
||||
system/ overview, hostname, date-time, localization, power, nadir self-update
|
||||
users/ local accounts, groups (list + detail)
|
||||
admin/ dashboard users (Better Auth admin), config
|
||||
docs/ built-in operator documentation (architecture, installation,
|
||||
security, limitations)
|
||||
api/emailer/send/ internal SvelteKit server route for outbound email
|
||||
install.sh/ serves the nadir-agent installer shell script (enabled
|
||||
when REPOSITORY_URL is set; returns 404 otherwise)
|
||||
lib/
|
||||
auth/ Better Auth server + client
|
||||
components/ shadcn-svelte UI + dashboard panels (cpu, network, storage, ...)
|
||||
machines/ valibot schemas
|
||||
remotes/ SvelteKit remote functions (server.remote.ts, machines.remote.ts, ...)
|
||||
auth/ Better Auth server instance + browser client
|
||||
components/ shadcn-svelte UI + reusable dashboard panels
|
||||
remotes/ SvelteKit remote functions, one file per agent domain:
|
||||
auth, config, machines, networking, packages,
|
||||
pam-users, server, services, storage, system,
|
||||
terminal, users, utils
|
||||
schemas/ valibot schemas (shared validation types)
|
||||
server/
|
||||
db/ Drizzle schema + custom encrypted column type
|
||||
emails/ nodemailer + better-svelte-email templates
|
||||
nadir-agent/ generated OpenAPI types + typed client
|
||||
paraglide/ generated i18n runtime
|
||||
messages/ translation source (en, ...)
|
||||
config/oauth.json optional OAuth providers passed to Better Auth
|
||||
db/ Drizzle schema + auth-schema + encrypted column type
|
||||
emails/ nodemailer + better-svelte-email templates
|
||||
nadir-agent/ generated OpenAPI types + typed fetch client
|
||||
terminal/ SSH session management (open/stream/write/resize/close)
|
||||
paraglide/ generated i18n runtime
|
||||
messages/ translation source (en.json, ...)
|
||||
config/oauth.json optional OAuth providers passed to Better Auth
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Remote functions
|
||||
|
||||
`src/lib/remotes/*.remote.ts` are the SvelteKit server-side functions the browser
|
||||
triggers via `query` / `command`. They call the agent through
|
||||
`nadirForMachine(machineId)`, which handles token decryption and 401/404/500 routing.
|
||||
After a mutating `command`, the relevant `query`s are `.refresh()`ed inside the command.
|
||||
|
||||
| File | Agent domain covered |
|
||||
| ------------------------ | --------------------------------------------- |
|
||||
| `auth.remote.ts` | Sign-in, sign-out, session |
|
||||
| `config.remote.ts` | UI application config |
|
||||
| `machines.remote.ts` | Machine CRUD, reordering, health batch |
|
||||
| `networking.remote.ts` | Interfaces, routing, DNS, `/etc/hosts` |
|
||||
| `packages.remote.ts` | Installed, updates, install/remove (SSE) |
|
||||
| `pam-users.remote.ts` | System user listing (PAM side) |
|
||||
| `server.remote.ts` | Agent self-update (`POST /api/update`) |
|
||||
| `services.remote.ts` | systemd unit list/control/logs (SSE) |
|
||||
| `storage.remote.ts` | Mounts, fstab entries |
|
||||
| `system.remote.ts` | Host info, hostname, time/tz/NTP, locale, power |
|
||||
| `terminal.remote.ts` | SSH terminal sessions (open/stream/write/resize/close) |
|
||||
| `users.remote.ts` | Local accounts + groups |
|
||||
| `utils.ts` | Shared helpers (not a remote file) |
|
||||
|
||||
---
|
||||
|
||||
## Database
|
||||
|
||||
The UI keeps its own SQLite database (Drizzle ORM, libSQL driver):
|
||||
|
||||
| Table | Contents |
|
||||
| -------------- | --------------------------------------------------------------------------------- |
|
||||
| `user` | Dashboard user (email, username, name, UI role, ban state) |
|
||||
| `session` | Better Auth sessions |
|
||||
| `account` | Better Auth OAuth accounts |
|
||||
| `verification` | Better Auth verification tokens |
|
||||
| `twoFactor` | TOTP secrets / backup codes |
|
||||
| `machines` | `{ id, name, address, order, token(encrypted) }` — registered agent nodes |
|
||||
| `settings` | Key/value store for UI-side application settings |
|
||||
|
||||
`token` in `machines` is stored with a custom `encryptedText` column type and is
|
||||
decrypted on demand (`autoDecrypt: false`) only when making a server-side call to
|
||||
the agent.
|
||||
|
||||
---
|
||||
|
||||
## SSH Terminal
|
||||
|
||||
The UI includes a browser-based SSH terminal (`terminal.remote.ts`). Operators
|
||||
can open an SSH session to any registered machine's host directly from the
|
||||
dashboard. Sessions are managed server-side (SSH client runs in the SvelteKit
|
||||
server process); the browser streams output via SSE and sends keystrokes via
|
||||
`command`. Sessions are closed on disconnect or explicit `closeSshTerminal`.
|
||||
|
||||
---
|
||||
|
||||
## `GET /install.sh`
|
||||
|
||||
When `REPOSITORY_URL` is set, the UI serves a shell installer at `/install.sh`
|
||||
that bootstraps `nadir-agent` on a new host. The script is generated from
|
||||
`src/routes/install.sh/install.sh.tmpl` with the release repo URL injected. If
|
||||
`REPOSITORY_URL` is not set the route returns 404 with an explanation.
|
||||
|
||||
---
|
||||
|
||||
## Deploying
|
||||
|
||||
`adapter-node` produces a plain Node/Bun server under `build/`:
|
||||
@@ -110,8 +197,10 @@ PORT=3000 ORIGIN=https://nadir.example.com bun run build/index.js
|
||||
```
|
||||
|
||||
Put it behind the same reverse proxy you use for nadir-agent, or co-host them.
|
||||
The agent's CSRF rules apply when the UI calls it cross-origin - see the agent
|
||||
README's _Connecting a dashboard_ section.
|
||||
The agent's CSRF rules apply when the UI calls it cross-origin — see the agent
|
||||
README's _Connecting a dashboard_ section. The recommended pattern is to call
|
||||
the agent server-to-server from the SvelteKit server layer; the browser never
|
||||
holds or sends the agent token.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
ALTER TABLE `machines` ADD `ca_cert` text;
|
||||
@@ -0,0 +1,5 @@
|
||||
CREATE TABLE `settings` (
|
||||
`key` text PRIMARY KEY,
|
||||
`updated_at` integer NOT NULL,
|
||||
`value` text NOT NULL
|
||||
);
|
||||
+107
-74
@@ -1,23 +1,27 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "sqlite",
|
||||
"id": "1a550330-2944-441c-aea2-18b98455929c",
|
||||
"id": "208e7c97-0071-4c2f-bc0c-7c4cbf18af2e",
|
||||
"prevIds": [
|
||||
"b3403d40-d4c3-4f1a-8d1d-763c548f5bc7"
|
||||
],
|
||||
"ddl": [
|
||||
{
|
||||
"name": "machines",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "account",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "machines",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "session",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "settings",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"name": "two_factor",
|
||||
"entityType": "tables"
|
||||
@@ -30,66 +34,6 @@
|
||||
"name": "verification",
|
||||
"entityType": "tables"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'http://127.0.0.1:9999'",
|
||||
"generated": null,
|
||||
"name": "address",
|
||||
"entityType": "columns",
|
||||
"table": "machines"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "ca_cert",
|
||||
"entityType": "columns",
|
||||
"table": "machines"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "machines"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "name",
|
||||
"entityType": "columns",
|
||||
"table": "machines"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "order",
|
||||
"entityType": "columns",
|
||||
"table": "machines"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "token",
|
||||
"entityType": "columns",
|
||||
"table": "machines"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
@@ -220,6 +164,56 @@
|
||||
"entityType": "columns",
|
||||
"table": "account"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'http://127.0.0.1:9999'",
|
||||
"generated": null,
|
||||
"name": "address",
|
||||
"entityType": "columns",
|
||||
"table": "machines"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "id",
|
||||
"entityType": "columns",
|
||||
"table": "machines"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "name",
|
||||
"entityType": "columns",
|
||||
"table": "machines"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "order",
|
||||
"entityType": "columns",
|
||||
"table": "machines"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "token",
|
||||
"entityType": "columns",
|
||||
"table": "machines"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
@@ -310,6 +304,36 @@
|
||||
"entityType": "columns",
|
||||
"table": "session"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "key",
|
||||
"entityType": "columns",
|
||||
"table": "settings"
|
||||
},
|
||||
{
|
||||
"type": "integer",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "updated_at",
|
||||
"entityType": "columns",
|
||||
"table": "settings"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": null,
|
||||
"generated": null,
|
||||
"name": "value",
|
||||
"entityType": "columns",
|
||||
"table": "settings"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"notNull": true,
|
||||
@@ -605,15 +629,6 @@
|
||||
"entityType": "fks",
|
||||
"table": "two_factor"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "machines_pk",
|
||||
"table": "machines",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
@@ -623,6 +638,15 @@
|
||||
"table": "account",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "machines_pk",
|
||||
"table": "machines",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
@@ -632,6 +656,15 @@
|
||||
"table": "session",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"key"
|
||||
],
|
||||
"nameExplicit": false,
|
||||
"name": "settings_pk",
|
||||
"table": "settings",
|
||||
"entityType": "pks"
|
||||
},
|
||||
{
|
||||
"columns": [
|
||||
"id"
|
||||
+37
-1
@@ -1,6 +1,39 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"account": "Account",
|
||||
"admin_config_auth_description": "Sign-in, registration and second-factor policy.",
|
||||
"admin_config_auth_title": "Authentication",
|
||||
"admin_config_description": "Runtime configuration for the dashboard. Stored in the database; falls back to environment values.",
|
||||
"admin_config_disable_signup": "Disable public registration",
|
||||
"admin_config_disable_signup_hint": "Only existing admins can invite new users.",
|
||||
"admin_config_enable_2fa": "Require two-factor authentication",
|
||||
"admin_config_enable_2fa_hint": "All users must set up TOTP on next sign-in.",
|
||||
"admin_config_enable_email_password": "Allow email + password sign-in",
|
||||
"admin_config_enable_email_password_hint": "Disable to restrict logins to OAuth providers.",
|
||||
"admin_config_origin": "Public dashboard URL",
|
||||
"admin_config_origin_hint": "Used to build auth callback URLs and email links.",
|
||||
"admin_config_restart_required": "applies after restart",
|
||||
"admin_config_smtp_description": "Outbound mail for password resets, verification and 2FA codes. Password is encrypted at rest.",
|
||||
"admin_config_smtp_from": "From address",
|
||||
"admin_config_smtp_host": "Host",
|
||||
"admin_config_smtp_missing": "Configure SMTP host before sending a test email.",
|
||||
"admin_config_smtp_pass": "Password",
|
||||
"admin_config_smtp_pass_hint": "Leave blank to keep the current password.",
|
||||
"admin_config_smtp_port": "Port",
|
||||
"admin_config_smtp_ssl": "Use TLS/SSL",
|
||||
"admin_config_smtp_ssl_hint": "Enable for port 465; leave off for STARTTLS on 587.",
|
||||
"admin_config_smtp_test": "Send test",
|
||||
"admin_config_smtp_test_body": "This is a test email from your Nadir dashboard.",
|
||||
"admin_config_smtp_test_sent": "Test email sent.",
|
||||
"admin_config_smtp_test_subject": "Nadir SMTP test",
|
||||
"admin_config_smtp_test_to": "Send test email to",
|
||||
"admin_config_smtp_title": "Mail (SMTP)",
|
||||
"admin_config_smtp_user": "Username",
|
||||
"admin_config_social_client_id": "Client ID",
|
||||
"admin_config_social_client_secret": "Client secret",
|
||||
"admin_config_social_description": "OAuth client credentials for sign-in providers. Secrets are encrypted at rest.",
|
||||
"admin_config_social_secret_hint": "Leave blank to keep the current secret.",
|
||||
"admin_config_social_title": "Social providers",
|
||||
"agent_update_failed": "Agent update did not complete - check `nadir logs` on the host",
|
||||
"agent_update_started": "Updating agent... ({from} → {to})",
|
||||
"agent_update_success": "Agent updated to {version}",
|
||||
@@ -138,10 +171,12 @@
|
||||
"errors_address_invalid": "Enter a valid URL, e.g. http://127.0.0.1:9999",
|
||||
"errors_email_invalid": "Enter a valid email address",
|
||||
"errors_generic": "An error occurred during this operation, please review Nadir Logs for more information.",
|
||||
"errors_hostname_invalid": "Enter a valid hostname (RFC 1123).",
|
||||
"errors_invalid_code": "Invalid or expired code, try again.",
|
||||
"errors_non_empty": "This field is required",
|
||||
"errors_not_found": "This item has not been found",
|
||||
"errors_password_too_short": "Password must be at least {min} characters",
|
||||
"errors_port_invalid": "Enter a port between 1 and 65535",
|
||||
"errors_password_weak": "Use upper- and lower-case letters and at least one number.",
|
||||
"errors_passwords_no_match": "Passwords do not match",
|
||||
"errors_unauthenticated": "Unauthenticated",
|
||||
@@ -223,7 +258,8 @@
|
||||
"landing_how_step1_desc": "Deploy the stateless web dashboard using Docker. This interface will coordinate your registered agents. Access the web setup at port 3000 to configure your dashboard admin account.",
|
||||
"landing_how_step2_title": "Bootstrap the target server",
|
||||
"landing_how_step2_desc": "Run the installation script on the host machine you want to manage. The script automatically detects the host architecture (amd64/arm64), fetches the latest tagged binary release from Gitea, verifies SHA-256 integrity, configures a systemd unit, and installs a PAM configuration file.",
|
||||
"landing_security_note": "Security note: The bootstrap installer requires root privileges. It generates a self-signed TLS certificate automatically if secure TLS is configured.",
|
||||
"landing_security_note": "Security note:",
|
||||
"landing_security_note_text": "The bootstrap installer requires root privileges. It generates a self-signed TLS certificate automatically if secure TLS is configured.",
|
||||
"landing_security_architecture_title": "Deployment security and architecture",
|
||||
"landing_security_architecture_desc": "Because the nadir-agent operates with root privileges, it should never be exposed directly to the public internet. By default, it binds to localhost. It is designed to be co-located on the same server as the Web UI communicating over loopback, or securely accessed across nodes over a private VPN (such as WireGuard, Tailscale, or Netbird). While agent installations support generating self-signed TLS certificates automatically, operating within a trusted private network boundary remains the recommended deployment model.",
|
||||
"landing_how_step3_title": "Link in the dashboard",
|
||||
|
||||
+6
-4
@@ -1,13 +1,15 @@
|
||||
import { type Handle, redirect } from '@sveltejs/kit';
|
||||
import { sequence } from '@sveltejs/kit/hooks';
|
||||
import { building, dev } from '$app/environment';
|
||||
import { auth } from '$lib/auth/server';
|
||||
import { env } from '$lib/const/schema';
|
||||
import { getAuth } from '$lib/auth/server';
|
||||
import { getConfig } from '$lib/server/config';
|
||||
import { getTextDirection } from '$lib/paraglide/runtime';
|
||||
import { paraglideMiddleware } from '$lib/paraglide/server';
|
||||
import { svelteKitHandler } from 'better-auth/svelte-kit';
|
||||
|
||||
const handleBetterAuth: Handle = async ({ event, resolve }) => {
|
||||
const cfg = getConfig();
|
||||
const auth = getAuth();
|
||||
const session = await auth.api.getSession({
|
||||
headers: event.request.headers
|
||||
});
|
||||
@@ -23,7 +25,7 @@ const handleBetterAuth: Handle = async ({ event, resolve }) => {
|
||||
redirect(307, '/auth/sign-in');
|
||||
}
|
||||
if (
|
||||
env.ENABLE_2FA &&
|
||||
cfg.ENABLE_2FA &&
|
||||
session?.user !== undefined &&
|
||||
'twoFactorEnabled' in session.user &&
|
||||
session?.user.twoFactorEnabled !== true &&
|
||||
@@ -32,7 +34,7 @@ const handleBetterAuth: Handle = async ({ event, resolve }) => {
|
||||
)
|
||||
redirect(307, '/auth/setup-2fa');
|
||||
if (session?.user && 'twoFactorRedirect' in session.user) redirect(307, '/auth/2fa');
|
||||
if (dev && env.ORIGIN.startsWith('https:')) event.url.protocol = 'https:';
|
||||
if (dev && cfg.ORIGIN.startsWith('https:')) event.url.protocol = 'https:';
|
||||
if (event.url.pathname.startsWith('/admin')) {
|
||||
const roles = (session?.user?.role ?? '')
|
||||
.split(',')
|
||||
|
||||
+98
-83
@@ -1,7 +1,7 @@
|
||||
import { dash } from '@better-auth/infra';
|
||||
import { getRequestEvent } from '$app/server';
|
||||
import { env } from '$lib/const/schema';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { getConfig } from '$lib/server/config';
|
||||
import { db } from '$lib/server/db';
|
||||
import * as schema from '$lib/server/db/schema';
|
||||
import { emailer } from '$lib/server/emails';
|
||||
@@ -17,90 +17,105 @@ export const oauthConfig = (await Bun.file(
|
||||
path.join(cwd(), 'config/oauth.json')
|
||||
).json()) as GenericOAuthConfig[];
|
||||
|
||||
export const auth = betterAuth({
|
||||
basePath: '/api/auth',
|
||||
baseURL: env.ORIGIN,
|
||||
database: drizzleAdapter(db, {
|
||||
provider: 'sqlite',
|
||||
schema
|
||||
}),
|
||||
emailAndPassword: {
|
||||
autoSignIn: false,
|
||||
customSyntheticUser: ({ additionalFields, coreFields, id }) => ({
|
||||
...coreFields,
|
||||
banExpires: null,
|
||||
banned: false,
|
||||
banReason: null,
|
||||
displayUsername: null,
|
||||
role: 'user',
|
||||
twoFactorEnabled: false,
|
||||
username: null,
|
||||
...additionalFields,
|
||||
id
|
||||
function build() {
|
||||
const cfg = getConfig();
|
||||
return betterAuth({
|
||||
basePath: '/api/auth',
|
||||
baseURL: cfg.ORIGIN,
|
||||
database: drizzleAdapter(db, {
|
||||
provider: 'sqlite',
|
||||
schema
|
||||
}),
|
||||
disableSignUp: env.DISABLE_SIGNUP || false,
|
||||
enabled: env.ENABLE_EMAIL_AND_PASSWORD || true,
|
||||
requireEmailVerification: true,
|
||||
sendResetPassword: async ({ token, url, user }) => {
|
||||
if (url.endsWith('reset-password')) await emailer.sendResetPassword({ token, url, user });
|
||||
emailAndPassword: {
|
||||
autoSignIn: false,
|
||||
customSyntheticUser: ({ additionalFields, coreFields, id }) => ({
|
||||
...coreFields,
|
||||
banExpires: null,
|
||||
banned: false,
|
||||
banReason: null,
|
||||
displayUsername: null,
|
||||
role: 'user',
|
||||
twoFactorEnabled: false,
|
||||
username: null,
|
||||
...additionalFields,
|
||||
id
|
||||
}),
|
||||
disableSignUp: cfg.DISABLE_SIGNUP || false,
|
||||
enabled: cfg.ENABLE_EMAIL_AND_PASSWORD || true,
|
||||
requireEmailVerification: true,
|
||||
sendResetPassword: async ({ token, url, user }) => {
|
||||
if (url.endsWith('reset-password')) await emailer.sendResetPassword({ token, url, user });
|
||||
|
||||
if (url.endsWith('complete-registration'))
|
||||
await emailer.sendCompleteRegistration({ token, url, user });
|
||||
}
|
||||
},
|
||||
emailVerification: {
|
||||
autoSignInAfterVerification: true,
|
||||
sendOnSignIn: true,
|
||||
sendOnSignUp: true,
|
||||
sendVerificationEmail: async ({ url, user }) => {
|
||||
await emailer.sendVerificationEmail({ url, user });
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
admin(),
|
||||
dash(),
|
||||
genericOAuth({ config: oauthConfig }),
|
||||
twoFactor({
|
||||
issuer: m.appname(),
|
||||
otpOptions: {
|
||||
sendOTP: async ({ otp, user }) => {
|
||||
await emailer.sendOtp({ otp, user });
|
||||
}
|
||||
},
|
||||
totpOptions: {
|
||||
period: 30
|
||||
if (url.endsWith('complete-registration'))
|
||||
await emailer.sendCompleteRegistration({ token, url, user });
|
||||
}
|
||||
},
|
||||
emailVerification: {
|
||||
autoSignInAfterVerification: true,
|
||||
sendOnSignIn: true,
|
||||
sendOnSignUp: true,
|
||||
sendVerificationEmail: async ({ url, user }) => {
|
||||
await emailer.sendVerificationEmail({ url, user });
|
||||
}
|
||||
},
|
||||
plugins: [
|
||||
admin(),
|
||||
dash(),
|
||||
genericOAuth({ config: oauthConfig }),
|
||||
twoFactor({
|
||||
issuer: m.appname(),
|
||||
otpOptions: {
|
||||
sendOTP: async ({ otp, user }) => {
|
||||
await emailer.sendOtp({ otp, user });
|
||||
}
|
||||
},
|
||||
totpOptions: {
|
||||
period: 30
|
||||
}
|
||||
}),
|
||||
username(),
|
||||
sveltekitCookies(getRequestEvent)
|
||||
],
|
||||
rateLimit: { enabled: true },
|
||||
socialProviders: {
|
||||
facebook:
|
||||
(cfg.FACEBOOK_CLIENT_ID && {
|
||||
clientId: cfg.FACEBOOK_CLIENT_ID,
|
||||
clientSecret: cfg.FACEBOOK_CLIENT_SECRET ?? ''
|
||||
}) ||
|
||||
undefined,
|
||||
github:
|
||||
(cfg.GITHUB_CLIENT_ID && {
|
||||
clientId: cfg.GITHUB_CLIENT_ID,
|
||||
clientSecret: cfg.GITHUB_CLIENT_SECRET ?? ''
|
||||
}) ||
|
||||
undefined,
|
||||
google:
|
||||
(cfg.GOOGLE_CLIENT_ID && {
|
||||
clientId: cfg.GOOGLE_CLIENT_ID,
|
||||
clientSecret: cfg.GOOGLE_CLIENT_SECRET ?? ''
|
||||
}) ||
|
||||
undefined
|
||||
},
|
||||
telemetry: { enabled: false },
|
||||
user: {
|
||||
deleteUser: {
|
||||
enabled: true
|
||||
}
|
||||
}),
|
||||
username(),
|
||||
sveltekitCookies(getRequestEvent)
|
||||
],
|
||||
rateLimit: { enabled: true },
|
||||
socialProviders: {
|
||||
facebook:
|
||||
(process.env.FACEBOOK_CLIENT_ID && {
|
||||
clientId: process.env.FACEBOOK_CLIENT_ID as string,
|
||||
clientSecret: process.env.FACEBOOK_CLIENT_SECRET as string
|
||||
}) ||
|
||||
undefined,
|
||||
github:
|
||||
(process.env.GITHUB_CLIENT_ID && {
|
||||
clientId: process.env.GITHUB_CLIENT_ID as string,
|
||||
clientSecret: process.env.GITHUB_CLIENT_SECRET as string
|
||||
}) ||
|
||||
undefined,
|
||||
google:
|
||||
(process.env.GOOGLE_CLIENT_ID && {
|
||||
clientId: process.env.GOOGLE_CLIENT_ID as string,
|
||||
clientSecret: process.env.GOOGLE_CLIENT_SECRET as string
|
||||
}) ||
|
||||
undefined
|
||||
},
|
||||
telemetry: { enabled: false },
|
||||
user: {
|
||||
deleteUser: {
|
||||
enabled: true
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export type Auth = typeof auth;
|
||||
// ponytail: single-slot cache, rebuilt on config edits via invalidateAuth().
|
||||
// In-flight rate-limit counters and 2FA flow state reset on invalidation.
|
||||
let cached: ReturnType<typeof build> | null = null;
|
||||
|
||||
export function getAuth(): ReturnType<typeof build> {
|
||||
return (cached ??= build());
|
||||
}
|
||||
|
||||
export function invalidateAuth(): void {
|
||||
cached = null;
|
||||
}
|
||||
|
||||
export type Auth = ReturnType<typeof build>;
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Field from '$lib/components/ui/field';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { Spinner } from '$lib/components/ui/spinner';
|
||||
import * as Tabs from '$lib/components/ui/tabs';
|
||||
@@ -331,24 +331,24 @@
|
||||
{m.terminal_section_connection()}
|
||||
</p>
|
||||
<div class="grid grid-cols-5 gap-3">
|
||||
<div class="col-span-3 space-y-1.5">
|
||||
<Label for="term-user" class="text-xs">{m.username()}</Label>
|
||||
<Field.Field class="col-span-3">
|
||||
<Field.Label for="term-user" class="text-xs">{m.username()}</Field.Label>
|
||||
<div class="relative">
|
||||
<UserIcon
|
||||
class="text-muted-foreground pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2"
|
||||
/>
|
||||
<Input
|
||||
id="term-user"
|
||||
class="pl-8 h-9 text-sm"
|
||||
class="h-9 pl-8 text-sm"
|
||||
bind:value={username}
|
||||
placeholder="root"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-span-2 space-y-1.5">
|
||||
<Label for="term-port" class="text-xs">{m.terminal_port()}</Label>
|
||||
<Input id="term-port" class="w-full h-9 text-sm" bind:value={port} placeholder="22" />
|
||||
</div>
|
||||
</Field.Field>
|
||||
<Field.Field class="col-span-2">
|
||||
<Field.Label for="term-port" class="text-xs">{m.terminal_port()}</Field.Label>
|
||||
<Input id="term-port" class="h-9 w-full text-sm" bind:value={port} placeholder="22" />
|
||||
</Field.Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -394,39 +394,45 @@
|
||||
>
|
||||
</Tabs.List>
|
||||
<div class="mt-3">
|
||||
<Tabs.Content value="password" class="space-y-1.5">
|
||||
<Label for="term-password" class="text-xs">{m.password()}</Label>
|
||||
<div class="relative">
|
||||
<KeyRoundIcon
|
||||
class="text-muted-foreground pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2"
|
||||
/>
|
||||
<Input
|
||||
id="term-password"
|
||||
class="pl-8 h-9 text-sm"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder={m.terminal_password_placeholder()}
|
||||
/>
|
||||
</div>
|
||||
<Tabs.Content value="password">
|
||||
<Field.Field>
|
||||
<Field.Label for="term-password" class="text-xs">{m.password()}</Field.Label>
|
||||
<div class="relative">
|
||||
<KeyRoundIcon
|
||||
class="text-muted-foreground pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2"
|
||||
/>
|
||||
<Input
|
||||
id="term-password"
|
||||
class="h-9 pl-8 text-sm"
|
||||
type="password"
|
||||
bind:value={password}
|
||||
placeholder={m.terminal_password_placeholder()}
|
||||
/>
|
||||
</div>
|
||||
</Field.Field>
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="key" class="space-y-1.5">
|
||||
<Label for="term-key" class="text-xs">{m.terminal_auth_private_key()}</Label>
|
||||
<Textarea
|
||||
id="term-key"
|
||||
class="h-60 font-mono text-xs"
|
||||
bind:value={privateKey}
|
||||
placeholder={m.terminal_private_key_placeholder()}
|
||||
/>
|
||||
<Tabs.Content value="key">
|
||||
<Field.Field>
|
||||
<Field.Label for="term-key" class="text-xs">
|
||||
{m.terminal_auth_private_key()}
|
||||
</Field.Label>
|
||||
<Textarea
|
||||
id="term-key"
|
||||
class="h-60 font-mono text-xs"
|
||||
bind:value={privateKey}
|
||||
placeholder={m.terminal_private_key_placeholder()}
|
||||
/>
|
||||
</Field.Field>
|
||||
</Tabs.Content>
|
||||
</div>
|
||||
</Tabs.Root>
|
||||
|
||||
<div class="flex items-center gap-2 pt-1">
|
||||
<Field.Field orientation="horizontal" class="pt-1">
|
||||
<Checkbox id="term-save" bind:checked={saveCredential} />
|
||||
<Label for="term-save" class="text-xs text-muted-foreground cursor-pointer">
|
||||
<Field.Label for="term-save" class="text-muted-foreground cursor-pointer text-xs font-normal">
|
||||
{m.terminal_remember_credential()}
|
||||
</Label>
|
||||
</div>
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { PersistedState } from 'runed';
|
||||
@@ -113,7 +112,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2 border-t p-2">
|
||||
<Label class="text-xs">{i18n.display}</Label>
|
||||
<span class="text-muted-foreground text-xs font-medium">{i18n.display}</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">{i18n.rowsPerPage}</span>
|
||||
<Input
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
bind:ref
|
||||
data-slot="field-label"
|
||||
class={cn(
|
||||
'has-data-checked:bg-primary/5 has-data-checked:border-primary/30 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border *:data-[slot=field]:p-2.5 group/field-label peer/field-label flex w-fit',
|
||||
'has-data-checked:bg-primary/5 has-data-checked:border-primary/30 dark:has-data-checked:border-primary/20 dark:has-data-checked:bg-primary/10 gap-2 leading-snug group-data-[disabled=true]/field:opacity-50 has-[>[data-slot=field]]:rounded-lg has-[>[data-slot=field]]:border border-input *:data-[slot=field]:p-2.5 group/field-label peer/field-label flex w-fit',
|
||||
'has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col',
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -15,6 +15,12 @@ const EnvSchema = v.object({
|
||||
v.optional(v.string(), 'true'),
|
||||
v.transform((v) => v === 'true')
|
||||
),
|
||||
FACEBOOK_CLIENT_ID: v.optional(v.string()),
|
||||
FACEBOOK_CLIENT_SECRET: v.optional(v.string()),
|
||||
GITHUB_CLIENT_ID: v.optional(v.string()),
|
||||
GITHUB_CLIENT_SECRET: v.optional(v.string()),
|
||||
GOOGLE_CLIENT_ID: v.optional(v.string()),
|
||||
GOOGLE_CLIENT_SECRET: v.optional(v.string()),
|
||||
ORIGIN: v.pipe(v.optional(v.string(), 'http://localhost:5173')),
|
||||
REPOSITORY_URL: v.pipe(v.string(), v.nonEmpty()),
|
||||
SMTP_FROM: v.pipe(v.optional(v.string())),
|
||||
|
||||
@@ -1,32 +1,34 @@
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { form, getRequestEvent, query } from '$app/server';
|
||||
import { env as pEnv } from '$env/dynamic/private';
|
||||
import {
|
||||
loginSchema,
|
||||
registerSchema,
|
||||
resetPasswordSchema,
|
||||
resetRequestSchema
|
||||
} from '$lib/auth/schemas';
|
||||
import { auth, oauthConfig } from '$lib/auth/server';
|
||||
import { env } from '$lib/const/schema';
|
||||
import { getAuth, oauthConfig } from '$lib/auth/server';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { getConfig } from '$lib/server/config';
|
||||
|
||||
export const getOAuthProviders = query(() => {
|
||||
const cfg = getConfig();
|
||||
const socialProviders = [
|
||||
pEnv.FACEBOOK_CLIENT_ID ? 'facebook' : undefined,
|
||||
pEnv.GITHUB_CLIENT_ID ? 'github' : undefined,
|
||||
pEnv.GOOGLE_CLIENT_ID ? 'google' : undefined
|
||||
cfg.FACEBOOK_CLIENT_ID ? 'facebook' : undefined,
|
||||
cfg.GITHUB_CLIENT_ID ? 'github' : undefined,
|
||||
cfg.GOOGLE_CLIENT_ID ? 'google' : undefined
|
||||
].filter((x): x is string => Boolean(x));
|
||||
return { oauthConfig, socialProviders };
|
||||
});
|
||||
|
||||
export const login = form(loginSchema, async (credentials) => {
|
||||
const { request } = getRequestEvent();
|
||||
const cfg = getConfig();
|
||||
const auth = getAuth();
|
||||
let res: Awaited<ReturnType<typeof auth.api.signInUsername>>;
|
||||
try {
|
||||
res = await auth.api.signInUsername({
|
||||
body: {
|
||||
callbackURL: env.ORIGIN + '/dashboard',
|
||||
callbackURL: cfg.ORIGIN + '/dashboard',
|
||||
password: credentials._password,
|
||||
rememberMe: credentials.rememberMe,
|
||||
username: credentials.username
|
||||
@@ -49,7 +51,7 @@ export const login = form(loginSchema, async (credentials) => {
|
||||
}
|
||||
}
|
||||
if (
|
||||
env.ENABLE_2FA &&
|
||||
cfg.ENABLE_2FA &&
|
||||
res?.user !== undefined &&
|
||||
'twoFactorEnabled' in res.user &&
|
||||
res?.user.twoFactorEnabled !== true
|
||||
@@ -62,7 +64,7 @@ export const login = form(loginSchema, async (credentials) => {
|
||||
export const register = form(registerSchema, async ({ _password, email, username }) => {
|
||||
const { request } = getRequestEvent();
|
||||
try {
|
||||
await auth.api.signUpEmail({
|
||||
await getAuth().api.signUpEmail({
|
||||
body: { email, name: username, password: _password, username },
|
||||
headers: request.headers
|
||||
});
|
||||
@@ -75,15 +77,15 @@ export const register = form(registerSchema, async ({ _password, email, username
|
||||
});
|
||||
|
||||
export const requestReset = form(resetRequestSchema, async ({ email }) => {
|
||||
await auth.api.requestPasswordReset({
|
||||
body: { email, redirectTo: env.ORIGIN + '/auth/reset-password' }
|
||||
await getAuth().api.requestPasswordReset({
|
||||
body: { email, redirectTo: getConfig().ORIGIN + '/auth/reset-password' }
|
||||
});
|
||||
return { sent: true };
|
||||
});
|
||||
|
||||
export const resetPassword = form(resetPasswordSchema, async ({ newPassword, token }) => {
|
||||
try {
|
||||
await auth.api.resetPassword({ body: { newPassword, token } });
|
||||
await getAuth().api.resetPassword({ body: { newPassword, token } });
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
throw error(400, { message: m.invalid_reset_link() });
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { command, form, getRequestEvent, query } from '$app/server';
|
||||
import { v } from '$lib';
|
||||
import { invalidateAuth } from '$lib/auth/server';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { configFormSchema } from '$lib/schemas/config';
|
||||
import { type ConfigKey, getConfig, getRawConfig, setConfigValues } from '$lib/server/config';
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
function requireAdmin() {
|
||||
const { locals } = getRequestEvent();
|
||||
const roles = (locals.user?.role ?? '')
|
||||
.split(',')
|
||||
.map((r: string) => r.trim())
|
||||
.filter(Boolean);
|
||||
if (!locals.user || !roles.includes('admin')) error(403, { message: m.forbidden() });
|
||||
}
|
||||
|
||||
export const getAppConfig = query(() => {
|
||||
requireAdmin();
|
||||
const cfg = getConfig();
|
||||
const raw = getRawConfig();
|
||||
const has = (key: ConfigKey) => key in raw;
|
||||
return {
|
||||
boot: {
|
||||
DISABLE_SIGNUP: cfg.DISABLE_SIGNUP,
|
||||
ENABLE_EMAIL_AND_PASSWORD: cfg.ENABLE_EMAIL_AND_PASSWORD,
|
||||
ORIGIN: cfg.ORIGIN
|
||||
},
|
||||
// runtime — picked up on next read
|
||||
ENABLE_2FA: cfg.ENABLE_2FA,
|
||||
FACEBOOK_CLIENT_ID: cfg.FACEBOOK_CLIENT_ID ?? '',
|
||||
FACEBOOK_CLIENT_SECRET_SET: Boolean(cfg.FACEBOOK_CLIENT_SECRET),
|
||||
GITHUB_CLIENT_ID: cfg.GITHUB_CLIENT_ID ?? '',
|
||||
GITHUB_CLIENT_SECRET_SET: Boolean(cfg.GITHUB_CLIENT_SECRET),
|
||||
GOOGLE_CLIENT_ID: cfg.GOOGLE_CLIENT_ID ?? '',
|
||||
GOOGLE_CLIENT_SECRET_SET: Boolean(cfg.GOOGLE_CLIENT_SECRET),
|
||||
// Whether each key is currently overridden in the DB (vs. coming from env).
|
||||
overridden: {
|
||||
DISABLE_SIGNUP: has('DISABLE_SIGNUP'),
|
||||
ENABLE_2FA: has('ENABLE_2FA'),
|
||||
ENABLE_EMAIL_AND_PASSWORD: has('ENABLE_EMAIL_AND_PASSWORD'),
|
||||
ORIGIN: has('ORIGIN'),
|
||||
SMTP_FROM: has('SMTP_FROM'),
|
||||
SMTP_HOST: has('SMTP_HOST'),
|
||||
SMTP_PASS: has('SMTP_PASS'),
|
||||
SMTP_PORT: has('SMTP_PORT'),
|
||||
SMTP_SSL: has('SMTP_SSL'),
|
||||
SMTP_USER: has('SMTP_USER')
|
||||
},
|
||||
SMTP_FROM: cfg.SMTP_FROM ?? '',
|
||||
SMTP_HOST: cfg.SMTP_HOST ?? '',
|
||||
// Don't ship the decrypted password back; just say whether it's set.
|
||||
SMTP_PASS_SET: Boolean(cfg.SMTP_PASS),
|
||||
SMTP_PORT: cfg.SMTP_PORT,
|
||||
SMTP_SSL: cfg.SMTP_SSL ?? false,
|
||||
SMTP_USER: cfg.SMTP_USER ?? ''
|
||||
};
|
||||
});
|
||||
|
||||
const KEEP_IF_BLANK = ['FACEBOOK_CLIENT_SECRET', 'GITHUB_CLIENT_SECRET', 'GOOGLE_CLIENT_SECRET', 'SMTP_PASS'] as const;
|
||||
|
||||
export const saveAppConfig = form(configFormSchema, async (updates) => {
|
||||
requireAdmin();
|
||||
const patch: Partial<Record<ConfigKey, unknown>> = { ...updates };
|
||||
for (const k of KEEP_IF_BLANK) if (patch[k] === '') delete patch[k];
|
||||
setConfigValues(patch);
|
||||
invalidateAuth();
|
||||
await getAppConfig().refresh();
|
||||
});
|
||||
|
||||
export const sendTestEmail = command(v.object({ to: v.pipe(v.string(), v.email()) }), async ({ to }) => {
|
||||
requireAdmin();
|
||||
const cfg = getConfig();
|
||||
if (!cfg.SMTP_HOST) error(400, { message: m.admin_config_smtp_missing() });
|
||||
const transporter = nodemailer.createTransport({
|
||||
auth: { pass: cfg.SMTP_PASS, user: cfg.SMTP_USER },
|
||||
host: cfg.SMTP_HOST,
|
||||
port: cfg.SMTP_PORT,
|
||||
secure: cfg.SMTP_SSL
|
||||
});
|
||||
try {
|
||||
await transporter.sendMail({
|
||||
from: cfg.SMTP_FROM,
|
||||
subject: m.admin_config_smtp_test_subject(),
|
||||
text: m.admin_config_smtp_test_body(),
|
||||
to
|
||||
});
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : m.errors_generic();
|
||||
error(500, { message });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { command, query } from '$app/server';
|
||||
import { command, form, query } from '$app/server';
|
||||
import { v } from '$lib';
|
||||
import { upsertHostSchema } from '$lib/schemas/networking-host';
|
||||
|
||||
import { nadirForMachine, throwNadirError } from './utils';
|
||||
|
||||
@@ -136,22 +137,15 @@ export const rollbackChange = command(
|
||||
}
|
||||
);
|
||||
|
||||
export const upsertHost = command(
|
||||
v.object({
|
||||
hostnames: v.array(v.string()),
|
||||
ip: v.string(),
|
||||
machineId: v.string()
|
||||
}),
|
||||
async ({ hostnames, ip, machineId }) => {
|
||||
const { client: nadir } = await nadirForMachine(machineId);
|
||||
const { error: err } = await nadir.PUT('/api/networking/hosts/{ip}', {
|
||||
body: { hostnames },
|
||||
params: { path: { ip } }
|
||||
});
|
||||
if (err) throwNadirError(err);
|
||||
await listHosts(machineId).refresh();
|
||||
}
|
||||
);
|
||||
export const upsertHost = form(upsertHostSchema, async ({ hostnames, ip, machineId }) => {
|
||||
const { client: nadir } = await nadirForMachine(machineId);
|
||||
const { error: err } = await nadir.PUT('/api/networking/hosts/{ip}', {
|
||||
body: { hostnames },
|
||||
params: { path: { ip } }
|
||||
});
|
||||
if (err) throwNadirError(err);
|
||||
await listHosts(machineId).refresh();
|
||||
});
|
||||
|
||||
export const deleteHost = command(
|
||||
v.object({ ip: v.string(), machineId: v.string() }),
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { command, query } from '$app/server';
|
||||
import { command, form, query } from '$app/server';
|
||||
import { v } from '$lib';
|
||||
import {
|
||||
createPamGroupSchema,
|
||||
createPamUserSchema,
|
||||
setPamUserPasswordSchema
|
||||
} from '$lib/schemas/pam';
|
||||
|
||||
import { nadirForMachine, throwNadirError } from './utils';
|
||||
|
||||
@@ -10,23 +15,21 @@ export const listPamUsers = query(v.string(), async (machineId) => {
|
||||
return data.users ?? [];
|
||||
});
|
||||
|
||||
export const createPamUser = command(
|
||||
v.object({
|
||||
comment: v.optional(v.string()),
|
||||
create_home: v.optional(v.boolean()),
|
||||
home: v.optional(v.string()),
|
||||
machineId: v.string(),
|
||||
shell: v.optional(v.string()),
|
||||
system: v.optional(v.boolean()),
|
||||
username: v.string()
|
||||
}),
|
||||
async (body) => {
|
||||
const { client: nadir } = await nadirForMachine(body.machineId);
|
||||
const { error: err } = await nadir.POST('/api/users', { body });
|
||||
if (err) throwNadirError(err);
|
||||
await listPamUsers(body.machineId).refresh();
|
||||
}
|
||||
);
|
||||
export const createPamUser = form(createPamUserSchema, async (body) => {
|
||||
const { client: nadir } = await nadirForMachine(body.machineId);
|
||||
const { error: err } = await nadir.POST('/api/users', {
|
||||
body: {
|
||||
comment: body.comment || undefined,
|
||||
create_home: body.create_home,
|
||||
home: body.home || undefined,
|
||||
shell: body.shell || undefined,
|
||||
system: body.system,
|
||||
username: body.username
|
||||
}
|
||||
});
|
||||
if (err) throwNadirError(err);
|
||||
await listPamUsers(body.machineId).refresh();
|
||||
});
|
||||
|
||||
export const deletePamUser = command(
|
||||
v.object({ machineId: v.string(), remove_home: v.optional(v.boolean()), username: v.string() }),
|
||||
@@ -40,8 +43,8 @@ export const deletePamUser = command(
|
||||
}
|
||||
);
|
||||
|
||||
export const setPamUserPassword = command(
|
||||
v.object({ machineId: v.string(), password: v.string(), username: v.string() }),
|
||||
export const setPamUserPassword = form(
|
||||
setPamUserPasswordSchema,
|
||||
async ({ machineId, password, username }) => {
|
||||
const { client: nadir } = await nadirForMachine(machineId);
|
||||
const { error: err } = await nadir.POST('/api/users/{username}/password', {
|
||||
@@ -84,20 +87,14 @@ export const listPamGroups = query(v.string(), async (machineId) => {
|
||||
return data.groups ?? [];
|
||||
});
|
||||
|
||||
export const createPamGroup = command(
|
||||
v.object({
|
||||
gid: v.optional(v.number()),
|
||||
machineId: v.string(),
|
||||
name: v.string(),
|
||||
system: v.optional(v.boolean())
|
||||
}),
|
||||
async (body) => {
|
||||
const { client: nadir } = await nadirForMachine(body.machineId);
|
||||
const { error: err } = await nadir.POST('/api/groups', { body });
|
||||
if (err) throwNadirError(err);
|
||||
await listPamGroups(body.machineId).refresh();
|
||||
}
|
||||
);
|
||||
export const createPamGroup = form(createPamGroupSchema, async (body) => {
|
||||
const { client: nadir } = await nadirForMachine(body.machineId);
|
||||
const { error: err } = await nadir.POST('/api/groups', {
|
||||
body: { gid: body.gid, name: body.name, system: body.system }
|
||||
});
|
||||
if (err) throwNadirError(err);
|
||||
await listPamGroups(body.machineId).refresh();
|
||||
});
|
||||
|
||||
export const deletePamGroup = command(
|
||||
v.object({ group: v.string(), machineId: v.string() }),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { command, query } from '$app/server';
|
||||
import { command, form, query } from '$app/server';
|
||||
import { v } from '$lib';
|
||||
import { addMountSchema } from '$lib/schemas/storage-mount';
|
||||
|
||||
import { nadirForMachine, throwNadirError } from './utils';
|
||||
|
||||
@@ -17,24 +18,15 @@ export const listFstab = query(v.string(), async (machineId) => {
|
||||
return data.entries ?? [];
|
||||
});
|
||||
|
||||
export const addMount = command(
|
||||
v.object({
|
||||
device: v.string(),
|
||||
dump: v.optional(v.number()),
|
||||
fstype: v.string(),
|
||||
machineId: v.string(),
|
||||
mountpoint: v.string(),
|
||||
options: v.optional(v.string()),
|
||||
pass: v.optional(v.number())
|
||||
}),
|
||||
async ({ machineId, ...body }) => {
|
||||
const { client: nadir } = await nadirForMachine(machineId);
|
||||
const { error: err } = await nadir.POST('/api/storage/mounts', { body });
|
||||
if (err) throwNadirError(err);
|
||||
await listMounts(machineId).refresh();
|
||||
await listFstab(machineId).refresh();
|
||||
}
|
||||
);
|
||||
export const addMount = form(addMountSchema, async ({ machineId, options, ...body }) => {
|
||||
const { client: nadir } = await nadirForMachine(machineId);
|
||||
const { error: err } = await nadir.POST('/api/storage/mounts', {
|
||||
body: { ...body, options: options || undefined }
|
||||
});
|
||||
if (err) throwNadirError(err);
|
||||
await listMounts(machineId).refresh();
|
||||
await listFstab(machineId).refresh();
|
||||
});
|
||||
|
||||
export const removeMount = command(
|
||||
v.object({ machineId: v.string(), mountpoint: v.string() }),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { command, query } from '$app/server';
|
||||
import { command, form, query } from '$app/server';
|
||||
import { v } from '$lib';
|
||||
import { hostnameSchema } from '$lib/schemas/hostname';
|
||||
import { setTimeSchema } from '$lib/schemas/system-time';
|
||||
|
||||
import { systemDetails } from './server.remote';
|
||||
import { nadirForMachine, throwNadirError } from './utils';
|
||||
@@ -52,15 +54,14 @@ export const setNtp = command(
|
||||
}
|
||||
);
|
||||
|
||||
export const setTime = command(
|
||||
v.object({ machineId: v.string(), time: v.string() }),
|
||||
async ({ machineId, time }) => {
|
||||
const { client: nadir } = await nadirForMachine(machineId);
|
||||
const { error: err } = await nadir.POST('/api/system/time', { body: { time } });
|
||||
if (err) throwNadirError(err);
|
||||
await systemTime(machineId).refresh();
|
||||
}
|
||||
);
|
||||
export const setTime = form(setTimeSchema, async ({ machineId, time }) => {
|
||||
// ponytail: form sends a datetime-local string; normalize to RFC3339 UTC.
|
||||
const iso = new Date(time).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
const { client: nadir } = await nadirForMachine(machineId);
|
||||
const { error: err } = await nadir.POST('/api/system/time', { body: { time: iso } });
|
||||
if (err) throwNadirError(err);
|
||||
await systemTime(machineId).refresh();
|
||||
});
|
||||
|
||||
export const systemHostname = query(v.string(), async (machineId) => {
|
||||
const { client: nadir } = await nadirForMachine(machineId);
|
||||
@@ -70,15 +71,13 @@ export const systemHostname = query(v.string(), async (machineId) => {
|
||||
return data;
|
||||
});
|
||||
|
||||
export const setHostname = command(
|
||||
v.object({ hostname: v.string(), machineId: v.string() }),
|
||||
async ({ hostname, machineId }) => {
|
||||
const { client: nadir } = await nadirForMachine(machineId);
|
||||
const { error: err } = await nadir.POST('/api/system/hostname', { body: { hostname } });
|
||||
if (err) throwNadirError(err);
|
||||
await systemHostname(machineId).refresh();
|
||||
}
|
||||
);
|
||||
|
||||
export const setHostname = form(hostnameSchema, async ({ hostname, machineId }) => {
|
||||
const { client: nadir } = await nadirForMachine(machineId);
|
||||
const { error: err } = await nadir.POST('/api/system/hostname', { body: { hostname } });
|
||||
if (err) throwNadirError(err);
|
||||
await systemHostname(machineId).refresh();
|
||||
});
|
||||
|
||||
export const listKeymaps = query(v.string(), async (machineId) => {
|
||||
const { client: nadir } = await nadirForMachine(machineId);
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
inviteUserSchema,
|
||||
updateUserSchema
|
||||
} from '$lib/auth/schemas';
|
||||
import { auth } from '$lib/auth/server';
|
||||
import { getAuth } from '$lib/auth/server';
|
||||
import { env } from '$lib/const/schema';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { db } from '$lib/server/db';
|
||||
@@ -24,7 +24,7 @@ type Role = 'admin' | 'user';
|
||||
const requireAdmin = () => {
|
||||
const { locals } = getRequestEvent();
|
||||
if (!locals.user) redirect(307, '/auth/sign-in');
|
||||
const roles = (locals.user.role ?? '').split(',').map((r) => r.trim());
|
||||
const roles = (locals.user.role ?? '').split(',').map((r: string) => r.trim());
|
||||
if (!roles.includes('admin')) redirect(307, '/dashboard');
|
||||
return locals;
|
||||
};
|
||||
@@ -142,7 +142,7 @@ export const createUser = form(
|
||||
requireAdmin();
|
||||
const { request } = getRequestEvent();
|
||||
try {
|
||||
const created = await auth.api.createUser({
|
||||
const created = await getAuth().api.createUser({
|
||||
body: { email, name, password: _password, role: role as Role },
|
||||
headers: request.headers
|
||||
});
|
||||
@@ -167,7 +167,7 @@ export const inviteUser = form(inviteUserSchema, async ({ email, name, role, use
|
||||
const { request } = getRequestEvent();
|
||||
try {
|
||||
const tmpPassword = crypto.randomUUID() + 'Aa1!';
|
||||
const created = await auth.api.createUser({
|
||||
const created = await getAuth().api.createUser({
|
||||
body: { email, name: name || email, password: tmpPassword, role: role as Role },
|
||||
headers: request.headers
|
||||
});
|
||||
@@ -178,7 +178,7 @@ export const inviteUser = form(inviteUserSchema, async ({ email, name, role, use
|
||||
.where(eq(userTable.id, created.user.id));
|
||||
}
|
||||
// ponytail: re-use existing reset flow — auth.ts routes /complete-registration → invite email.
|
||||
await auth.api.requestPasswordReset({
|
||||
await getAuth().api.requestPasswordReset({
|
||||
body: { email, redirectTo: env.ORIGIN + '/auth/complete-registration' }
|
||||
});
|
||||
return { ok: true };
|
||||
@@ -193,7 +193,7 @@ export const updateUser = form(updateUserSchema, async ({ email, id, name, role,
|
||||
requireAdmin();
|
||||
const { request } = getRequestEvent();
|
||||
try {
|
||||
await auth.api.adminUpdateUser({
|
||||
await getAuth().api.adminUpdateUser({
|
||||
body: { data: { displayUsername: username, email, name, role, username }, userId: id },
|
||||
headers: request.headers
|
||||
});
|
||||
@@ -209,7 +209,7 @@ export const deleteUser = command(v.string(), async (id) => {
|
||||
requireAdmin();
|
||||
const { request } = getRequestEvent();
|
||||
try {
|
||||
await auth.api.removeUser({ body: { userId: id }, headers: request.headers });
|
||||
await getAuth().api.removeUser({ body: { userId: id }, headers: request.headers });
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
throw error(400, { message: m.errors_generic() });
|
||||
@@ -220,7 +220,7 @@ export const banUser = command(banUserSchema, async ({ banReason, id }) => {
|
||||
requireAdmin();
|
||||
const { request } = getRequestEvent();
|
||||
try {
|
||||
await auth.api.banUser({
|
||||
await getAuth().api.banUser({
|
||||
body: { banReason: banReason || 'Banned by admin', userId: id },
|
||||
headers: request.headers
|
||||
});
|
||||
@@ -241,7 +241,7 @@ export const resendInvite = command(v.string(), async (id) => {
|
||||
and(eq(verificationTable.value, id), like(verificationTable.identifier, 'reset-password:%'))
|
||||
);
|
||||
try {
|
||||
await auth.api.requestPasswordReset({
|
||||
await getAuth().api.requestPasswordReset({
|
||||
body: { email: u.email, redirectTo: env.ORIGIN + '/auth/complete-registration' }
|
||||
});
|
||||
} catch (e) {
|
||||
@@ -254,7 +254,7 @@ export const unbanUser = command(v.string(), async (id) => {
|
||||
requireAdmin();
|
||||
const { request } = getRequestEvent();
|
||||
try {
|
||||
await auth.api.unbanUser({ body: { userId: id }, headers: request.headers });
|
||||
await getAuth().api.unbanUser({ body: { userId: id }, headers: request.headers });
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
throw error(400, { message: m.errors_generic() });
|
||||
|
||||
@@ -120,3 +120,4 @@ export function throwNadirError(
|
||||
}
|
||||
throw error(status, { message });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { v } from "$lib";
|
||||
import { m } from "$lib/paraglide/messages";
|
||||
|
||||
const switchVal = v.pipe(
|
||||
v.optional(v.string(), 'no'),
|
||||
v.transform((x) => x === 'yes')
|
||||
);
|
||||
const optStr = v.optional(v.string(), '');
|
||||
const optUrl = v.pipe(
|
||||
v.optional(v.string(), ''),
|
||||
v.check((s) => s === '' || URL.canParse(s), m.errors_address_invalid())
|
||||
);
|
||||
const optPort = v.pipe(
|
||||
v.optional(v.string(), ''),
|
||||
v.check(
|
||||
(s) => s === '' || (/^\d+$/.test(s) && Number(s) >= 1 && Number(s) <= 65535),
|
||||
m.errors_port_invalid()
|
||||
),
|
||||
v.transform((s) => (s === '' ? undefined : Number(s)))
|
||||
);
|
||||
|
||||
export const configFormSchema = v.object({
|
||||
DISABLE_SIGNUP: switchVal,
|
||||
ENABLE_2FA: switchVal,
|
||||
ENABLE_EMAIL_AND_PASSWORD: switchVal,
|
||||
FACEBOOK_CLIENT_ID: optStr,
|
||||
FACEBOOK_CLIENT_SECRET: optStr,
|
||||
GITHUB_CLIENT_ID: optStr,
|
||||
GITHUB_CLIENT_SECRET: optStr,
|
||||
GOOGLE_CLIENT_ID: optStr,
|
||||
GOOGLE_CLIENT_SECRET: optStr,
|
||||
ORIGIN: optUrl,
|
||||
SMTP_FROM: optStr,
|
||||
SMTP_HOST: optStr,
|
||||
SMTP_PASS: optStr,
|
||||
SMTP_PORT: optPort,
|
||||
SMTP_SSL: switchVal,
|
||||
SMTP_USER: optStr
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { v } from "$lib";
|
||||
import { m } from "$lib/paraglide/messages";
|
||||
|
||||
export const hostnameSchema = v.object({
|
||||
hostname: v.pipe(
|
||||
v.string(m.errors_non_empty()),
|
||||
v.nonEmpty(m.errors_non_empty()),
|
||||
v.maxLength(253, m.errors_hostname_invalid()),
|
||||
v.regex(
|
||||
/^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/,
|
||||
m.errors_hostname_invalid()
|
||||
)
|
||||
),
|
||||
machineId: v.pipe(v.string(), v.nonEmpty())
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { v } from '$lib';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
|
||||
export const upsertHostSchema = v.object({
|
||||
hostnames: v.pipe(
|
||||
v.string(m.errors_non_empty()),
|
||||
v.nonEmpty(m.errors_non_empty()),
|
||||
v.transform((s) => s.split(/\s+/).filter(Boolean)),
|
||||
v.minLength(1, m.errors_non_empty())
|
||||
),
|
||||
ip: v.pipe(
|
||||
v.string(m.errors_non_empty()),
|
||||
v.nonEmpty(m.errors_non_empty()),
|
||||
v.regex(/^(?:\d{1,3}\.){3}\d{1,3}$|^[0-9a-fA-F:]+$/, m.errors_address_invalid())
|
||||
),
|
||||
machineId: v.pipe(v.string(), v.nonEmpty())
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import { v } from '$lib';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
|
||||
const yesNo = v.pipe(
|
||||
v.optional(v.string(), 'no'),
|
||||
v.transform((x) => x === 'yes')
|
||||
);
|
||||
const reqStr = v.pipe(v.string(m.errors_non_empty()), v.nonEmpty(m.errors_non_empty()));
|
||||
const machineId = v.pipe(v.string(), v.nonEmpty());
|
||||
|
||||
export const createPamUserSchema = v.object({
|
||||
comment: v.optional(v.string(), ''),
|
||||
create_home: yesNo,
|
||||
home: v.optional(v.string(), ''),
|
||||
machineId,
|
||||
shell: v.optional(v.string(), ''),
|
||||
system: yesNo,
|
||||
username: reqStr
|
||||
});
|
||||
|
||||
export const setPamUserPasswordSchema = v.object({
|
||||
machineId,
|
||||
password: v.pipe(
|
||||
v.string(m.errors_non_empty()),
|
||||
v.nonEmpty(m.errors_non_empty()),
|
||||
v.minLength(8, m.errors_password_too_short({ min: 8 }))
|
||||
),
|
||||
username: v.pipe(v.string(), v.nonEmpty())
|
||||
});
|
||||
|
||||
export const createPamGroupSchema = v.object({
|
||||
gid: v.pipe(
|
||||
v.optional(v.string(), ''),
|
||||
v.transform((s) => (s === '' ? undefined : Number(s)))
|
||||
),
|
||||
machineId,
|
||||
name: reqStr,
|
||||
system: yesNo
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import { v } from '$lib';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
|
||||
const reqStr = v.pipe(v.string(m.errors_non_empty()), v.nonEmpty(m.errors_non_empty()));
|
||||
|
||||
export const addMountSchema = v.object({
|
||||
device: reqStr,
|
||||
fstype: reqStr,
|
||||
machineId: v.pipe(v.string(), v.nonEmpty()),
|
||||
mountpoint: reqStr,
|
||||
options: v.optional(v.string(), '')
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { v } from '$lib';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
|
||||
export const setTimeSchema = v.object({
|
||||
machineId: v.pipe(v.string(), v.nonEmpty()),
|
||||
time: v.pipe(v.string(m.errors_non_empty()), v.nonEmpty(m.errors_non_empty()))
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { env } from '$lib/const/schema';
|
||||
import { db } from '$lib/server/db';
|
||||
import { decryptValue, encrypt } from '$lib/server/db/custom-types';
|
||||
import { settings } from '$lib/server/db/schema';
|
||||
|
||||
// Keys editable via /admin/config. Keep in sync with the form.
|
||||
export const CONFIG_KEYS = [
|
||||
'DISABLE_SIGNUP',
|
||||
'ENABLE_2FA',
|
||||
'ENABLE_EMAIL_AND_PASSWORD',
|
||||
'FACEBOOK_CLIENT_ID',
|
||||
'FACEBOOK_CLIENT_SECRET',
|
||||
'GITHUB_CLIENT_ID',
|
||||
'GITHUB_CLIENT_SECRET',
|
||||
'GOOGLE_CLIENT_ID',
|
||||
'GOOGLE_CLIENT_SECRET',
|
||||
'ORIGIN',
|
||||
'SMTP_FROM',
|
||||
'SMTP_HOST',
|
||||
'SMTP_PASS',
|
||||
'SMTP_PORT',
|
||||
'SMTP_SSL',
|
||||
'SMTP_USER'
|
||||
] as const;
|
||||
export type ConfigKey = (typeof CONFIG_KEYS)[number];
|
||||
|
||||
const ENCRYPTED_KEYS = new Set<ConfigKey>([
|
||||
'FACEBOOK_CLIENT_SECRET',
|
||||
'GITHUB_CLIENT_SECRET',
|
||||
'GOOGLE_CLIENT_SECRET',
|
||||
'SMTP_PASS'
|
||||
]);
|
||||
const BOOLEAN_KEYS = new Set<ConfigKey>([
|
||||
'DISABLE_SIGNUP',
|
||||
'ENABLE_2FA',
|
||||
'ENABLE_EMAIL_AND_PASSWORD',
|
||||
'SMTP_SSL'
|
||||
]);
|
||||
const NUMBER_KEYS = new Set<ConfigKey>(['SMTP_PORT']);
|
||||
|
||||
type Effective = typeof env;
|
||||
|
||||
let cache: Effective | null = null;
|
||||
|
||||
export function getConfig(): Effective {
|
||||
if (!cache) cache = build();
|
||||
return cache;
|
||||
}
|
||||
|
||||
export function getRawConfig(): Record<ConfigKey, string> {
|
||||
const rows = db.select().from(settings).all();
|
||||
const out: Partial<Record<ConfigKey, string>> = {};
|
||||
for (const row of rows) {
|
||||
if (!(CONFIG_KEYS as readonly string[]).includes(row.key)) continue;
|
||||
const key = row.key as ConfigKey;
|
||||
out[key] = ENCRYPTED_KEYS.has(key) ? (decryptValue(row.value) ?? '') : row.value;
|
||||
}
|
||||
return out as Record<ConfigKey, string>;
|
||||
}
|
||||
|
||||
export function refreshConfig(): Effective {
|
||||
cache = build();
|
||||
return cache;
|
||||
}
|
||||
|
||||
export function setConfigValues(updates: Partial<Record<ConfigKey, unknown>>): void {
|
||||
const now = new Date();
|
||||
for (const [k, v] of Object.entries(updates)) {
|
||||
const key = k as ConfigKey;
|
||||
if (!(CONFIG_KEYS as readonly string[]).includes(key)) continue;
|
||||
const raw =
|
||||
v === undefined || v === null
|
||||
? ''
|
||||
: BOOLEAN_KEYS.has(key)
|
||||
? v
|
||||
? 'true'
|
||||
: 'false'
|
||||
: String(v);
|
||||
const stored = ENCRYPTED_KEYS.has(key) && raw !== '' ? encrypt(raw, env.CRYPTO_SECRET) : raw;
|
||||
db.insert(settings)
|
||||
.values({ key, updatedAt: now, value: stored })
|
||||
.onConflictDoUpdate({ set: { updatedAt: now, value: stored }, target: settings.key })
|
||||
.run();
|
||||
}
|
||||
refreshConfig();
|
||||
}
|
||||
|
||||
function build(): Effective {
|
||||
const rows = db.select().from(settings).all();
|
||||
const overrides: Record<string, unknown> = {};
|
||||
for (const row of rows) {
|
||||
if (!(CONFIG_KEYS as readonly string[]).includes(row.key)) continue;
|
||||
overrides[row.key] = parseValue(row.key as ConfigKey, row.value);
|
||||
}
|
||||
return { ...env, ...overrides } as Effective;
|
||||
}
|
||||
|
||||
function parseValue(key: ConfigKey, raw: string): unknown {
|
||||
const value = ENCRYPTED_KEYS.has(key) ? (decryptValue(raw) ?? '') : raw;
|
||||
if (BOOLEAN_KEYS.has(key)) return value === 'true';
|
||||
if (NUMBER_KEYS.has(key)) return value === '' ? undefined : Number(value);
|
||||
return value;
|
||||
}
|
||||
@@ -4,6 +4,14 @@ import { int, sqliteTable, text } from 'drizzle-orm/sqlite-core';
|
||||
import { account, session, twoFactor, user } from './auth-schema';
|
||||
import { encryptedText } from './custom-types';
|
||||
|
||||
export const settings = sqliteTable('settings', {
|
||||
key: text('key').primaryKey(),
|
||||
updatedAt: int('updated_at', { mode: 'timestamp_ms' })
|
||||
.$onUpdate(() => new Date())
|
||||
.notNull(),
|
||||
value: text('value').notNull()
|
||||
});
|
||||
|
||||
export const machines = sqliteTable('machines', {
|
||||
// Full base URL incl. scheme, e.g. https://10.0.0.5:9999 — lets the user pick http/https.
|
||||
address: text('address').notNull().default('http://127.0.0.1:9999'),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { env } from '$lib/const/schema';
|
||||
import { getConfig } from '$lib/server/config';
|
||||
import nodemailer from 'nodemailer';
|
||||
|
||||
import type { MailPayload } from './schemas';
|
||||
@@ -6,7 +6,8 @@ import type { MailPayload } from './schemas';
|
||||
// Sends mail directly — no request context, so it's safe from Better Auth background
|
||||
// tasks that run after the response. Both the Emailer and /api/emailer/send call this.
|
||||
export async function sendMail(data: MailPayload) {
|
||||
if (!env.SMTP_HOST) {
|
||||
const cfg = getConfig();
|
||||
if (!cfg.SMTP_HOST) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { html: _html, ...log } = data;
|
||||
console.log('\n%s\n\n', log.plainText);
|
||||
@@ -14,14 +15,14 @@ export async function sendMail(data: MailPayload) {
|
||||
}
|
||||
|
||||
const transporter = nodemailer.createTransport({
|
||||
auth: { pass: env.SMTP_PASS, user: env.SMTP_USER },
|
||||
host: env.SMTP_HOST,
|
||||
port: env.SMTP_PORT,
|
||||
secure: env.SMTP_SSL
|
||||
auth: { pass: cfg.SMTP_PASS, user: cfg.SMTP_USER },
|
||||
host: cfg.SMTP_HOST,
|
||||
port: cfg.SMTP_PORT,
|
||||
secure: cfg.SMTP_SSL
|
||||
});
|
||||
|
||||
await transporter.sendMail({
|
||||
from: env.SMTP_FROM,
|
||||
from: cfg.SMTP_FROM,
|
||||
html: data.html,
|
||||
subject: data.subject,
|
||||
to: data.to
|
||||
|
||||
@@ -31,3 +31,4 @@ export function hasPermission(
|
||||
if (!modPerms) return false;
|
||||
return modPerms.includes('*') || modPerms.includes(perm);
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
</div>
|
||||
</header>
|
||||
{/if}
|
||||
<div class="w-full flex flex-col h-full">
|
||||
<div class="w-full flex flex-col h-full text-balance">
|
||||
{@render children()}
|
||||
</div>
|
||||
</Sidebar.Inset>
|
||||
|
||||
+14
-10
@@ -79,15 +79,19 @@
|
||||
<span class="font-bold text-lg tracking-tight">{m.appname()}</span>
|
||||
</a>
|
||||
<div class="ms-auto flex items-center gap-3">
|
||||
<a href={resolve('/auth/sign-in')} class={buttonVariants({ size: 'sm', variant: 'ghost' })}>
|
||||
<a href={resolve('/dashboard')} class={buttonVariants({ size: 'sm', variant: 'ghost' })}>
|
||||
{m.login()}
|
||||
</a>
|
||||
<a href={resolve('/auth/sign-up')} class={buttonVariants({ size: 'sm', variant: 'default' })}>
|
||||
<a href='#get-start' class={buttonVariants({ size: 'sm', variant: 'default' })}>
|
||||
{m.landing_hero_cta_start()}
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
:global(html,body){
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
</style>
|
||||
<main class="min-h-screen pt-16 text-foreground antialiased selection:bg-primary/20">
|
||||
<!-- Hero Section -->
|
||||
<section class="border-b border-border bg-linear-to-b from-background to-muted/20">
|
||||
@@ -118,7 +122,7 @@
|
||||
rel="noreferrer"
|
||||
class={buttonVariants({ size: 'lg', variant: 'outline' })}
|
||||
>
|
||||
{m.landing_hero_cta_github()}
|
||||
{m.landing_footer_gitea()}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -141,7 +145,7 @@
|
||||
>
|
||||
<!-- Card.Root 4 (Uptime) -->
|
||||
<div
|
||||
class="absolute w-[280px] scale-90 translate-x-[-75px] -translate-y-3 opacity-40 origin-center pointer-events-none z-10"
|
||||
class="absolute w-70 scale-90 -translate-x-18.75 -translate-y-3 opacity-40 origin-center pointer-events-none z-10"
|
||||
>
|
||||
<KpiCard
|
||||
label={m.dashboard_uptime()}
|
||||
@@ -152,7 +156,7 @@
|
||||
</div>
|
||||
<!-- Card.Root 3 (Load Average) -->
|
||||
<div
|
||||
class="absolute w-[280px] scale-90 translate-x-[-25px] -translate-y-1 opacity-95 origin-center pointer-events-none z-20"
|
||||
class="absolute w-70 scale-90 -translate-x-6.25 -translate-y-1 opacity-95 origin-center pointer-events-none z-20"
|
||||
>
|
||||
<KpiCard
|
||||
label={m.dashboard_load_average()}
|
||||
@@ -163,7 +167,7 @@
|
||||
</div>
|
||||
<!-- Card.Root 2 (Memory) -->
|
||||
<div
|
||||
class="absolute w-[280px] scale-90 translate-x-[25px] translate-y-1 opacity-97 origin-center pointer-events-none z-30"
|
||||
class="absolute w-70 scale-90 translate-x-6.25 translate-y-1 opacity-97 origin-center pointer-events-none z-30"
|
||||
>
|
||||
<KpiCard
|
||||
label={m.dashboard_memory()}
|
||||
@@ -174,7 +178,7 @@
|
||||
</div>
|
||||
<!-- Card.Root 1 (CPU) -->
|
||||
<div
|
||||
class="absolute w-[280px] scale-90 translate-x-[75px] translate-y-3 opacity-100 origin-center shadow-2xl z-40"
|
||||
class="absolute w-70 scale-90 translate-x-18.75 translate-y-3 opacity-100 origin-center shadow-2xl z-40"
|
||||
>
|
||||
<KpiCard
|
||||
label={m.dashboard_cpu()}
|
||||
@@ -354,8 +358,8 @@
|
||||
>
|
||||
<ShieldAlertIcon class="size-4 shrink-0 text-amber-500 mt-0.5" />
|
||||
<div>
|
||||
<strong>Security note:</strong>
|
||||
{m.landing_security_note().replace('Security note:', '').trim()}
|
||||
<strong>{m.landing_security_note()}</strong>
|
||||
{m.landing_security_note_text()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,309 @@
|
||||
<script lang="ts">
|
||||
import MailIcon from '@lucide/svelte/icons/mail';
|
||||
import RefreshCwIcon from '@lucide/svelte/icons/refresh-cw';
|
||||
import PageMeta from '$lib/components/seo/page-meta.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Field from '$lib/components/ui/field';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { getAppConfig, saveAppConfig, sendTestEmail } from '$lib/remotes/config.remote';
|
||||
import { configFormSchema } from '$lib/schemas/config';
|
||||
import { extractErrorMessage } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const cfg = $derived(getAppConfig());
|
||||
const id = $props.id();
|
||||
|
||||
let testing = $state(false);
|
||||
let testTo = $state('');
|
||||
|
||||
const c = $derived(cfg.current);
|
||||
|
||||
async function test() {
|
||||
if (!testTo) return;
|
||||
testing = true;
|
||||
try {
|
||||
await sendTestEmail({ to: testTo });
|
||||
toast.success(m.admin_config_smtp_test_sent());
|
||||
} catch (err) {
|
||||
toast.error(extractErrorMessage(err) ?? m.errors_generic());
|
||||
} finally {
|
||||
testing = false;
|
||||
}
|
||||
}
|
||||
|
||||
const socials = [
|
||||
{ idKey: 'FACEBOOK_CLIENT_ID', label: 'Facebook', secretKey: 'FACEBOOK_CLIENT_SECRET' },
|
||||
{ idKey: 'GITHUB_CLIENT_ID', label: 'GitHub', secretKey: 'GITHUB_CLIENT_SECRET' },
|
||||
{ idKey: 'GOOGLE_CLIENT_ID', label: 'Google', secretKey: 'GOOGLE_CLIENT_SECRET' }
|
||||
] as const;
|
||||
</script>
|
||||
|
||||
<PageMeta title={m.seo_title_admin_config()} description={m.seo_desc_admin_config()} />
|
||||
|
||||
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<h1 class="text-2xl font-semibold tracking-tight">{m.nav_admin_config()}</h1>
|
||||
<p class="text-muted-foreground text-sm">{m.admin_config_description()}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
title={m.dashboard_refresh()}
|
||||
onclick={() => cfg.refresh()}
|
||||
>
|
||||
<RefreshCwIcon class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
oninput={() => saveAppConfig.validate()}
|
||||
{...saveAppConfig.preflight(configFormSchema).enhance(async ({ submit }) => {
|
||||
try {
|
||||
await submit();
|
||||
toast.success(m.saved());
|
||||
} catch (err) {
|
||||
toast.error(extractErrorMessage(err) ?? m.errors_generic());
|
||||
}
|
||||
})}
|
||||
>
|
||||
<Field.Group>
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.admin_config_auth_title()}</Card.Title>
|
||||
<Card.Description>{m.admin_config_auth_description()} · {m.admin_config_restart_required()}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Field.Group>
|
||||
<Field.Label for="{id}-disable-signup">
|
||||
<Field.Field orientation="horizontal">
|
||||
<Field.Content>
|
||||
<span>
|
||||
{m.admin_config_disable_signup()}
|
||||
</span>
|
||||
<Field.Description>
|
||||
{m.admin_config_disable_signup_hint()}
|
||||
</Field.Description>
|
||||
</Field.Content>
|
||||
<Switch
|
||||
id="{id}-disable-signup"
|
||||
name="DISABLE_SIGNUP"
|
||||
value="yes"
|
||||
checked={c?.boot.DISABLE_SIGNUP ?? false}
|
||||
/>
|
||||
</Field.Field>
|
||||
</Field.Label>
|
||||
<Field.Label for="{id}-enable-emailpw">
|
||||
<Field.Field orientation="horizontal">
|
||||
<Field.Content>
|
||||
<span>
|
||||
{m.admin_config_enable_email_password()}
|
||||
</span>
|
||||
<Field.Description>
|
||||
{m.admin_config_enable_email_password_hint()}
|
||||
</Field.Description>
|
||||
</Field.Content>
|
||||
<Switch
|
||||
id="{id}-enable-emailpw"
|
||||
name="ENABLE_EMAIL_AND_PASSWORD"
|
||||
value="yes"
|
||||
checked={c?.boot.ENABLE_EMAIL_AND_PASSWORD ?? true}
|
||||
/>
|
||||
</Field.Field>
|
||||
</Field.Label>
|
||||
<Field.Label for="{id}-enable-2fa">
|
||||
<Field.Field orientation="horizontal">
|
||||
<Field.Content>
|
||||
<span>
|
||||
{m.admin_config_enable_2fa()}
|
||||
</span>
|
||||
<Field.Description>{m.admin_config_enable_2fa_hint()}</Field.Description>
|
||||
</Field.Content>
|
||||
<Switch
|
||||
id="{id}-enable-2fa"
|
||||
name="ENABLE_2FA"
|
||||
value="yes"
|
||||
checked={c?.ENABLE_2FA ?? false}
|
||||
/>
|
||||
</Field.Field>
|
||||
</Field.Label>
|
||||
<Field.Field>
|
||||
<Field.Label for="{id}-origin">
|
||||
{m.admin_config_origin()}
|
||||
</Field.Label>
|
||||
<Input
|
||||
id="{id}-origin"
|
||||
placeholder="https://nadir.example.com"
|
||||
{...saveAppConfig.fields.ORIGIN.as('url', c?.boot.ORIGIN ?? '')}
|
||||
/>
|
||||
<Field.Description>
|
||||
{m.admin_config_origin_hint()}
|
||||
</Field.Description>
|
||||
{#each saveAppConfig.fields.ORIGIN.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.admin_config_smtp_title()}</Card.Title>
|
||||
<Card.Description>{m.admin_config_smtp_description()}</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Field.Group>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Field.Field class="sm:col-span-2">
|
||||
<Field.Label for="{id}-smtp-host">{m.admin_config_smtp_host()}</Field.Label>
|
||||
<Input
|
||||
id="{id}-smtp-host"
|
||||
placeholder="smtp.example.com"
|
||||
{...saveAppConfig.fields.SMTP_HOST.as('text', c?.SMTP_HOST ?? '')}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="{id}-smtp-port">{m.admin_config_smtp_port()}</Field.Label>
|
||||
<Input
|
||||
id="{id}-smtp-port"
|
||||
type="number"
|
||||
min={1}
|
||||
max={65535}
|
||||
placeholder="587"
|
||||
{...saveAppConfig.fields.SMTP_PORT.as('text', String(c?.SMTP_PORT ?? ''))}
|
||||
/>
|
||||
{#each saveAppConfig.fields.SMTP_PORT.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Field.Field>
|
||||
<Field.Label for="{id}-smtp-user">{m.admin_config_smtp_user()}</Field.Label>
|
||||
<Input
|
||||
id="{id}-smtp-user"
|
||||
autocomplete="off"
|
||||
{...saveAppConfig.fields.SMTP_USER.as('text', c?.SMTP_USER ?? '')}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="{id}-smtp-pass">{m.admin_config_smtp_pass()}</Field.Label>
|
||||
<Input
|
||||
id="{id}-smtp-pass"
|
||||
autocomplete="new-password"
|
||||
placeholder={c?.SMTP_PASS_SET ? '••••••••' : ''}
|
||||
{...saveAppConfig.fields.SMTP_PASS.as('password', '')}
|
||||
/>
|
||||
<Field.Description>{m.admin_config_smtp_pass_hint()}</Field.Description>
|
||||
</Field.Field>
|
||||
</div>
|
||||
<Field.Field>
|
||||
<Field.Label for="{id}-smtp-from">{m.admin_config_smtp_from()}</Field.Label>
|
||||
<Input
|
||||
id="{id}-smtp-from"
|
||||
placeholder="Nadir <noreply@example.com>"
|
||||
{...saveAppConfig.fields.SMTP_FROM.as('text', c?.SMTP_FROM ?? '')}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Field.Content>
|
||||
<Field.Label for="{id}-smtp-ssl">{m.admin_config_smtp_ssl()}</Field.Label>
|
||||
<Field.Description>{m.admin_config_smtp_ssl_hint()}</Field.Description>
|
||||
</Field.Content>
|
||||
<Switch
|
||||
id="{id}-smtp-ssl"
|
||||
name="SMTP_SSL"
|
||||
value="yes"
|
||||
checked={c?.SMTP_SSL ?? false}
|
||||
/>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Card.Content>
|
||||
<Card.Footer class="border-t pt-4">
|
||||
<Field.Field orientation="responsive" class="w-full">
|
||||
<Field.Content>
|
||||
<Field.Label for="{id}-smtp-test-to">
|
||||
{m.admin_config_smtp_test_to()}
|
||||
</Field.Label>
|
||||
<Input
|
||||
id="{id}-smtp-test-to"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
bind:value={testTo}
|
||||
/>
|
||||
</Field.Content>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
class="mt-auto"
|
||||
disabled={testing || !testTo || !c?.SMTP_HOST}
|
||||
onclick={test}
|
||||
>
|
||||
<MailIcon class="size-4" />
|
||||
{m.admin_config_smtp_test()}
|
||||
</Button>
|
||||
</Field.Field>
|
||||
</Card.Footer>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{m.admin_config_social_title()}</Card.Title>
|
||||
<Card.Description>
|
||||
{m.admin_config_social_description()} · {m.admin_config_restart_required()}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
class="text-muted-foreground hidden text-xs font-medium sm:grid sm:grid-cols-[7rem_1fr_1fr] sm:gap-4 sm:px-1"
|
||||
>
|
||||
<span></span>
|
||||
<span>{m.admin_config_social_client_id()}</span>
|
||||
<span>{m.admin_config_social_client_secret()}</span>
|
||||
</div>
|
||||
{#each socials as p (p.label)}
|
||||
<div
|
||||
class="grid grid-cols-1 gap-3 sm:grid-cols-[7rem_1fr_1fr] sm:items-center sm:gap-4"
|
||||
>
|
||||
<span class="text-sm font-medium">{p.label}</span>
|
||||
<Field.Field class="gap-1.5">
|
||||
<Field.Label class="sm:hidden" for="{id}-{p.idKey}">
|
||||
{p.label} — {m.admin_config_social_client_id()}
|
||||
</Field.Label>
|
||||
<Input
|
||||
id="{id}-{p.idKey}"
|
||||
autocomplete="off"
|
||||
aria-label={`${p.label} ${m.admin_config_social_client_id()}`}
|
||||
{...saveAppConfig.fields[p.idKey].as('text', c?.[p.idKey] ?? '')}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field class="gap-1.5">
|
||||
<Field.Label class="sm:hidden" for="{id}-{p.secretKey}">
|
||||
{p.label} — {m.admin_config_social_client_secret()}
|
||||
</Field.Label>
|
||||
<Input
|
||||
id="{id}-{p.secretKey}"
|
||||
autocomplete="new-password"
|
||||
aria-label={`${p.label} ${m.admin_config_social_client_secret()}`}
|
||||
placeholder={c?.[`${p.secretKey}_SET`] ? '••••••••' : ''}
|
||||
{...saveAppConfig.fields[p.secretKey].as('password', '')}
|
||||
/>
|
||||
</Field.Field>
|
||||
</div>
|
||||
{/each}
|
||||
<p class="text-muted-foreground text-xs">{m.admin_config_social_secret_hint()}</p>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<div class="flex justify-end">
|
||||
<Button type="submit" disabled={!!saveAppConfig.pending || !c}>{m.save()}</Button>
|
||||
</div>
|
||||
</Field.Group>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Field from '$lib/components/ui/field';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { NativeSelect } from '$lib/components/ui/native-select';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
@@ -227,29 +226,43 @@
|
||||
<h3 class="text-sm font-semibold">{m.users_filter_title()}</h3>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3 p-2">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label class="flex items-center gap-1 text-xs"
|
||||
>{m.users_filter_active()} <InfoIcon class="size-3 opacity-60" /></Label
|
||||
<Field.Field>
|
||||
<Field.Label for="flt-active-{id}" class="flex items-center gap-1 text-xs">
|
||||
{m.users_filter_active()} <InfoIcon class="size-3 opacity-60" />
|
||||
</Field.Label>
|
||||
<NativeSelect
|
||||
id="flt-active-{id}"
|
||||
bind:value={filters.activeWithin}
|
||||
onchange={bump}
|
||||
class="h-9"
|
||||
>
|
||||
<NativeSelect bind:value={filters.activeWithin} onchange={bump} class="h-9">
|
||||
<option value="all">{m.users_filter_any_time()}</option>
|
||||
<option value="24h">{m.users_filter_24h()}</option>
|
||||
<option value="7d">{m.users_filter_7d()}</option>
|
||||
<option value="30d">{m.users_filter_30d()}</option>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label class="text-xs">{m.users_filter_joined()}</Label>
|
||||
<NativeSelect bind:value={filters.joinedWithin} onchange={bump} class="h-9">
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="flt-joined-{id}" class="text-xs">
|
||||
{m.users_filter_joined()}
|
||||
</Field.Label>
|
||||
<NativeSelect
|
||||
id="flt-joined-{id}"
|
||||
bind:value={filters.joinedWithin}
|
||||
onchange={bump}
|
||||
class="h-9"
|
||||
>
|
||||
<option value="all">{m.users_filter_any_time()}</option>
|
||||
<option value="24h">{m.users_filter_24h()}</option>
|
||||
<option value="7d">{m.users_filter_7d()}</option>
|
||||
<option value="30d">{m.users_filter_30d()}</option>
|
||||
</NativeSelect>
|
||||
</div>
|
||||
</Field.Field>
|
||||
</div>
|
||||
<div class="border-t p-2">
|
||||
<Label class="mb-2 block text-xs">{m.users_filter_date_range()}</Label>
|
||||
<p class="text-muted-foreground mb-2 block text-xs font-medium">
|
||||
{m.users_filter_date_range()}
|
||||
</p>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div class="relative">
|
||||
<CalendarIcon
|
||||
@@ -312,7 +325,9 @@
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 border-t p-2">
|
||||
<Label class="text-xs">{m.users_filter_display()}</Label>
|
||||
<span class="text-muted-foreground text-xs font-medium">
|
||||
{m.users_filter_display()}
|
||||
</span>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-sm">{m.users_rows_per_page()}</span>
|
||||
<Input
|
||||
|
||||
@@ -12,12 +12,13 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Field from '$lib/components/ui/field';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { deleteHost, listHosts, upsertHost } from '$lib/remotes/networking.remote';
|
||||
import { getWhoami } from '$lib/remotes/system.remote';
|
||||
import { upsertHostSchema } from '$lib/schemas/networking-host';
|
||||
import { extractErrorMessage, hasPermission } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
@@ -31,8 +32,9 @@
|
||||
|
||||
let search = $state('');
|
||||
let editOpen = $state(false);
|
||||
let editForm = $state({ hostnames: '', ip: '' });
|
||||
let editingExisting = $state(false);
|
||||
let editingIp = $state('');
|
||||
let editingHostnames = $state('');
|
||||
let deleteOpen = $state(false);
|
||||
let deleting = $state<Host | null>(null);
|
||||
|
||||
@@ -69,29 +71,18 @@
|
||||
|
||||
function openAdd() {
|
||||
editingExisting = false;
|
||||
editForm = { hostnames: '', ip: '' };
|
||||
editingIp = '';
|
||||
editingHostnames = '';
|
||||
editOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(h: Host) {
|
||||
editingExisting = true;
|
||||
editForm = { hostnames: (h.hostnames ?? []).join(' '), ip: h.ip };
|
||||
editingIp = h.ip;
|
||||
editingHostnames = (h.hostnames ?? []).join(' ');
|
||||
editOpen = true;
|
||||
}
|
||||
|
||||
async function doSave() {
|
||||
const ip = editForm.ip.trim();
|
||||
const hostnames = editForm.hostnames.split(/\s+/).filter(Boolean);
|
||||
if (!ip || !hostnames.length) return;
|
||||
try {
|
||||
await upsertHost({ hostnames, ip, machineId });
|
||||
toast.success(m.networking_host_saved());
|
||||
editOpen = false;
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!deleting) return;
|
||||
try {
|
||||
@@ -203,36 +194,50 @@
|
||||
<Dialog.Description>{m.networking_host_add_description()}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
doSave();
|
||||
}}
|
||||
class="flex flex-col gap-3"
|
||||
oninput={() => upsertHost.validate()}
|
||||
{...upsertHost.preflight(upsertHostSchema).enhance(async ({ submit }) => {
|
||||
try {
|
||||
await submit();
|
||||
toast.success(m.networking_host_saved());
|
||||
editOpen = false;
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="h-ip-{id}">{m.networking_col_ip()}</Label>
|
||||
<Input
|
||||
id="h-ip-{id}"
|
||||
bind:value={editForm.ip}
|
||||
placeholder="192.168.1.10"
|
||||
required
|
||||
readonly={editingExisting}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="h-names-{id}">{m.networking_col_hostnames()}</Label>
|
||||
<Input
|
||||
id="h-names-{id}"
|
||||
bind:value={editForm.hostnames}
|
||||
placeholder="server server.local"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Dialog.Footer class="mt-2">
|
||||
<input {...upsertHost.fields.machineId.as('hidden', machineId)} />
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label for="h-ip-{id}">{m.networking_col_ip()}</Field.Label>
|
||||
<Input
|
||||
id="h-ip-{id}"
|
||||
placeholder="192.168.1.10"
|
||||
required
|
||||
readonly={editingExisting}
|
||||
{...upsertHost.fields.ip.as('text', editingIp)}
|
||||
/>
|
||||
{#each upsertHost.fields.ip.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="h-names-{id}">{m.networking_col_hostnames()}</Field.Label>
|
||||
<Input
|
||||
id="h-names-{id}"
|
||||
placeholder="server server.local"
|
||||
required
|
||||
{...upsertHost.fields.hostnames.as('text', editingHostnames)}
|
||||
/>
|
||||
{#each upsertHost.fields.hostnames.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
<Dialog.Footer class="mt-4">
|
||||
<Button type="button" variant="outline" onclick={() => (editOpen = false)}>
|
||||
{m.cancel()}
|
||||
</Button>
|
||||
<Button type="submit">{m.save()}</Button>
|
||||
<Button type="submit" disabled={!!upsertHost.pending}>{m.save()}</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
|
||||
+33
-35
@@ -1,5 +1,4 @@
|
||||
<script lang="ts">
|
||||
import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left';
|
||||
import PlusIcon from '@lucide/svelte/icons/plus';
|
||||
import TrashIcon from '@lucide/svelte/icons/trash-2';
|
||||
import { goto } from '$app/navigation';
|
||||
@@ -8,11 +7,14 @@
|
||||
import * as AlertDialog from '$lib/components/ui/alert-dialog';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Field from '$lib/components/ui/field';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as RadioGroup from '$lib/components/ui/radio-group';
|
||||
import { Separator } from '$lib/components/ui/separator';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
// ponytail: nested arrays (dns, routes) + conditional sections fight FormData serialization;
|
||||
// stays on command() with the in-memory valid derivation. Field primitives applied for a11y.
|
||||
import { applyInterfaceConfig, getInterfaceConfig } from '$lib/remotes/networking.remote';
|
||||
import { getWhoami } from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage, hasPermission } from '$lib/utils';
|
||||
@@ -114,14 +116,6 @@
|
||||
|
||||
<div class="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4">
|
||||
<div class="flex items-start gap-3">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
href={resolve('/dashboard/[machineId]/networking/interfaces', { machineId })}
|
||||
title={m.error_action_back()}
|
||||
>
|
||||
<ArrowLeftIcon class="size-4" />
|
||||
</Button>
|
||||
<div class="flex min-w-0 flex-col gap-0.5">
|
||||
<h1 class="font-mono text-2xl font-semibold tracking-tight">{name}</h1>
|
||||
<p class="text-muted-foreground text-sm">{m.networking_configure_description()}</p>
|
||||
@@ -161,17 +155,17 @@
|
||||
{#if v4Method === 'static'}
|
||||
<Separator />
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_6rem]">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="v4-addr-{id}">{m.machine_address()}</Label>
|
||||
<Field.Field>
|
||||
<Field.Label for="v4-addr-{id}">{m.machine_address()}</Field.Label>
|
||||
<Input
|
||||
id="v4-addr-{id}"
|
||||
bind:value={v4.address}
|
||||
placeholder="192.168.1.10"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="v4-prefix-{id}">{m.prefix()}</Label>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="v4-prefix-{id}">{m.prefix()}</Field.Label>
|
||||
<Input
|
||||
id="v4-prefix-{id}"
|
||||
type="number"
|
||||
@@ -180,12 +174,14 @@
|
||||
bind:value={v4.prefix}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</Field.Field>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="v4-gw-{id}">{m.networking_col_gateway()} {m.optional()}</Label>
|
||||
<Field.Field>
|
||||
<Field.Label for="v4-gw-{id}">
|
||||
{m.networking_col_gateway()} {m.optional()}
|
||||
</Field.Label>
|
||||
<Input id="v4-gw-{id}" bind:value={v4.gateway} placeholder="192.168.1.1" />
|
||||
</div>
|
||||
</Field.Field>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -221,17 +217,17 @@
|
||||
{#if v6Method === 'static'}
|
||||
<Separator />
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-[1fr_6rem]">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="v6-addr-{id}">{m.machine_address()}</Label>
|
||||
<Field.Field>
|
||||
<Field.Label for="v6-addr-{id}">{m.machine_address()}</Field.Label>
|
||||
<Input
|
||||
id="v6-addr-{id}"
|
||||
bind:value={v6.address}
|
||||
placeholder="2001:db8::10"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="v6-prefix-{id}">{m.prefix()}</Label>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="v6-prefix-{id}">{m.prefix()}</Field.Label>
|
||||
<Input
|
||||
id="v6-prefix-{id}"
|
||||
type="number"
|
||||
@@ -240,12 +236,14 @@
|
||||
bind:value={v6.prefix}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</Field.Field>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="v6-gw-{id}">{m.networking_col_gateway()} {m.optional()}</Label>
|
||||
<Field.Field>
|
||||
<Field.Label for="v6-gw-{id}">
|
||||
{m.networking_col_gateway()} {m.optional()}
|
||||
</Field.Label>
|
||||
<Input id="v6-gw-{id}" bind:value={v6.gateway} placeholder="2001:db8::1" />
|
||||
</div>
|
||||
</Field.Field>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
@@ -323,9 +321,9 @@
|
||||
|
||||
<!-- Rollback + submit -->
|
||||
<Card.Root>
|
||||
<Card.Content class="flex flex-col gap-3 py-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="rollback-{id}">{m.networking_rollback_seconds()}</Label>
|
||||
<Card.Content class="flex flex-col gap-3 py-4 sm:items-end sm:justify-between">
|
||||
<Field.Field>
|
||||
<Field.Label for="rollback-{id}">{m.networking_rollback_seconds()}</Field.Label>
|
||||
<Input
|
||||
id="rollback-{id}"
|
||||
type="number"
|
||||
@@ -334,11 +332,11 @@
|
||||
class="w-32"
|
||||
bind:value={rollbackSeconds}
|
||||
/>
|
||||
<p class="text-muted-foreground text-xs">{m.networking_rollback_seconds_hint()}</p>
|
||||
</div>
|
||||
<Button type="submit" disabled={!valid || submitting || !canWrite}
|
||||
>{m.networking_apply()}</Button
|
||||
>
|
||||
<Field.Description>{m.networking_rollback_seconds_hint()}</Field.Description>
|
||||
</Field.Field>
|
||||
<Button type="submit" disabled={!valid || submitting || !canWrite}>
|
||||
{m.networking_apply()}
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</form>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Field from '$lib/components/ui/field';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import * as Tooltip from '$lib/components/ui/tooltip';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
@@ -266,16 +266,17 @@
|
||||
e.preventDefault();
|
||||
doInstall();
|
||||
}}
|
||||
class="flex flex-col gap-3"
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="pkg-name-{id}">{m.packages_col_name()}</Label>
|
||||
<Input id="pkg-name-{id}" bind:value={installName} placeholder="e.g. htop" required />
|
||||
</div>
|
||||
<Dialog.Footer class="mt-2">
|
||||
<Button type="button" variant="outline" onclick={() => (createOpen = false)}
|
||||
>{m.cancel()}</Button
|
||||
>
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label for="pkg-name-{id}">{m.packages_col_name()}</Field.Label>
|
||||
<Input id="pkg-name-{id}" bind:value={installName} placeholder="e.g. htop" required />
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
<Dialog.Footer class="mt-4">
|
||||
<Button type="button" variant="outline" onclick={() => (createOpen = false)}>
|
||||
{m.cancel()}
|
||||
</Button>
|
||||
<Button type="submit">{m.packages_install_button()}</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { Badge } from '$lib/components/ui/badge';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Field from '$lib/components/ui/field';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
@@ -161,10 +162,10 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex flex-col gap-4 p-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
<Field.Set class="gap-2">
|
||||
<Field.Legend variant="label" class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
|
||||
{m.services_active_filter()}
|
||||
</Label>
|
||||
</Field.Legend>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterActive.active} />
|
||||
@@ -183,11 +184,11 @@
|
||||
{m.services_filter_other()}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 border-t pt-3">
|
||||
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
</Field.Set>
|
||||
<Field.Set class="gap-2 border-t pt-3">
|
||||
<Field.Legend variant="label" class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
|
||||
{m.services_load_filter()}
|
||||
</Label>
|
||||
</Field.Legend>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterLoad.loaded} />
|
||||
@@ -206,11 +207,11 @@
|
||||
{m.services_filter_error()}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 border-t pt-3">
|
||||
<Label class="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
</Field.Set>
|
||||
<Field.Set class="gap-2 border-t pt-3">
|
||||
<Field.Legend variant="label" class="text-muted-foreground text-xs font-semibold tracking-wider uppercase">
|
||||
{m.services_sub_filter()}
|
||||
</Label>
|
||||
</Field.Legend>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<Label class="flex items-center gap-2 font-normal text-sm cursor-pointer">
|
||||
<Checkbox bind:checked={filterSub.running} />
|
||||
@@ -229,7 +230,7 @@
|
||||
{m.services_filter_other()}
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</Field.Set>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet columns()}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Empty from '$lib/components/ui/empty';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import * as Field from '$lib/components/ui/field';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
@@ -419,9 +420,11 @@
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-80 p-4" align="end">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label for="logs-lines-{id}" class="text-xs">{m.services_logs_lines()}</Label>
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label for="logs-lines-{id}" class="text-xs">
|
||||
{m.services_logs_lines()}
|
||||
</Field.Label>
|
||||
<Input
|
||||
id="logs-lines-{id}"
|
||||
type="number"
|
||||
@@ -434,11 +437,11 @@
|
||||
if (n >= 1 && n <= 10000) lines.current = n;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label for="logs-priority-{id}" class="text-xs"
|
||||
>{m.services_logs_priority()}</Label
|
||||
>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="logs-priority-{id}" class="text-xs">
|
||||
{m.services_logs_priority()}
|
||||
</Field.Label>
|
||||
<select
|
||||
id="logs-priority-{id}"
|
||||
class="border-input bg-background h-9 w-full rounded-md border px-2 text-sm"
|
||||
@@ -455,9 +458,11 @@
|
||||
<option value={6}>{m.syslog_info()}</option>
|
||||
<option value={7}>{m.syslog_debug()}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label for="logs-since-{id}" class="text-xs">{m.services_logs_since()}</Label>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="logs-since-{id}" class="text-xs">
|
||||
{m.services_logs_since()}
|
||||
</Field.Label>
|
||||
<select
|
||||
id="logs-since-{id}"
|
||||
class="border-input bg-background h-9 w-full rounded-md border px-2 text-sm"
|
||||
@@ -471,19 +476,19 @@
|
||||
<option value="today">{m.services_logs_since_today()}</option>
|
||||
<option value="yesterday">{m.services_logs_since_yesterday()}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1">
|
||||
<Label for="logs-search-{id}" class="text-xs"
|
||||
>{m.services_logs_search_placeholder()}</Label
|
||||
>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="logs-search-{id}" class="text-xs">
|
||||
{m.services_logs_search_placeholder()}
|
||||
</Field.Label>
|
||||
<Input
|
||||
id="logs-search-{id}"
|
||||
placeholder={m.services_logs_search_placeholder()}
|
||||
value={search}
|
||||
oninput={onLogSearch}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
<Label class="flex items-center gap-2 text-sm font-normal">
|
||||
|
||||
@@ -10,11 +10,12 @@
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Field from '$lib/components/ui/field';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { addMount, listFstab, listMounts, removeMount } from '$lib/remotes/storage.remote';
|
||||
import { addMountSchema } from '$lib/schemas/storage-mount';
|
||||
import { getWhoami } from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage, hasPermission } from '$lib/utils';
|
||||
import { PersistedState } from 'runed';
|
||||
@@ -78,7 +79,6 @@
|
||||
});
|
||||
|
||||
let createOpen = $state(false);
|
||||
let createForm = $state({ device: '', fstype: '', mountpoint: '', options: 'defaults' });
|
||||
let deleteOpen = $state(false);
|
||||
let deleting = $state<Mount | null>(null);
|
||||
|
||||
@@ -87,23 +87,6 @@
|
||||
toast.error(extractErrorMessage(e) ?? m.errors_generic());
|
||||
}
|
||||
|
||||
async function doCreate() {
|
||||
try {
|
||||
await addMount({
|
||||
device: createForm.device.trim(),
|
||||
fstype: createForm.fstype.trim(),
|
||||
machineId,
|
||||
mountpoint: createForm.mountpoint.trim(),
|
||||
options: createForm.options.trim() || undefined
|
||||
});
|
||||
toast.success(m.storage_mount_added());
|
||||
createOpen = false;
|
||||
createForm = { device: '', fstype: '', mountpoint: '', options: 'defaults' };
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!deleting) return;
|
||||
try {
|
||||
@@ -214,43 +197,69 @@
|
||||
<Dialog.Description>{m.storage_mount_add_description()}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
doCreate();
|
||||
}}
|
||||
class="flex flex-col gap-3"
|
||||
oninput={() => addMount.validate()}
|
||||
{...addMount.preflight(addMountSchema).enhance(async ({ submit }) => {
|
||||
try {
|
||||
await submit();
|
||||
toast.success(m.storage_mount_added());
|
||||
createOpen = false;
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="sm-device-{id}">{m.storage_col_device()}</Label>
|
||||
<Input
|
||||
id="sm-device-{id}"
|
||||
bind:value={createForm.device}
|
||||
placeholder="/dev/sdb1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="sm-mountpoint-{id}">{m.storage_col_mountpoint()}</Label>
|
||||
<Input
|
||||
id="sm-mountpoint-{id}"
|
||||
bind:value={createForm.mountpoint}
|
||||
placeholder="/mnt/data"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="sm-fstype-{id}">{m.storage_col_fstype()}</Label>
|
||||
<Input id="sm-fstype-{id}" bind:value={createForm.fstype} placeholder="ext4" required />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="sm-options-{id}">{m.storage_col_options()}</Label>
|
||||
<Input id="sm-options-{id}" bind:value={createForm.options} placeholder="defaults" />
|
||||
</div>
|
||||
<Dialog.Footer class="mt-2">
|
||||
<Button type="button" variant="outline" onclick={() => (createOpen = false)}
|
||||
>{m.cancel()}</Button
|
||||
>
|
||||
<Button type="submit">{m.storage_mount_add()}</Button>
|
||||
<input {...addMount.fields.machineId.as('hidden', machineId)} />
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label for="sm-device-{id}">{m.storage_col_device()}</Field.Label>
|
||||
<Input
|
||||
id="sm-device-{id}"
|
||||
placeholder="/dev/sdb1"
|
||||
required
|
||||
{...addMount.fields.device.as('text', '')}
|
||||
/>
|
||||
{#each addMount.fields.device.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="sm-mountpoint-{id}">{m.storage_col_mountpoint()}</Field.Label>
|
||||
<Input
|
||||
id="sm-mountpoint-{id}"
|
||||
placeholder="/mnt/data"
|
||||
required
|
||||
{...addMount.fields.mountpoint.as('text', '')}
|
||||
/>
|
||||
{#each addMount.fields.mountpoint.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="sm-fstype-{id}">{m.storage_col_fstype()}</Field.Label>
|
||||
<Input
|
||||
id="sm-fstype-{id}"
|
||||
placeholder="ext4"
|
||||
required
|
||||
{...addMount.fields.fstype.as('text', '')}
|
||||
/>
|
||||
{#each addMount.fields.fstype.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="sm-options-{id}">{m.storage_col_options()}</Field.Label>
|
||||
<Input
|
||||
id="sm-options-{id}"
|
||||
placeholder="defaults"
|
||||
{...addMount.fields.options.as('text', 'defaults')}
|
||||
/>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
<Dialog.Footer class="mt-4">
|
||||
<Button type="button" variant="outline" onclick={() => (createOpen = false)}>
|
||||
{m.cancel()}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!!addMount.pending}>{m.storage_mount_add()}</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
|
||||
@@ -6,46 +6,42 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Command from '$lib/components/ui/command';
|
||||
import * as Field from '$lib/components/ui/field';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Popover from '$lib/components/ui/popover';
|
||||
import { Switch } from '$lib/components/ui/switch';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import {
|
||||
getWhoami,
|
||||
listTimezones,
|
||||
setNtp,
|
||||
setTime,
|
||||
setTimezone,
|
||||
systemTime
|
||||
} from '$lib/remotes/system.remote';
|
||||
import { getWhoami } from '$lib/remotes/system.remote';
|
||||
import { setTimeSchema } from '$lib/schemas/system-time';
|
||||
import { extractErrorMessage, hasPermission } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const machineId = $derived(page.params.machineId!);
|
||||
const time = $derived(systemTime(machineId));
|
||||
const tzs = $derived(listTimezones(machineId));
|
||||
const formId = $props.id();
|
||||
const id = $props.id();
|
||||
const whoami = $derived(getWhoami(machineId));
|
||||
const canWrite = $derived(hasPermission('system', 'write', whoami.current?.permissions));
|
||||
|
||||
let tzOpen = $state(false);
|
||||
let saving = $state(false);
|
||||
let busy = $state(false);
|
||||
|
||||
// ponytail: builds an RFC3339 UTC string from a datetime-local value (treated as local).
|
||||
function rfc3339FromLocal(v: string) {
|
||||
return new Date(v).toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
}
|
||||
|
||||
async function withSaving<T>(fn: () => Promise<T>) {
|
||||
saving = true;
|
||||
async function withBusy<T>(fn: () => Promise<T>) {
|
||||
busy = true;
|
||||
try {
|
||||
await fn();
|
||||
toast.success(m.saved());
|
||||
} catch (e) {
|
||||
toast.error(extractErrorMessage(e) ?? m.errors_generic());
|
||||
} finally {
|
||||
saving = false;
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -67,73 +63,83 @@
|
||||
<Card.Header>
|
||||
<Card.Title>{m.system_time_current()}</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-wrap items-center gap-x-8 gap-y-2 text-sm">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-muted-foreground text-xs font-medium uppercase tracking-wider"
|
||||
>{m.system_time_timezone()}</span
|
||||
>
|
||||
<span class="font-mono font-medium">{t.timezone}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-muted-foreground text-xs font-medium uppercase tracking-wider"
|
||||
>{m.system_time_clock()}</span
|
||||
>
|
||||
<span class="font-mono tabular-nums">{t.time}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-muted-foreground text-xs font-medium uppercase tracking-wider">NTP</span
|
||||
>
|
||||
<Switch
|
||||
checked={t.ntp}
|
||||
disabled={saving || !t.can_ntp || !canWrite}
|
||||
onCheckedChange={(v) => withSaving(() => setNtp({ enabled: v, machineId }))}
|
||||
/>
|
||||
</div>
|
||||
<div class="text-muted-foreground text-xs">
|
||||
{t.ntp_synchronized ? m.system_time_ntp_synced() : m.system_time_ntp_not_synced()}
|
||||
</div>
|
||||
</Card.Content>
|
||||
<Card.Content class="border-t pt-4">
|
||||
<Popover.Root bind:open={tzOpen}>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tzOpen}
|
||||
class="w-full justify-between sm:w-80"
|
||||
disabled={!canWrite}
|
||||
{...props}
|
||||
<Card.Content>
|
||||
<Field.Group>
|
||||
<div class="flex flex-wrap items-center gap-x-8 gap-y-2 text-sm">
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-muted-foreground text-xs font-medium tracking-wider uppercase">
|
||||
{m.system_time_timezone()}
|
||||
</span>
|
||||
<span class="font-mono font-medium">{t.timezone}</span>
|
||||
</div>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-muted-foreground text-xs font-medium tracking-wider uppercase">
|
||||
{m.system_time_clock()}</span
|
||||
>
|
||||
{t.timezone}
|
||||
<ChevronsUpDownIcon class="size-4 opacity-50" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-80 p-0">
|
||||
<Command.Root>
|
||||
<Command.Input placeholder={m.system_time_search_timezone_placeholder()} />
|
||||
<Command.List class="max-h-72">
|
||||
<Command.Empty>{m.system_time_no_timezone_found()}</Command.Empty>
|
||||
{#each zones as z (z)}
|
||||
<Command.Item
|
||||
value={z}
|
||||
onSelect={async () => {
|
||||
tzOpen = false;
|
||||
if (z === t.timezone) return;
|
||||
await withSaving(() => setTimezone({ machineId, timezone: z }));
|
||||
}}
|
||||
<span class="font-mono tabular-nums">{t.time}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Field.Content>
|
||||
<Field.Label for="{id}-ntp">NTP</Field.Label>
|
||||
<Field.Description>
|
||||
{t.ntp_synchronized
|
||||
? m.system_time_ntp_synced()
|
||||
: m.system_time_ntp_not_synced()}
|
||||
</Field.Description>
|
||||
</Field.Content>
|
||||
<Switch
|
||||
id="{id}-ntp"
|
||||
checked={t.ntp}
|
||||
disabled={busy || !t.can_ntp || !canWrite}
|
||||
onCheckedChange={(v) => withBusy(() => setNtp({ enabled: v, machineId }))}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="{id}-tz">{m.system_time_timezone()}</Field.Label>
|
||||
<Popover.Root bind:open={tzOpen}>
|
||||
<Popover.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
id="{id}-tz"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={tzOpen}
|
||||
class="w-full justify-between sm:w-80"
|
||||
disabled={!canWrite}
|
||||
{...props}
|
||||
>
|
||||
<CheckIcon
|
||||
class={'mr-2 size-4 ' + (z === t.timezone ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
{z}
|
||||
</Command.Item>
|
||||
{/each}
|
||||
</Command.List>
|
||||
</Command.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
{t.timezone}
|
||||
<ChevronsUpDownIcon class="size-4 opacity-50" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</Popover.Trigger>
|
||||
<Popover.Content class="w-80 p-0">
|
||||
<Command.Root>
|
||||
<Command.Input placeholder={m.system_time_search_timezone_placeholder()} />
|
||||
<Command.List class="max-h-72">
|
||||
<Command.Empty>{m.system_time_no_timezone_found()}</Command.Empty>
|
||||
{#each zones as z (z)}
|
||||
<Command.Item
|
||||
value={z}
|
||||
onSelect={async () => {
|
||||
tzOpen = false;
|
||||
if (z === t.timezone) return;
|
||||
await withBusy(() => setTimezone({ machineId, timezone: z }));
|
||||
}}
|
||||
>
|
||||
<CheckIcon
|
||||
class={'mr-2 size-4 ' + (z === t.timezone ? 'opacity-100' : 'opacity-0')}
|
||||
/>
|
||||
{z}
|
||||
</Command.Item>
|
||||
{/each}
|
||||
</Command.List>
|
||||
</Command.Root>
|
||||
</Popover.Content>
|
||||
</Popover.Root>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -144,26 +150,35 @@
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form
|
||||
class="flex flex-col gap-2 sm:flex-row sm:items-end"
|
||||
onsubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.currentTarget);
|
||||
const v = String(fd.get('time') ?? '').trim();
|
||||
if (!v) return;
|
||||
await withSaving(() => setTime({ machineId, time: rfc3339FromLocal(v) }));
|
||||
}}
|
||||
oninput={() => setTime.validate()}
|
||||
{...setTime.preflight(setTimeSchema).enhance(async ({ submit }) => {
|
||||
try {
|
||||
await submit();
|
||||
toast.success(m.saved());
|
||||
} catch (err) {
|
||||
toast.error(extractErrorMessage(err) ?? m.errors_generic());
|
||||
}
|
||||
})}
|
||||
>
|
||||
<div class="flex grow flex-col gap-1.5">
|
||||
<Label for={formId + 'time'}>{m.system_time_current()}</Label>
|
||||
<Input
|
||||
id={formId + 'time'}
|
||||
name="time"
|
||||
type="datetime-local"
|
||||
step="1"
|
||||
disabled={t.ntp}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={t.ntp || saving || !canWrite}>{m.save()}</Button>
|
||||
<input {...setTime.fields.machineId.as('hidden', machineId)} />
|
||||
<Field.Group class="sm:flex-row sm:items-end">
|
||||
<Field.Field class="grow">
|
||||
<Field.Label for="{id}-time">{m.system_time_current()}</Field.Label>
|
||||
<Input
|
||||
id="{id}-time"
|
||||
type="datetime-local"
|
||||
step="1"
|
||||
disabled={t.ntp}
|
||||
{...setTime.fields.time.as('text', '')}
|
||||
/>
|
||||
{#each setTime.fields.time.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Button type="submit" disabled={t.ntp || !!setTime.pending || !canWrite}>
|
||||
{m.save()}
|
||||
</Button>
|
||||
</Field.Group>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -3,25 +3,23 @@
|
||||
import PageMeta from '$lib/components/seo/page-meta.svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Field from '$lib/components/ui/field';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { setHostname, systemHostname } from '$lib/remotes/system.remote';
|
||||
import { getWhoami } from '$lib/remotes/system.remote';
|
||||
import {
|
||||
getWhoami,
|
||||
setHostname,
|
||||
systemHostname
|
||||
} from '$lib/remotes/system.remote';
|
||||
import { hostnameSchema } from '$lib/schemas/hostname';
|
||||
import { extractErrorMessage, hasPermission } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const machineId = $derived(page.params.machineId!);
|
||||
|
||||
const host = $derived(systemHostname(machineId));
|
||||
const formId = $props.id();
|
||||
let saving = $state(false);
|
||||
const id = $props.id();
|
||||
const whoami = $derived(getWhoami(machineId));
|
||||
const canWrite = $derived(hasPermission('system', 'write', whoami.current?.permissions));
|
||||
|
||||
// ponytail: hostname syntax is RFC1123 — letters, digits, hyphen, max 63 chars per label.
|
||||
const HOSTNAME_RE =
|
||||
/^(?=.{1,253}$)([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/;
|
||||
</script>
|
||||
|
||||
<PageMeta title={m.seo_title_system_hostname()} description={m.seo_desc_system_hostname()} />
|
||||
@@ -43,40 +41,34 @@
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form
|
||||
class="flex flex-col gap-2 sm:flex-row sm:items-end"
|
||||
onsubmit={async (e) => {
|
||||
e.preventDefault();
|
||||
const fd = new FormData(e.currentTarget);
|
||||
const v = String(fd.get('hostname') ?? '').trim();
|
||||
if (!HOSTNAME_RE.test(v)) {
|
||||
toast.error(m.system_hostname_invalid());
|
||||
return;
|
||||
}
|
||||
if (v === h.hostname) return;
|
||||
saving = true;
|
||||
oninput={() => setHostname.validate()}
|
||||
{...setHostname.preflight(hostnameSchema).enhance(async ({ submit }) => {
|
||||
try {
|
||||
await setHostname({ hostname: v, machineId });
|
||||
await submit();
|
||||
toast.success(m.saved());
|
||||
} catch (err) {
|
||||
console.log(err);
|
||||
toast.error(extractErrorMessage(err) ?? m.errors_generic());
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}}
|
||||
})}
|
||||
>
|
||||
<div class="flex grow flex-col gap-1.5">
|
||||
<Label for={formId + 'hn'}>{m.nav_system_hostname()}</Label>
|
||||
<Input
|
||||
id={formId + 'hn'}
|
||||
name="hostname"
|
||||
value={h.hostname}
|
||||
required
|
||||
pattern="[A-Za-z0-9.\-]+"
|
||||
maxlength={253}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" disabled={saving || !canWrite}>{m.save()}</Button>
|
||||
<input {...setHostname.fields.machineId.as('hidden', machineId)} />
|
||||
<Field.Group class="sm:flex-row sm:items-end">
|
||||
<Field.Field class="grow">
|
||||
<Field.Label for="{id}-hn">{m.nav_system_hostname()}</Field.Label>
|
||||
<Input
|
||||
id="{id}-hn"
|
||||
required
|
||||
maxlength={253}
|
||||
{...setHostname.fields.hostname.as('text', h.hostname)}
|
||||
/>
|
||||
{#each setHostname.fields.hostname.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Button type="submit" disabled={!!setHostname.pending || !canWrite}>
|
||||
{m.save()}
|
||||
</Button>
|
||||
</Field.Group>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Field from '$lib/components/ui/field';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import {
|
||||
@@ -24,6 +24,7 @@
|
||||
listPamUsers,
|
||||
setPamUserPassword
|
||||
} from '$lib/remotes/pam-users.remote';
|
||||
import { createPamUserSchema, setPamUserPasswordSchema } from '$lib/schemas/pam';
|
||||
import { getWhoami } from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage, hasPermission } from '$lib/utils';
|
||||
import { PersistedState } from 'runed';
|
||||
@@ -117,16 +118,8 @@
|
||||
});
|
||||
|
||||
let createOpen = $state(false);
|
||||
let createForm = $state({
|
||||
comment: '',
|
||||
create_home: true,
|
||||
shell: '/bin/bash',
|
||||
system: false,
|
||||
username: ''
|
||||
});
|
||||
let pwOpen = $state(false);
|
||||
let pwUser = $state<null | PamUser>(null);
|
||||
let pwValue = $state('');
|
||||
let deleteOpen = $state(false);
|
||||
let deleting = $state<null | PamUser>(null);
|
||||
let removeHome = $state(false);
|
||||
@@ -136,30 +129,6 @@
|
||||
toast.error(extractErrorMessage(e) ?? m.errors_generic());
|
||||
}
|
||||
|
||||
async function doCreate() {
|
||||
try {
|
||||
await createPamUser({
|
||||
comment: createForm.comment || undefined,
|
||||
create_home: createForm.create_home,
|
||||
machineId,
|
||||
shell: createForm.shell || undefined,
|
||||
system: createForm.system,
|
||||
username: createForm.username.trim()
|
||||
});
|
||||
toast.success(m.users_created());
|
||||
createOpen = false;
|
||||
createForm = {
|
||||
comment: '',
|
||||
create_home: true,
|
||||
shell: '/bin/bash',
|
||||
system: false,
|
||||
username: ''
|
||||
};
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!deleting) return;
|
||||
try {
|
||||
@@ -177,22 +146,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function doSetPassword() {
|
||||
if (!pwUser || !pwValue) return;
|
||||
try {
|
||||
await setPamUserPassword({
|
||||
machineId,
|
||||
password: pwValue,
|
||||
username: pwUser.username
|
||||
});
|
||||
toast.success(m.saved());
|
||||
pwOpen = false;
|
||||
pwUser = null;
|
||||
pwValue = '';
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<PageMeta title={m.seo_title_users()} description={m.seo_desc_users()} />
|
||||
@@ -312,7 +265,6 @@
|
||||
disabled={!canRoot}
|
||||
onclick={() => {
|
||||
pwUser = u;
|
||||
pwValue = '';
|
||||
pwOpen = true;
|
||||
}}>{m.users_action_set_password()}</DropdownMenu.Item
|
||||
>
|
||||
@@ -339,46 +291,58 @@
|
||||
<Dialog.Description>{m.users_pam_create_description()}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
doCreate();
|
||||
}}
|
||||
class="flex flex-col gap-3"
|
||||
oninput={() => createPamUser.validate()}
|
||||
{...createPamUser.preflight(createPamUserSchema).enhance(async ({ submit }) => {
|
||||
try {
|
||||
await submit();
|
||||
toast.success(m.users_created());
|
||||
createOpen = false;
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="cu-username-{id}">{m.username()}</Label>
|
||||
<Input
|
||||
id="cu-username-{id}"
|
||||
bind:value={createForm.username}
|
||||
required
|
||||
pattern="[a-z_][a-z0-9_-]*"
|
||||
maxlength={32}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="cu-comment-{id}">{m.users_create_field_comment()}</Label>
|
||||
<Input id="cu-comment-{id}" bind:value={createForm.comment} />
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="cu-shell-{id}">{m.users_create_field_shell()}</Label>
|
||||
<Input id="cu-shell-{id}" bind:value={createForm.shell} />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Checkbox
|
||||
checked={createForm.create_home}
|
||||
onCheckedChange={(v) => (createForm.create_home = !!v)}
|
||||
/>
|
||||
{m.users_create_field_create_home()}
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={createForm.system} onCheckedChange={(v) => (createForm.system = !!v)} />
|
||||
{m.users_create_field_system()}
|
||||
</label>
|
||||
<Dialog.Footer class="mt-2">
|
||||
<Button type="button" variant="outline" onclick={() => (createOpen = false)}
|
||||
>{m.cancel()}</Button
|
||||
>
|
||||
<Button type="submit">{m.users_create()}</Button>
|
||||
<input {...createPamUser.fields.machineId.as('hidden', machineId)} />
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label for="cu-username-{id}">{m.username()}</Field.Label>
|
||||
<Input
|
||||
id="cu-username-{id}"
|
||||
required
|
||||
pattern="[a-z_][a-z0-9_\-]*"
|
||||
maxlength={32}
|
||||
{...createPamUser.fields.username.as('text', '')}
|
||||
/>
|
||||
{#each createPamUser.fields.username.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="cu-comment-{id}">{m.users_create_field_comment()}</Field.Label>
|
||||
<Input id="cu-comment-{id}" {...createPamUser.fields.comment.as('text', '')} />
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="cu-shell-{id}">{m.users_create_field_shell()}</Field.Label>
|
||||
<Input id="cu-shell-{id}" {...createPamUser.fields.shell.as('text', '/bin/bash')} />
|
||||
</Field.Field>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Checkbox id="cu-create-home-{id}" name="create_home" value="yes" checked />
|
||||
<Field.Label for="cu-create-home-{id}" class="font-normal">
|
||||
{m.users_create_field_create_home()}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Checkbox id="cu-system-{id}" name="system" value="yes" />
|
||||
<Field.Label for="cu-system-{id}" class="font-normal">
|
||||
{m.users_create_field_system()}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
<Dialog.Footer class="mt-4">
|
||||
<Button type="button" variant="outline" onclick={() => (createOpen = false)}>
|
||||
{m.cancel()}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!!createPamUser.pending}>{m.users_create()}</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
@@ -392,21 +356,39 @@
|
||||
<Dialog.Description>{m.users_set_password_description()}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
doSetPassword();
|
||||
}}
|
||||
class="flex flex-col gap-3"
|
||||
oninput={() => setPamUserPassword.validate()}
|
||||
{...setPamUserPassword.preflight(setPamUserPasswordSchema).enhance(async ({ submit }) => {
|
||||
try {
|
||||
await submit();
|
||||
toast.success(m.saved());
|
||||
pwOpen = false;
|
||||
pwUser = null;
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="pw-{id}">{m.password()}</Label>
|
||||
<Input id="pw-{id}" type="password" bind:value={pwValue} required minlength={1} />
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => (pwOpen = false)}
|
||||
>{m.cancel()}</Button
|
||||
>
|
||||
<Button type="submit" disabled={!pwValue}>{m.save()}</Button>
|
||||
<input {...setPamUserPassword.fields.machineId.as('hidden', machineId)} />
|
||||
<input {...setPamUserPassword.fields.username.as('hidden', pwUser?.username ?? '')} />
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label for="pw-{id}">{m.password()}</Field.Label>
|
||||
<Input
|
||||
id="pw-{id}"
|
||||
required
|
||||
minlength={1}
|
||||
{...setPamUserPassword.fields.password.as('password', '')}
|
||||
/>
|
||||
{#each setPamUserPassword.fields.password.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
<Dialog.Footer class="mt-4">
|
||||
<Button type="button" variant="outline" onclick={() => (pwOpen = false)}>
|
||||
{m.cancel()}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!!setPamUserPassword.pending}>{m.save()}</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as Field from '$lib/components/ui/field';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import {
|
||||
deletePamUser,
|
||||
@@ -21,6 +21,7 @@
|
||||
setPamUserGroups,
|
||||
setPamUserPassword
|
||||
} from '$lib/remotes/pam-users.remote';
|
||||
import { setPamUserPasswordSchema } from '$lib/schemas/pam';
|
||||
import { getWhoami } from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage, hasPermission } from '$lib/utils';
|
||||
import { toast } from 'svelte-sonner';
|
||||
@@ -55,7 +56,6 @@
|
||||
});
|
||||
|
||||
let pwOpen = $state(false);
|
||||
let pwValue = $state('');
|
||||
let deleteOpen = $state(false);
|
||||
let removeHome = $state(false);
|
||||
let saving = $state(false);
|
||||
@@ -86,18 +86,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function doSetPassword() {
|
||||
if (!pwValue) return;
|
||||
try {
|
||||
await setPamUserPassword({ machineId, password: pwValue, username });
|
||||
toast.success(m.saved());
|
||||
pwOpen = false;
|
||||
pwValue = '';
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
try {
|
||||
await deletePamUser({ machineId, remove_home: removeHome, username });
|
||||
@@ -236,21 +224,38 @@
|
||||
<Dialog.Description>{m.users_set_password_description()}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
doSetPassword();
|
||||
}}
|
||||
class="flex flex-col gap-3"
|
||||
oninput={() => setPamUserPassword.validate()}
|
||||
{...setPamUserPassword.preflight(setPamUserPasswordSchema).enhance(async ({ submit }) => {
|
||||
try {
|
||||
await submit();
|
||||
toast.success(m.saved());
|
||||
pwOpen = false;
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="pw-{id}">{m.password()}</Label>
|
||||
<Input id="pw-{id}" type="password" bind:value={pwValue} required minlength={1} />
|
||||
</div>
|
||||
<Dialog.Footer>
|
||||
<Button type="button" variant="outline" onclick={() => (pwOpen = false)}
|
||||
>{m.cancel()}</Button
|
||||
>
|
||||
<Button type="submit" disabled={!pwValue}>{m.save()}</Button>
|
||||
<input {...setPamUserPassword.fields.machineId.as('hidden', machineId)} />
|
||||
<input {...setPamUserPassword.fields.username.as('hidden', username)} />
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label for="pw-{id}">{m.password()}</Field.Label>
|
||||
<Input
|
||||
id="pw-{id}"
|
||||
required
|
||||
minlength={1}
|
||||
{...setPamUserPassword.fields.password.as('password', '')}
|
||||
/>
|
||||
{#each setPamUserPassword.fields.password.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
<Dialog.Footer class="mt-4">
|
||||
<Button type="button" variant="outline" onclick={() => (pwOpen = false)}>
|
||||
{m.cancel()}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!!setPamUserPassword.pending}>{m.save()}</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
|
||||
@@ -14,11 +14,12 @@
|
||||
import { Checkbox } from '$lib/components/ui/checkbox';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
|
||||
import * as Field from '$lib/components/ui/field';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { createPamGroup, deletePamGroup, listPamGroups } from '$lib/remotes/pam-users.remote';
|
||||
import { createPamGroupSchema } from '$lib/schemas/pam';
|
||||
import { getWhoami } from '$lib/remotes/system.remote';
|
||||
import { extractErrorMessage, hasPermission } from '$lib/utils';
|
||||
import { PersistedState } from 'runed';
|
||||
@@ -100,7 +101,6 @@
|
||||
});
|
||||
|
||||
let createOpen = $state(false);
|
||||
let createForm = $state({ gid: '', name: '', system: false });
|
||||
let deleteOpen = $state(false);
|
||||
let deleting = $state<null | PamGroup>(null);
|
||||
|
||||
@@ -109,22 +109,6 @@
|
||||
toast.error(extractErrorMessage(e) ?? m.errors_generic());
|
||||
}
|
||||
|
||||
async function doCreate() {
|
||||
try {
|
||||
await createPamGroup({
|
||||
gid: createForm.gid ? Number(createForm.gid) : undefined,
|
||||
machineId,
|
||||
name: createForm.name.trim(),
|
||||
system: createForm.system
|
||||
});
|
||||
toast.success(m.groups_created());
|
||||
createOpen = false;
|
||||
createForm = { gid: '', name: '', system: false };
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete() {
|
||||
if (!deleting) return;
|
||||
try {
|
||||
@@ -268,35 +252,53 @@
|
||||
<Dialog.Description>{m.groups_create_description()}</Dialog.Description>
|
||||
</Dialog.Header>
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
doCreate();
|
||||
}}
|
||||
class="flex flex-col gap-3"
|
||||
oninput={() => createPamGroup.validate()}
|
||||
{...createPamGroup.preflight(createPamGroupSchema).enhance(async ({ submit }) => {
|
||||
try {
|
||||
await submit();
|
||||
toast.success(m.groups_created());
|
||||
createOpen = false;
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
})}
|
||||
>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="cg-name-{id}">{m.name()}</Label>
|
||||
<Input
|
||||
id="cg-name-{id}"
|
||||
bind:value={createForm.name}
|
||||
required
|
||||
pattern="[a-z_][a-z0-9_-]*"
|
||||
maxlength={32}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<Label for="cg-gid-{id}">{m.groups_gid_optional()}</Label>
|
||||
<Input id="cg-gid-{id}" type="number" min="0" bind:value={createForm.gid} />
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<Checkbox checked={createForm.system} onCheckedChange={(v) => (createForm.system = !!v)} />
|
||||
{m.groups_create_field_system()}
|
||||
</label>
|
||||
<Dialog.Footer class="mt-2">
|
||||
<Button type="button" variant="outline" onclick={() => (createOpen = false)}
|
||||
>{m.cancel()}</Button
|
||||
>
|
||||
<Button type="submit">{m.users_create()}</Button>
|
||||
<input {...createPamGroup.fields.machineId.as('hidden', machineId)} />
|
||||
<Field.Group>
|
||||
<Field.Field>
|
||||
<Field.Label for="cg-name-{id}">{m.name()}</Field.Label>
|
||||
<Input
|
||||
id="cg-name-{id}"
|
||||
required
|
||||
pattern="[a-z_][a-z0-9_\-]*"
|
||||
maxlength={32}
|
||||
{...createPamGroup.fields.name.as('text', '')}
|
||||
/>
|
||||
{#each createPamGroup.fields.name.issues() as issue, i (`${issue}-${i}`)}
|
||||
<Field.Error>{issue.message}</Field.Error>
|
||||
{/each}
|
||||
</Field.Field>
|
||||
<Field.Field>
|
||||
<Field.Label for="cg-gid-{id}">{m.groups_gid_optional()}</Field.Label>
|
||||
<Input
|
||||
id="cg-gid-{id}"
|
||||
type="number"
|
||||
min="0"
|
||||
{...createPamGroup.fields.gid.as('text', '')}
|
||||
/>
|
||||
</Field.Field>
|
||||
<Field.Field orientation="horizontal">
|
||||
<Checkbox id="cg-system-{id}" name="system" value="yes" />
|
||||
<Field.Label for="cg-system-{id}" class="font-normal">
|
||||
{m.groups_create_field_system()}
|
||||
</Field.Label>
|
||||
</Field.Field>
|
||||
</Field.Group>
|
||||
<Dialog.Footer class="mt-4">
|
||||
<Button type="button" variant="outline" onclick={() => (createOpen = false)}>
|
||||
{m.cancel()}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!!createPamGroup.pending}>{m.users_create()}</Button>
|
||||
</Dialog.Footer>
|
||||
</form>
|
||||
</Dialog.Content>
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Dialog from '$lib/components/ui/dialog';
|
||||
import { Input } from '$lib/components/ui/input';
|
||||
import { Label } from '$lib/components/ui/label';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import {
|
||||
deletePamGroup,
|
||||
@@ -151,9 +150,9 @@
|
||||
</Card.Header>
|
||||
<Card.Content class="flex flex-col gap-4">
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs"
|
||||
>{m.groups_supplementary_label({ count: members.length })}</Label
|
||||
>
|
||||
<p class="text-muted-foreground mb-1.5 block text-xs font-medium">
|
||||
{m.groups_supplementary_label({ count: members.length })}
|
||||
</p>
|
||||
{#if members.length}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each members as name (name)}
|
||||
@@ -183,9 +182,9 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label class="mb-1.5 block text-xs"
|
||||
>{m.groups_primary_label({ count: primaryMembers.length })}</Label
|
||||
>
|
||||
<p class="text-muted-foreground mb-1.5 block text-xs font-medium">
|
||||
{m.groups_primary_label({ count: primaryMembers.length })}
|
||||
</p>
|
||||
{#if primaryMembers.length}
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
{#each primaryMembers as name (name)}
|
||||
|
||||
Reference in New Issue
Block a user