diff --git a/.gitignore b/.gitignore
index 34bf0f1..7b8c9c8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -26,3 +26,4 @@ src/lib/paraglide
project.inlang/cache/
# SQLite
*.db
+CONTEXT.md
\ No newline at end of file
diff --git a/README.md b/README.md
index ef4e955..221dbab 100644
--- a/README.md
+++ b/README.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.
---
diff --git a/CLAUDE.md b/context.md
similarity index 100%
rename from CLAUDE.md
rename to context.md
diff --git a/db.sqlite b/db.sqlite
index 8b162dc..3916a45 100644
Binary files a/db.sqlite and b/db.sqlite differ
diff --git a/drizzle/20260623171651_glossy_star_brand/migration.sql b/drizzle/20260623171651_glossy_star_brand/migration.sql
deleted file mode 100644
index 1a42f04..0000000
--- a/drizzle/20260623171651_glossy_star_brand/migration.sql
+++ /dev/null
@@ -1 +0,0 @@
-ALTER TABLE `machines` ADD `ca_cert` text;
\ No newline at end of file
diff --git a/drizzle/20260625212440_modern_spiral/migration.sql b/drizzle/20260625212440_modern_spiral/migration.sql
new file mode 100644
index 0000000..49f8c93
--- /dev/null
+++ b/drizzle/20260625212440_modern_spiral/migration.sql
@@ -0,0 +1,5 @@
+CREATE TABLE `settings` (
+ `key` text PRIMARY KEY,
+ `updated_at` integer NOT NULL,
+ `value` text NOT NULL
+);
diff --git a/drizzle/20260623171651_glossy_star_brand/snapshot.json b/drizzle/20260625212440_modern_spiral/snapshot.json
similarity index 95%
rename from drizzle/20260623171651_glossy_star_brand/snapshot.json
rename to drizzle/20260625212440_modern_spiral/snapshot.json
index 7fd3247..4dd1305 100644
--- a/drizzle/20260623171651_glossy_star_brand/snapshot.json
+++ b/drizzle/20260625212440_modern_spiral/snapshot.json
@@ -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"
diff --git a/messages/en.json b/messages/en.json
index 1370712..d969c27 100644
--- a/messages/en.json
+++ b/messages/en.json
@@ -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",
diff --git a/src/hooks.server.ts b/src/hooks.server.ts
index 4941b96..7b3dfa5 100644
--- a/src/hooks.server.ts
+++ b/src/hooks.server.ts
@@ -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(',')
diff --git a/src/lib/auth/server.ts b/src/lib/auth/server.ts
index 2501a87..26cb94a 100644
--- a/src/lib/auth/server.ts
+++ b/src/lib/auth/server.ts
@@ -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 | null = null;
+
+export function getAuth(): ReturnType {
+ return (cached ??= build());
+}
+
+export function invalidateAuth(): void {
+ cached = null;
+}
+
+export type Auth = ReturnType;
diff --git a/src/lib/components/blocks/terminal-dialog.svelte b/src/lib/components/blocks/terminal-dialog.svelte
index 61f5c94..07b9a2a 100644
--- a/src/lib/components/blocks/terminal-dialog.svelte
+++ b/src/lib/components/blocks/terminal-dialog.svelte
@@ -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()}
@@ -394,39 +394,45 @@
>
-
+
-
+
{m.terminal_remember_credential()}
-
-
+
+
diff --git a/src/lib/components/dashboard/data-table.svelte b/src/lib/components/dashboard/data-table.svelte
index f20086c..c046908 100644
--- a/src/lib/components/dashboard/data-table.svelte
+++ b/src/lib/components/dashboard/data-table.svelte
@@ -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 @@
{/if}
-
{i18n.display}
+
{i18n.display}
{i18n.rowsPerPage}
[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
)}
diff --git a/src/lib/const/schema.ts b/src/lib/const/schema.ts
index 9726a17..0574de5 100644
--- a/src/lib/const/schema.ts
+++ b/src/lib/const/schema.ts
@@ -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())),
diff --git a/src/lib/remotes/auth.remote.ts b/src/lib/remotes/auth.remote.ts
index f8929d2..c7ec088 100644
--- a/src/lib/remotes/auth.remote.ts
+++ b/src/lib/remotes/auth.remote.ts
@@ -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>;
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() });
diff --git a/src/lib/remotes/config.remote.ts b/src/lib/remotes/config.remote.ts
new file mode 100644
index 0000000..5383c6a
--- /dev/null
+++ b/src/lib/remotes/config.remote.ts
@@ -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> = { ...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 });
+ }
+});
+
diff --git a/src/lib/remotes/networking.remote.ts b/src/lib/remotes/networking.remote.ts
index 3ab889d..10e3ba6 100644
--- a/src/lib/remotes/networking.remote.ts
+++ b/src/lib/remotes/networking.remote.ts
@@ -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() }),
diff --git a/src/lib/remotes/pam-users.remote.ts b/src/lib/remotes/pam-users.remote.ts
index 4145d92..83a2c0f 100644
--- a/src/lib/remotes/pam-users.remote.ts
+++ b/src/lib/remotes/pam-users.remote.ts
@@ -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() }),
diff --git a/src/lib/remotes/storage.remote.ts b/src/lib/remotes/storage.remote.ts
index 21c2dc7..1a3ab5c 100644
--- a/src/lib/remotes/storage.remote.ts
+++ b/src/lib/remotes/storage.remote.ts
@@ -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() }),
diff --git a/src/lib/remotes/system.remote.ts b/src/lib/remotes/system.remote.ts
index 70c037d..08d374d 100644
--- a/src/lib/remotes/system.remote.ts
+++ b/src/lib/remotes/system.remote.ts
@@ -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);
diff --git a/src/lib/remotes/users.remote.ts b/src/lib/remotes/users.remote.ts
index 56e03c7..ab3112c 100644
--- a/src/lib/remotes/users.remote.ts
+++ b/src/lib/remotes/users.remote.ts
@@ -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() });
diff --git a/src/lib/remotes/utils.ts b/src/lib/remotes/utils.ts
index e1d2bf1..ecab1c2 100644
--- a/src/lib/remotes/utils.ts
+++ b/src/lib/remotes/utils.ts
@@ -120,3 +120,4 @@ export function throwNadirError(
}
throw error(status, { message });
}
+
diff --git a/src/lib/schemas/config.ts b/src/lib/schemas/config.ts
new file mode 100644
index 0000000..46ca8dc
--- /dev/null
+++ b/src/lib/schemas/config.ts
@@ -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
+});
diff --git a/src/lib/schemas/hostname.ts b/src/lib/schemas/hostname.ts
new file mode 100644
index 0000000..e1b2f96
--- /dev/null
+++ b/src/lib/schemas/hostname.ts
@@ -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())
+});
\ No newline at end of file
diff --git a/src/lib/schemas/networking-host.ts b/src/lib/schemas/networking-host.ts
new file mode 100644
index 0000000..6df2e02
--- /dev/null
+++ b/src/lib/schemas/networking-host.ts
@@ -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())
+});
diff --git a/src/lib/schemas/pam.ts b/src/lib/schemas/pam.ts
new file mode 100644
index 0000000..ebf61f5
--- /dev/null
+++ b/src/lib/schemas/pam.ts
@@ -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
+});
diff --git a/src/lib/schemas/storage-mount.ts b/src/lib/schemas/storage-mount.ts
new file mode 100644
index 0000000..257f77c
--- /dev/null
+++ b/src/lib/schemas/storage-mount.ts
@@ -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(), '')
+});
diff --git a/src/lib/schemas/system-time.ts b/src/lib/schemas/system-time.ts
new file mode 100644
index 0000000..86bbb8e
--- /dev/null
+++ b/src/lib/schemas/system-time.ts
@@ -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()))
+});
diff --git a/src/lib/server/config.ts b/src/lib/server/config.ts
new file mode 100644
index 0000000..4a88a1e
--- /dev/null
+++ b/src/lib/server/config.ts
@@ -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([
+ 'FACEBOOK_CLIENT_SECRET',
+ 'GITHUB_CLIENT_SECRET',
+ 'GOOGLE_CLIENT_SECRET',
+ 'SMTP_PASS'
+]);
+const BOOLEAN_KEYS = new Set([
+ 'DISABLE_SIGNUP',
+ 'ENABLE_2FA',
+ 'ENABLE_EMAIL_AND_PASSWORD',
+ 'SMTP_SSL'
+]);
+const NUMBER_KEYS = new Set(['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 {
+ const rows = db.select().from(settings).all();
+ const out: Partial> = {};
+ 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;
+}
+
+export function refreshConfig(): Effective {
+ cache = build();
+ return cache;
+}
+
+export function setConfigValues(updates: Partial>): 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 = {};
+ 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;
+}
diff --git a/src/lib/server/db/schema.ts b/src/lib/server/db/schema.ts
index 7352174..873ec08 100644
--- a/src/lib/server/db/schema.ts
+++ b/src/lib/server/db/schema.ts
@@ -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'),
diff --git a/src/lib/server/emails/send.ts b/src/lib/server/emails/send.ts
index ad23ffc..4259a4c 100644
--- a/src/lib/server/emails/send.ts
+++ b/src/lib/server/emails/send.ts
@@ -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
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
index d58eb43..3b9d3dc 100644
--- a/src/lib/utils.ts
+++ b/src/lib/utils.ts
@@ -31,3 +31,4 @@ export function hasPermission(
if (!modPerms) return false;
return modPerms.includes('*') || modPerms.includes(perm);
}
+
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index 070122a..4869219 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -47,7 +47,7 @@
{/if}
-
+
{@render children()}
diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte
index c597530..af59b86 100644
--- a/src/routes/+page.svelte
+++ b/src/routes/+page.svelte
@@ -79,15 +79,19 @@
{m.appname()}
-
+
@@ -118,7 +122,7 @@
rel="noreferrer"
class={buttonVariants({ size: 'lg', variant: 'outline' })}
>
- {m.landing_hero_cta_github()}
+ {m.landing_footer_gitea()}
@@ -141,7 +145,7 @@
>
- Security note:
- {m.landing_security_note().replace('Security note:', '').trim()}
+ {m.landing_security_note()}
+ {m.landing_security_note_text()}
diff --git a/src/routes/admin/config/+page.svelte b/src/routes/admin/config/+page.svelte
index 027a61b..010e06b 100644
--- a/src/routes/admin/config/+page.svelte
+++ b/src/routes/admin/config/+page.svelte
@@ -1,6 +1,309 @@
+
+
+
+
+
{m.nav_admin_config()}
+
{m.admin_config_description()}
+
+
cfg.refresh()}
+ >
+
+
+
+
+
+
diff --git a/src/routes/admin/users/+page.svelte b/src/routes/admin/users/+page.svelte
index 7aa8cfe..88aa133 100644
--- a/src/routes/admin/users/+page.svelte
+++ b/src/routes/admin/users/+page.svelte
@@ -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 @@
{m.users_filter_title()}
-
- {m.users_filter_active()}
+
+ {m.users_filter_active()}
+
+
-
{m.users_filter_any_time()}
{m.users_filter_24h()}
{m.users_filter_7d()}
{m.users_filter_30d()}
-
-
- {m.users_filter_joined()}
-
+
+
+
+ {m.users_filter_joined()}
+
+
{m.users_filter_any_time()}
{m.users_filter_24h()}
{m.users_filter_7d()}
{m.users_filter_30d()}
-
+
-
{m.users_filter_date_range()}
+
+ {m.users_filter_date_range()}
+
-
{m.users_filter_display()}
+
+ {m.users_filter_display()}
+
{m.users_rows_per_page()}
(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 @@
{m.networking_host_add_description()}
diff --git a/src/routes/dashboard/[machineId]/networking/interfaces/[name]/configure/+page.svelte b/src/routes/dashboard/[machineId]/networking/interfaces/[name]/configure/+page.svelte
index ffca97c..ec009c3 100644
--- a/src/routes/dashboard/[machineId]/networking/interfaces/[name]/configure/+page.svelte
+++ b/src/routes/dashboard/[machineId]/networking/interfaces/[name]/configure/+page.svelte
@@ -1,5 +1,4 @@
@@ -67,73 +63,83 @@
{m.system_time_current()}
-
-
- {m.system_time_timezone()}
- {t.timezone}
-
-
- {m.system_time_clock()}
- {t.time}
-
-
- NTP
- withSaving(() => setNtp({ enabled: v, machineId }))}
- />
-
-
- {t.ntp_synchronized ? m.system_time_ntp_synced() : m.system_time_ntp_not_synced()}
-
-
-
-
-
- {#snippet child({ props })}
-
+
+
+
+
+ {m.system_time_timezone()}
+
+ {t.timezone}
+
+
+
+ {m.system_time_clock()}
- {t.timezone}
-
-
- {/snippet}
-
-
-
-
-
- {m.system_time_no_timezone_found()}
- {#each zones as z (z)}
- {
- tzOpen = false;
- if (z === t.timezone) return;
- await withSaving(() => setTimezone({ machineId, timezone: z }));
- }}
+ {t.time}
+
+
+
+
+ NTP
+
+ {t.ntp_synchronized
+ ? m.system_time_ntp_synced()
+ : m.system_time_ntp_not_synced()}
+
+
+ withBusy(() => setNtp({ enabled: v, machineId }))}
+ />
+
+
+ {m.system_time_timezone()}
+
+
+ {#snippet child({ props })}
+
-
- {z}
-
- {/each}
-
-
-
-
+ {t.timezone}
+
+
+ {/snippet}
+
+
+
+
+
+ {m.system_time_no_timezone_found()}
+ {#each zones as z (z)}
+ {
+ tzOpen = false;
+ if (z === t.timezone) return;
+ await withBusy(() => setTimezone({ machineId, timezone: z }));
+ }}
+ >
+
+ {z}
+
+ {/each}
+
+
+
+
+
+
@@ -144,26 +150,35 @@
diff --git a/src/routes/dashboard/[machineId]/system/hostname/+page.svelte b/src/routes/dashboard/[machineId]/system/hostname/+page.svelte
index 1e94dee..968bd6f 100644
--- a/src/routes/dashboard/[machineId]/system/hostname/+page.svelte
+++ b/src/routes/dashboard/[machineId]/system/hostname/+page.svelte
@@ -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])?)*$/;
@@ -43,40 +41,34 @@
diff --git a/src/routes/dashboard/[machineId]/users/+page.svelte b/src/routes/dashboard/[machineId]/users/+page.svelte
index c60d6dd..af9e863 100644
--- a/src/routes/dashboard/[machineId]/users/+page.svelte
+++ b/src/routes/dashboard/[machineId]/users/+page.svelte
@@ -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);
- let pwValue = $state('');
let deleteOpen = $state(false);
let deleting = $state(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);
- }
- }
@@ -312,7 +265,6 @@
disabled={!canRoot}
onclick={() => {
pwUser = u;
- pwValue = '';
pwOpen = true;
}}>{m.users_action_set_password()}
@@ -339,46 +291,58 @@
{m.users_pam_create_description()}
@@ -392,21 +356,39 @@
{m.users_set_password_description()}
diff --git a/src/routes/dashboard/[machineId]/users/[username]/+page.svelte b/src/routes/dashboard/[machineId]/users/[username]/+page.svelte
index b71d108..b1ad9a6 100644
--- a/src/routes/dashboard/[machineId]/users/[username]/+page.svelte
+++ b/src/routes/dashboard/[machineId]/users/[username]/+page.svelte
@@ -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 @@
{m.users_set_password_description()}
diff --git a/src/routes/dashboard/[machineId]/users/groups/+page.svelte b/src/routes/dashboard/[machineId]/users/groups/+page.svelte
index 701218d..b41437c 100644
--- a/src/routes/dashboard/[machineId]/users/groups/+page.svelte
+++ b/src/routes/dashboard/[machineId]/users/groups/+page.svelte
@@ -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);
@@ -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 @@
{m.groups_create_description()}
diff --git a/src/routes/dashboard/[machineId]/users/groups/[group]/+page.svelte b/src/routes/dashboard/[machineId]/users/groups/[group]/+page.svelte
index 23e31ae..7a6083d 100644
--- a/src/routes/dashboard/[machineId]/users/groups/[group]/+page.svelte
+++ b/src/routes/dashboard/[machineId]/users/groups/[group]/+page.svelte
@@ -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 @@
-
{m.groups_supplementary_label({ count: members.length })}
+
+ {m.groups_supplementary_label({ count: members.length })}
+
{#if members.length}
{#each members as name (name)}
@@ -183,9 +182,9 @@
-
{m.groups_primary_label({ count: primaryMembers.length })}
+
+ {m.groups_primary_label({ count: primaryMembers.length })}
+
{#if primaryMembers.length}
{#each primaryMembers as name (name)}