feat: refined ui

This commit is contained in:
2026-06-26 00:31:29 +02:00
parent 8414c26bd4
commit c86726ec9a
48 changed files with 1720 additions and 872 deletions
+1
View File
@@ -26,3 +26,4 @@ src/lib/paraglide
project.inlang/cache/
# SQLite
*.db
CONTEXT.md
+128 -39
View File
@@ -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.
---
View File
BIN
View File
Binary file not shown.
@@ -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
);
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
)}
+6
View File
@@ -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())),
+14 -12
View File
@@ -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() });
+94
View File
@@ -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 });
}
});
+11 -17
View File
@@ -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() }),
+31 -34
View File
@@ -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() }),
+11 -19
View File
@@ -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() }),
+18 -19
View File
@@ -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);
+10 -10
View File
@@ -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() });
+1
View File
@@ -120,3 +120,4 @@ export function throwNadirError(
}
throw error(status, { message });
}
+39
View File
@@ -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
});
+15
View File
@@ -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())
});
+17
View File
@@ -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())
});
+39
View File
@@ -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
});
+12
View File
@@ -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(), '')
});
+7
View File
@@ -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()))
});
+103
View File
@@ -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;
}
+8
View File
@@ -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'),
+8 -7
View File
@@ -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
+1
View File
@@ -31,3 +31,4 @@ export function hasPermission(
if (!modPerms) return false;
return modPerms.includes('*') || modPerms.includes(perm);
}
+1 -1
View File
@@ -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
View File
@@ -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>
+303
View File
@@ -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>
+27 -12
View File
@@ -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>
@@ -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)}