diff --git a/.claude/launch.json b/.claude/launch.json index 8412f87f..993eaa16 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -1,6 +1,13 @@ { "version": "0.0.1", "configurations": [ + { + "name": "backend", + "runtimeExecutable": "cargo", + "runtimeArgs": ["run", "--locked", "--no-default-features"], + "cwd": "apps/backend", + "port": 3000 + }, { "name": "dashboard", "runtimeExecutable": "bun", diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bee417ea..16698e6d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -55,33 +55,33 @@ jobs: - os: linux arch: amd64 runner: ubuntu-latest - bin: cms - asset: cms-${{ github.ref_name }}-linux-amd64 + bin: vcms + asset: vcms-${{ github.ref_name }}-linux-amd64 - os: linux arch: arm64 runner: ubuntu-24.04-arm - bin: cms - asset: cms-${{ github.ref_name }}-linux-arm64 + bin: vcms + asset: vcms-${{ github.ref_name }}-linux-arm64 - os: macos arch: amd64 runner: macos-26-intel - bin: cms - asset: cms-${{ github.ref_name }}-macos-amd64 + bin: vcms + asset: vcms-${{ github.ref_name }}-macos-amd64 - os: macos arch: arm64 runner: macos-26 - bin: cms - asset: cms-${{ github.ref_name }}-macos-arm64 + bin: vcms + asset: vcms-${{ github.ref_name }}-macos-arm64 - os: windows arch: amd64 runner: windows-latest - bin: cms.exe - asset: cms-${{ github.ref_name }}-windows-amd64.exe + bin: vcms.exe + asset: vcms-${{ github.ref_name }}-windows-amd64.exe - os: windows arch: arm64 runner: windows-11-arm - bin: cms.exe - asset: cms-${{ github.ref_name }}-windows-arm64.exe + bin: vcms.exe + asset: vcms-${{ github.ref_name }}-windows-arm64.exe steps: - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 with: diff --git a/.gitignore b/.gitignore index e09a5b3d..03b48888 100644 --- a/.gitignore +++ b/.gitignore @@ -2,9 +2,9 @@ /target # Database -cms.db -cms.db-shm -cms.db-wal +vcms.db +vcms.db-shm +vcms.db-wal # Env !.env.example diff --git a/AGENTS.md b/AGENTS.md index 38976326..ab222c07 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,18 +63,18 @@ bun run format # Format all projects ## CLI -The backend binary (`cms`) is a clap CLI. With no subcommand it runs the server (back-compat with `cargo run`). +The backend binary (`vcms`) is a clap CLI. With no subcommand it runs the server (back-compat with `cargo run`). ```bash -cms # run the server (alias for `cms serve`) -cms serve # run the server -cms config init [--force] [--path P] # write a default config.toml (non-secrets only) -cms config show # print effective merged config (secrets redacted) -cms config path # print resolved config file + search order -cms admin reset-password --username U --password P -cms backup create [--scope instance|site] [--site ID] [--out FILE] [--no-files] [--encrypt] -cms backup list # list recorded backups -cms restore --file PATH [--scope instance|site] [--site ID] [--import-as-new] --yes +vcms # run the server (alias for `vcms serve`) +vcms serve # run the server +vcms config init [--force] [--path P] # write a default config.toml (non-secrets only) +vcms config show # print effective merged config (secrets redacted) +vcms config path # print resolved config file + search order +vcms admin reset-password --email U --password P +vcms backup create [--scope instance|site] [--site ID] [--out FILE] [--no-files] [--encrypt] +vcms backup list # list recorded backups +vcms restore --file PATH [--scope instance|site] [--site ID] [--import-as-new] --yes ``` `backup`/`restore` run offline (no HTTP server) against the configured database — @@ -91,29 +91,29 @@ Non-secret settings live in a TOML config file; secrets stay in the environment Layers merge with precedence: **CLI flag > env var > config file > built-in default**. Config file search order (first existing wins; missing is fine): -1. `--config` flag / `CMS_CONFIG` env -2. `./cms.toml` (current dir) -3. `~/.cms/config.toml` (CMS home; `$CMS_HOME/config.toml` if set) — where `cms config init` writes -4. `/etc/cms/config.toml` +1. `--config` flag / `VCMS_CONFIG` env +2. `./vcms.toml` (current dir) +3. `~/.vcms/config.toml` (CMS home; `$VCMS_HOME/config.toml` if set) — where `vcms config init` writes +4. `/etc/vcms/config.toml` ## Data directory (CMS home) -All runtime files live under one home directory: `$CMS_HOME` if set, else `~/.cms` -(same layout on Windows, macOS, Linux via the `directories` crate). `cms serve` +All runtime files live under one home directory: `$VCMS_HOME` if set, else `~/.vcms` +(same layout on Windows, macOS, Linux via the `directories` crate). `vcms serve` creates it on first run. Resolution lives in `apps/backend/src/paths.rs`. ```text -~/.cms/ - config.toml # non-secret config (cms config init target) +~/.vcms/ + config.toml # non-secret config (vcms config init target) secrets.toml # auto-generated HMAC_SECRET + backup key (0600 on unix) - cms.db # default SQLite database (+ -wal / -shm) + vcms.db # default SQLite database (+ -wal / -shm) logs/ # rolling logs when [log] output = "file" storage/ # default filesystem storage for uploads ``` Secrets: on first `serve`/`admin`, a random `HMAC_SECRET` is generated and persisted to `secrets.toml` (`apps/backend/src/secrets.rs`), then loaded by -every process — including `cms mcp stdio`, which is launched from an arbitrary cwd +every process — including `vcms mcp stdio`, which is launched from an arbitrary cwd and so cannot rely on a cwd `.env`. Env vars still override the file. `mcp stdio` is read-only: it never creates the home dir, database, or secrets file. @@ -123,7 +123,7 @@ Env-only secrets (never read from `config.toml` by convention, omitted from `BACKUP_ENCRYPTION_KEY`. (`HMAC_SECRET` and a random backup encryption key are auto-persisted to `secrets.toml`; the others remain env-only.) -Sample `config.toml` (generate with `cms config init`): +Sample `config.toml` (generate with `vcms config init`): ```toml bind_address = "0.0.0.0:3000" @@ -137,7 +137,7 @@ mcp_enabled = true mcp_allowed_hosts = ["localhost", "127.0.0.1"] [log] -level = "cms=debug,tower_http=debug,axum=debug" +level = "cms=debug,vcms=debug,tower_http=debug,axum=debug" output = "stdout" # stdout | file format = "pretty" # pretty | json annotations = false @@ -152,10 +152,10 @@ Logging keys map to the `[log]` table: `RUST_LOG`→`log.level`, `LOG_OUTPUT`→ | Variable | Default | Description | |----------|---------|-------------| -| `CMS_CONFIG` | - | Explicit config file path (same as `--config`) | -| `CMS_HOME` | `~/.cms` | CMS home directory (db, config, secrets, logs, storage) | -| `DATABASE_URL` | `sqlite://~/.cms/cms.db` | Database URL: `sqlite:path`, `postgres://...`, `mysql://...` | -| `HMAC_SECRET` | `cms-hmac-secret-change-in-production` | HMAC key for token lookup | +| `VCMS_CONFIG` | - | Explicit config file path (same as `--config`) | +| `VCMS_HOME` | `~/.vcms` | CMS home directory (db, config, secrets, logs, storage) | +| `DATABASE_URL` | `sqlite://~/.vcms/vcms.db` | Database URL: `sqlite:path`, `postgres://...`, `mysql://...` | +| `HMAC_SECRET` | auto | HMAC key for token lookup (required; auto-generated to `secrets.toml`, env overrides) | | `BIND_ADDRESS` | `0.0.0.0:3000` | REST API listen address | | `GRPC_BIND_ADDRESS` | `0.0.0.0:50051` | gRPC server listen address | | `STORAGE_FS_PATH` | - | Filesystem storage path | @@ -167,7 +167,7 @@ Logging keys map to the `[log]` table: `RUST_LOG`→`log.level`, `LOG_OUTPUT`→ | `S3_PUBLIC_URL` | - | Public URL for S3 assets | | `BACKUP_ENABLED` | `true` | Run the scheduled-backup poller / allow backups | | `BACKUP_DESTINATION` | `filesystem` | Backup destination: `filesystem` or `s3` | -| `BACKUP_LOCAL_PATH` | `~/.cms/backups` | Local backup dir (when destination is filesystem) | +| `BACKUP_LOCAL_PATH` | `~/.vcms/backups` | Local backup dir (when destination is filesystem) | | `BACKUP_ZSTD_LEVEL` | `12` | zstd compression level for backups | | `BACKUP_DEFAULT_RETENTION` | `7` | Default "keep last N" for new schedules | | `BACKUP_S3_BUCKET` / `_REGION` / `_ENDPOINT` / `_PUBLIC_URL` | - | S3 backup destination (non-secret parts) | @@ -180,15 +180,15 @@ Logging keys map to the `[log]` table: `RUST_LOG`→`log.level`, `LOG_OUTPUT`→ | `DB_MIN_CONNECTIONS` | `2` | Min DB connections | | `RATE_LIMIT_MAX_REQUESTS` | `100` | Rate limit per window | | `RATE_LIMIT_WINDOW_SECS` | `60` | Rate limit window | -| `CMS_ENV` | - | `production` enables production security checks | -| `RUST_LOG` | `cms=debug,tower_http=debug,axum=debug` | Log filter (`[log] level`) | +| `VCMS_ENV` | - | `production` enables production security checks | +| `RUST_LOG` | `cms=debug,vcms=debug,tower_http=debug,axum=debug` | Log filter (`[log] level`) | | `LOG_OUTPUT` | `stdout` | `stdout` or `file` (`[log] output`) | | `LOG_FORMAT` | `pretty` | `pretty` or `json` (`[log] format`) | | `LOG_ANNOTATIONS` | `false` | Include file + line numbers (`[log] annotations`) | -| `LOG_DIR` | `~/.cms/logs` | Log directory when `output = file` (`[log] dir`) | +| `LOG_DIR` | `~/.vcms/logs` | Log directory when `output = file` (`[log] dir`) | **Note**: `HMAC_SECRET` is auto-generated and persisted to -`~/.cms/secrets.toml` on first run. Set it explicitly via env to override. +`~/.vcms/secrets.toml` on first run. Set it explicitly via env to override. ## Proto Compilation @@ -199,9 +199,11 @@ Logging keys map to the `[log]` table: `RUST_LOG`→`log.level`, `LOG_OUTPUT`→ ## First-Run Behavior On initial startup, the server seeds a default admin user (the first user created -is automatically granted the `instance_owner` role): -- Username: `admin` +is automatically granted the `instance_owner` role). **Login is by email**; the +`name` field is a display name (non-unique, e.g. "John Doe"): +- Email: `admin@cms.local` - Password: `admin` +- Display name: `admin` **Change this password immediately in production.** ## Authorization (RBAC) diff --git a/CLAUDE.md b/CLAUDE.md index 3aefb253..7fa991bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,18 +64,18 @@ bun run format # Format all projects ## CLI -The backend binary (`cms`) is a clap CLI. With no subcommand it runs the server (back-compat with `cargo run`). +The backend binary (`vcms`) is a clap CLI. With no subcommand it runs the server (back-compat with `cargo run`). ```bash -cms # run the server (alias for `cms serve`) -cms serve # run the server -cms config init [--force] [--path P] # write a default config.toml (non-secrets only) -cms config show # print effective merged config (secrets redacted) -cms config path # print resolved config file + search order -cms admin reset-password --username U --password P -cms backup create [--scope instance|site] [--site ID] [--out FILE] [--no-files] [--encrypt] -cms backup list # list recorded backups -cms restore --file PATH [--scope instance|site] [--site ID] [--import-as-new] --yes +vcms # run the server (alias for `vcms serve`) +vcms serve # run the server +vcms config init [--force] [--path P] # write a default config.toml (non-secrets only) +vcms config show # print effective merged config (secrets redacted) +vcms config path # print resolved config file + search order +vcms admin reset-password --email U --password P +vcms backup create [--scope instance|site] [--site ID] [--out FILE] [--no-files] [--encrypt] +vcms backup list # list recorded backups +vcms restore --file PATH [--scope instance|site] [--site ID] [--import-as-new] --yes ``` `backup`/`restore` run offline (no HTTP server) against the configured database — @@ -92,22 +92,22 @@ Non-secret settings live in a TOML config file; secrets stay in the environment Layers merge with precedence: **CLI flag > env var > config file > built-in default**. Config file search order (first existing wins; missing is fine): -1. `--config` flag / `CMS_CONFIG` env -2. `./cms.toml` (current dir) -3. `~/.cms/config.toml` (CMS home; `$CMS_HOME/config.toml` if set) — where `cms config init` writes -4. `/etc/cms/config.toml` +1. `--config` flag / `VCMS_CONFIG` env +2. `./vcms.toml` (current dir) +3. `~/.vcms/config.toml` (CMS home; `$VCMS_HOME/config.toml` if set) — where `vcms config init` writes +4. `/etc/vcms/config.toml` ## Data directory (CMS home) -All runtime files live under one home directory: `$CMS_HOME` if set, else `~/.cms` -(same layout on Windows, macOS, Linux via the `directories` crate). `cms serve` +All runtime files live under one home directory: `$VCMS_HOME` if set, else `~/.vcms` +(same layout on Windows, macOS, Linux via the `directories` crate). `vcms serve` creates it on first run. Resolution lives in `apps/backend/src/paths.rs`. ```text -~/.cms/ - config.toml # non-secret config (cms config init target) +~/.vcms/ + config.toml # non-secret config (vcms config init target) secrets.toml # auto-generated HMAC_SECRET + backup key (0600 on unix) - cms.db # default SQLite database (+ -wal / -shm) + vcms.db # default SQLite database (+ -wal / -shm) logs/ # rolling logs when [log] output = "file" storage/ # default filesystem storage for uploads search/ # Tantivy full-text search index (derived; rebuildable) @@ -115,7 +115,7 @@ creates it on first run. Resolution lives in `apps/backend/src/paths.rs`. Secrets: on first `serve`/`admin`, a random `HMAC_SECRET` is generated and persisted to `secrets.toml` (`apps/backend/src/secrets.rs`), then loaded by -every process — including `cms mcp stdio`, which is launched from an arbitrary cwd +every process — including `vcms mcp stdio`, which is launched from an arbitrary cwd and so cannot rely on a cwd `.env`. Env vars still override the file. `mcp stdio` is read-only: it never creates the home dir, database, or secrets file. @@ -125,7 +125,7 @@ Env-only secrets (never read from `config.toml` by convention, omitted from `BACKUP_ENCRYPTION_KEY`. (`HMAC_SECRET` and a random backup encryption key are auto-persisted to `secrets.toml`; the others remain env-only.) -Sample `config.toml` (generate with `cms config init`): +Sample `config.toml` (generate with `vcms config init`): ```toml bind_address = "0.0.0.0:3000" @@ -140,7 +140,7 @@ mcp_enabled = true mcp_allowed_hosts = ["localhost", "127.0.0.1"] [log] -level = "cms=debug,tower_http=debug,axum=debug" +level = "cms=debug,vcms=debug,tower_http=debug,axum=debug" output = "stdout" # stdout | file format = "pretty" # pretty | json annotations = false @@ -155,10 +155,10 @@ Logging keys map to the `[log]` table: `RUST_LOG`→`log.level`, `LOG_OUTPUT`→ | Variable | Default | Description | |----------|---------|-------------| -| `CMS_CONFIG` | - | Explicit config file path (same as `--config`) | -| `CMS_HOME` | `~/.cms` | CMS home directory (db, config, secrets, logs, storage) | -| `DATABASE_URL` | `sqlite://~/.cms/cms.db` | Database URL: `sqlite:path`, `postgres://...`, `mysql://...` | -| `HMAC_SECRET` | `cms-hmac-secret-change-in-production` | HMAC key for token lookup | +| `VCMS_CONFIG` | - | Explicit config file path (same as `--config`) | +| `VCMS_HOME` | `~/.vcms` | CMS home directory (db, config, secrets, logs, storage) | +| `DATABASE_URL` | `sqlite://~/.vcms/vcms.db` | Database URL: `sqlite:path`, `postgres://...`, `mysql://...` | +| `HMAC_SECRET` | auto | HMAC key for token lookup (required; auto-generated to `secrets.toml`, env overrides) | | `BIND_ADDRESS` | `0.0.0.0:3000` | REST API listen address | | `GRPC_BIND_ADDRESS` | `0.0.0.0:50051` | gRPC server listen address | | `STORAGE_FS_PATH` | - | Filesystem storage path | @@ -170,7 +170,7 @@ Logging keys map to the `[log]` table: `RUST_LOG`→`log.level`, `LOG_OUTPUT`→ | `S3_PUBLIC_URL` | - | Public URL for S3 assets | | `BACKUP_ENABLED` | `true` | Run the scheduled-backup poller / allow backups | | `BACKUP_DESTINATION` | `filesystem` | Backup destination: `filesystem` or `s3` | -| `BACKUP_LOCAL_PATH` | `~/.cms/backups` | Local backup dir (when destination is filesystem) | +| `BACKUP_LOCAL_PATH` | `~/.vcms/backups` | Local backup dir (when destination is filesystem) | | `BACKUP_ZSTD_LEVEL` | `12` | zstd compression level for backups | | `BACKUP_DEFAULT_RETENTION` | `7` | Default "keep last N" for new schedules | | `BACKUP_S3_BUCKET` / `_REGION` / `_ENDPOINT` / `_PUBLIC_URL` | - | S3 backup destination (non-secret parts) | @@ -178,22 +178,22 @@ Logging keys map to the `[log]` table: `RUST_LOG`→`log.level`, `LOG_OUTPUT`→ | `BACKUP_S3_SECRET_ACCESS_KEY` | - | S3 backup secret key (secret, env-only) | | `BACKUP_ENCRYPTION_KEY` | auto | AES-256 backup key (hex); auto-generated to `secrets.toml` | | `SEARCH_ENABLED` | `true` | Build/use the Tantivy full-text index for entry search (else SQL `LIKE`) | -| `SEARCH_INDEX_PATH` | `~/.cms/search` | Directory for the Tantivy search index | +| `SEARCH_INDEX_PATH` | `~/.vcms/search` | Directory for the Tantivy search index | | `MAX_UPLOAD_SIZE_MB` | `50` | Max upload size in MB | | `COOKIE_SECURE` | `false` | Require HTTPS cookies | | `DB_MAX_CONNECTIONS` | `10` | Max DB connections | | `DB_MIN_CONNECTIONS` | `2` | Min DB connections | | `RATE_LIMIT_MAX_REQUESTS` | `100` | Rate limit per window | | `RATE_LIMIT_WINDOW_SECS` | `60` | Rate limit window | -| `CMS_ENV` | - | `production` enables production security checks | -| `RUST_LOG` | `cms=debug,tower_http=debug,axum=debug` | Log filter (`[log] level`) | +| `VCMS_ENV` | - | `production` enables production security checks | +| `RUST_LOG` | `cms=debug,vcms=debug,tower_http=debug,axum=debug` | Log filter (`[log] level`) | | `LOG_OUTPUT` | `stdout` | `stdout` or `file` (`[log] output`) | | `LOG_FORMAT` | `pretty` | `pretty` or `json` (`[log] format`) | | `LOG_ANNOTATIONS` | `false` | Include file + line numbers (`[log] annotations`) | -| `LOG_DIR` | `~/.cms/logs` | Log directory when `output = file` (`[log] dir`) | +| `LOG_DIR` | `~/.vcms/logs` | Log directory when `output = file` (`[log] dir`) | **Note**: `HMAC_SECRET` is auto-generated and persisted to -`~/.cms/secrets.toml` on first run. Set it explicitly via env to override. +`~/.vcms/secrets.toml` on first run. Set it explicitly via env to override. ## Proto Compilation @@ -204,9 +204,11 @@ Logging keys map to the `[log]` table: `RUST_LOG`→`log.level`, `LOG_OUTPUT`→ ## First-Run Behavior On initial startup, the server seeds a default admin user (the first user created -is automatically granted the `instance_owner` role): -- Username: `admin` +is automatically granted the `instance_owner` role). **Login is by email**; the +`name` field is a display name (non-unique, e.g. "John Doe"): +- Email: `admin@cms.local` - Password: `admin` +- Display name: `admin` **Change this password immediately in production.** ## Authorization (RBAC) @@ -296,7 +298,7 @@ is **derived** and fully rebuildable. scalar text of `data`, English-stemmed, BM25-ranked). `fields_from` re-resolves the schema when opening an existing index read-only. - `mod.rs` — `SearchService`. **Reading** needs no lock: `open_read_only` (reader - only) lets *any* process search the index, including a separate `cms mcp stdio` + only) lets *any* process search the index, including a separate `vcms mcp stdio` running next to the server. **Writing** requires the directory lock: `open` (reader + writer) is held only by the running server. `index_doc`/`delete_doc` stage uncommitted ops; `commit` flushes; `rebuild_all`/`rebuild_site` reindex from @@ -312,7 +314,7 @@ is **derived** and fully rebuildable. Wiring: `Services` holds `search: Option>` (reads) and `search_queue: Option>` (writes). `Services::new` opens the index -read-write (server); `Services::new_read_only` opens it read-only (`cms mcp stdio`). +read-write (server); `Services::new_read_only` opens it read-only (`vcms mcp stdio`). `EntryService`/`SingletonService` **enqueue** on write; `EntryService::list_entries` queries the index — so REST, GraphQL, gRPC, and MCP all get ranked search via the existing `search` param with **no handler changes**. Indexing is asynchronous @@ -322,7 +324,7 @@ index builds on startup when empty, rebuilds after a restore, and exposes owner/operator reindex routes: `POST /api/dashboard/instance/search/reindex` and `POST /api/dashboard/sites/{site_id}/search/reindex`. -This is the cross-process model: writes from `cms mcp stdio` (or any process) land in +This is the cross-process model: writes from `vcms mcp stdio` (or any process) land in the durable queue and the running server indexes them; if the server is down they drain on its next start. The remaining hard limit is *concurrent writers* — only one process indexes at a time. diff --git a/README.md b/README.md index ecbe7baf..7919d30b 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Upload, organize, and serve images and files with automatic thumbnail generation ### 💾 Backups & Disaster Recovery -Create on-demand or scheduled backups of a single site or the whole instance. Backups are compressed, optionally encrypted (AES-256-GCM), and stored on local disk or a separate S3 bucket — keep the last N automatically. Restore in place or import a site as a copy, from the dashboard or offline with `cms restore` when the server won't even boot. Backups are a portable logical dump, so you can move data between SQLite, PostgreSQL, and MySQL. +Create on-demand or scheduled backups of a single site or the whole instance. Backups are compressed, optionally encrypted (AES-256-GCM), and stored on local disk or a separate S3 bucket — keep the last N automatically. Restore in place or import a site as a copy, from the dashboard or offline with `vcms restore` when the server won't even boot. Backups are a portable logical dump, so you can move data between SQLite, PostgreSQL, and MySQL. ### 🔐 Secure & Scalable @@ -66,14 +66,15 @@ A clean, fast interface for content editors and administrators. Rich text editin ```bash bun run build -./target/release/cms +./target/release/vcms ``` -Visit `http://localhost:3000` and log in with: -- **Username:** `admin` +Visit `http://localhost:3000/dashboard` and log in with: +- **Email:** `admin@cms.local` - **Password:** `admin` -*Change the default password after your first login.* +*Login is by email; the name field is just a display name. Change the default +password after your first login.* ### Access Your Content @@ -91,46 +92,46 @@ Visit `http://localhost:3000` and log in with: Run a standalone MCP process for clients that launch local stdio servers: ```bash -CMS_MCP_TOKEN=cms_site_... cms mcp stdio +VCMS_MCP_TOKEN=vcms_site_... vcms mcp stdio ``` The command connects to an existing CMS database and never starts HTTP/gRPC listeners, seeds users, creates a SQLite database, or runs migrations. Run -`cms serve` first when the database schema needs initialization or migration. +`vcms serve` first when the database schema needs initialization or migration. MCP protocol messages use stdout; structured process logs use stderr. Because the process is launched by the MCP client from an arbitrary working directory, it does **not** rely on a `.env` in the current directory. The database path and the `HMAC_SECRET` it needs to verify the token are -read from the CMS home directory (`~/.cms`, see [Data directory](#data-directory)) -that `cms serve` initialized. The client only needs to supply `CMS_MCP_TOKEN` -(and `CMS_HOME` if you moved the home directory): +read from the CMS home directory (`~/.vcms`, see [Data directory](#data-directory)) +that `vcms serve` initialized. The client only needs to supply `VCMS_MCP_TOKEN` +(and `VCMS_HOME` if you moved the home directory): ```jsonc // Example MCP client config { - "command": "cms", + "command": "vcms", "args": ["mcp", "stdio"], - "env": { "CMS_MCP_TOKEN": "cms_site_..." } + "env": { "VCMS_MCP_TOKEN": "vcms_site_..." } } ``` ### Data directory All runtime files live under a single home directory so a fresh install works -from any working directory. The location is `$CMS_HOME` when set, otherwise -`~/.cms` (resolved cross-platform — same layout on Windows, macOS, and Linux): +from any working directory. The location is `$VCMS_HOME` when set, otherwise +`~/.vcms` (resolved cross-platform — same layout on Windows, macOS, and Linux): ```text -~/.cms/ - config.toml # non-secret configuration (cms config init writes here) +~/.vcms/ + config.toml # non-secret configuration (vcms config init writes here) secrets.toml # auto-generated HMAC_SECRET + backup key (0600 on unix) - cms.db # default SQLite database (+ -wal / -shm) + vcms.db # default SQLite database (+ -wal / -shm) logs/ # rolling logs when [log] output = "file" storage/ # default filesystem storage for uploads ``` -`cms serve` creates this directory on first run and generates `secrets.toml` if +`vcms serve` creates this directory on first run and generates `secrets.toml` if absent. Environment variables (`DATABASE_URL`, `HMAC_SECRET`, `STORAGE_FS_PATH`, S3 settings, …) still override these defaults. @@ -167,6 +168,7 @@ git clone https://github.com/velopulent/cms ```bash # Run development server cd cms +bun install bun run dev ``` diff --git a/apps/backend/Cargo.toml b/apps/backend/Cargo.toml index 55c39b82..74d44a31 100644 --- a/apps/backend/Cargo.toml +++ b/apps/backend/Cargo.toml @@ -7,6 +7,12 @@ build = "build.rs" # license check for our own crate (see `[licenses] private` in deny.toml). publish = false +# The crate stays `cms` (keeps the `cms=…` log target and proto package), but the +# shipped binary is `vcms`. +[[bin]] +name = "vcms" +path = "src/main.rs" + [features] default = ["embed-dashboard"] embed-dashboard = ["dep:rust-embed"] diff --git a/apps/backend/migrations/mysql/20260501000000_initial_schema.sql b/apps/backend/migrations/mysql/20260501000000_initial_schema.sql index ef5f991f..732750cf 100644 --- a/apps/backend/migrations/mysql/20260501000000_initial_schema.sql +++ b/apps/backend/migrations/mysql/20260501000000_initial_schema.sql @@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS users ( id VARCHAR(36) PRIMARY KEY NOT NULL, - username VARCHAR(255) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, email VARCHAR(255) UNIQUE NOT NULL, password_hash TEXT NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, diff --git a/apps/backend/migrations/mysql/20260610000000_auth_rbac.sql b/apps/backend/migrations/mysql/20260610000000_auth_rbac.sql index d2879d6a..9f78e1c7 100644 --- a/apps/backend/migrations/mysql/20260610000000_auth_rbac.sql +++ b/apps/backend/migrations/mysql/20260610000000_auth_rbac.sql @@ -40,7 +40,7 @@ CREATE INDEX idx_security_audit_created ON security_audit_events(created_at); UPDATE users SET instance_role = 'instance_owner' WHERE id = COALESCE( - (SELECT selected.id FROM (SELECT id FROM users WHERE username = 'admin' ORDER BY created_at LIMIT 1) selected), + (SELECT selected.id FROM (SELECT id FROM users WHERE email = 'admin@cms.local') selected), (SELECT selected.id FROM (SELECT id FROM users ORDER BY created_at, id LIMIT 1) selected) ) AND NOT EXISTS ( diff --git a/apps/backend/migrations/postgres/20260501000000_initial_schema.sql b/apps/backend/migrations/postgres/20260501000000_initial_schema.sql index 01e3e0cd..6055d8e3 100644 --- a/apps/backend/migrations/postgres/20260501000000_initial_schema.sql +++ b/apps/backend/migrations/postgres/20260501000000_initial_schema.sql @@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY NOT NULL, - username TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), diff --git a/apps/backend/migrations/postgres/20260610000000_auth_rbac.sql b/apps/backend/migrations/postgres/20260610000000_auth_rbac.sql index 8d0f5d39..04ae9319 100644 --- a/apps/backend/migrations/postgres/20260610000000_auth_rbac.sql +++ b/apps/backend/migrations/postgres/20260610000000_auth_rbac.sql @@ -35,7 +35,7 @@ CREATE INDEX idx_security_audit_created ON security_audit_events(created_at); UPDATE users SET instance_role = 'instance_owner' WHERE id = COALESCE( - (SELECT id FROM users WHERE username = 'admin' ORDER BY created_at LIMIT 1), + (SELECT id FROM users WHERE email = 'admin@cms.local'), (SELECT id FROM users ORDER BY created_at, id LIMIT 1) ) AND NOT EXISTS (SELECT 1 FROM users WHERE instance_role = 'instance_owner'); diff --git a/apps/backend/migrations/sqlite/20260501000000_initial_schema.sql b/apps/backend/migrations/sqlite/20260501000000_initial_schema.sql index 76fccb97..c425e011 100644 --- a/apps/backend/migrations/sqlite/20260501000000_initial_schema.sql +++ b/apps/backend/migrations/sqlite/20260501000000_initial_schema.sql @@ -2,7 +2,7 @@ CREATE TABLE IF NOT EXISTS users ( id TEXT PRIMARY KEY NOT NULL, - username TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), diff --git a/apps/backend/migrations/sqlite/20260610000000_auth_rbac.sql b/apps/backend/migrations/sqlite/20260610000000_auth_rbac.sql index a212ea06..788e977e 100644 --- a/apps/backend/migrations/sqlite/20260610000000_auth_rbac.sql +++ b/apps/backend/migrations/sqlite/20260610000000_auth_rbac.sql @@ -35,7 +35,7 @@ CREATE INDEX idx_security_audit_created ON security_audit_events(created_at); UPDATE users SET instance_role = 'instance_owner' WHERE id = COALESCE( - (SELECT id FROM users WHERE username = 'admin' ORDER BY created_at LIMIT 1), + (SELECT id FROM users WHERE email = 'admin@cms.local'), (SELECT id FROM users ORDER BY created_at, id LIMIT 1) ) AND NOT EXISTS (SELECT 1 FROM users WHERE instance_role = 'instance_owner'); diff --git a/apps/backend/migrations/sqlite/20260616000000_roles_v2.sql b/apps/backend/migrations/sqlite/20260616000000_roles_v2.sql index 812afecc..ce9c3bbb 100644 --- a/apps/backend/migrations/sqlite/20260616000000_roles_v2.sql +++ b/apps/backend/migrations/sqlite/20260616000000_roles_v2.sql @@ -8,7 +8,7 @@ PRAGMA foreign_keys=OFF; -- Widen users.instance_role to allow instance_admin. CREATE TABLE users_new ( id TEXT PRIMARY KEY NOT NULL, - username TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, email TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, created_at TEXT NOT NULL DEFAULT (datetime('now')), @@ -16,8 +16,8 @@ CREATE TABLE users_new ( instance_role TEXT CHECK(instance_role IS NULL OR instance_role IN ('instance_owner', 'instance_admin')), must_change_password INTEGER NOT NULL DEFAULT 0 ); -INSERT INTO users_new (id, username, email, password_hash, created_at, updated_at, instance_role, must_change_password) - SELECT id, username, email, password_hash, created_at, updated_at, instance_role, must_change_password FROM users; +INSERT INTO users_new (id, name, email, password_hash, created_at, updated_at, instance_role, must_change_password) + SELECT id, name, email, password_hash, created_at, updated_at, instance_role, must_change_password FROM users; DROP TABLE users; ALTER TABLE users_new RENAME TO users; diff --git a/apps/backend/project.json b/apps/backend/project.json index 8583c1dc..7a619b04 100644 --- a/apps/backend/project.json +++ b/apps/backend/project.json @@ -11,7 +11,7 @@ "command": "cargo build --release --locked", "env": { "SKIP_DASHBOARD_BUILD": "1" } }, - "outputs": ["{workspaceRoot}/target/release/cms"], + "outputs": ["{workspaceRoot}/target/release/vcms"], "cache": false }, "build-dev": { @@ -21,7 +21,7 @@ "command": "cargo build --locked", "env": { "SKIP_DASHBOARD_BUILD": "1" } }, - "outputs": ["{workspaceRoot}/target/debug/cms"], + "outputs": ["{workspaceRoot}/target/debug/vcms"], "cache": false }, "run": { diff --git a/apps/backend/src/cli.rs b/apps/backend/src/cli.rs index 7128ad13..5a0eff60 100644 --- a/apps/backend/src/cli.rs +++ b/apps/backend/src/cli.rs @@ -5,27 +5,27 @@ use clap::{Parser, Subcommand}; /// CMS server and administrative command-line interface. #[derive(Parser, Debug, Default)] #[command( - name = "cms", + name = "vcms", version, about = "Headless CMS server", long_about = "Headless CMS server.\n\n\ - All runtime files live under one home directory ($CMS_HOME, default ~/.cms): \ + All runtime files live under one home directory ($VCMS_HOME, default ~/.vcms): \ config.toml, secrets.toml, the SQLite database, logs/, and storage/. \ - `cms serve` creates it on first run and generates secrets if absent.", - after_help = "DATA DIRECTORY ($CMS_HOME, default ~/.cms):\n \ - config.toml non-secret config (written by `cms config init`)\n \ + `vcms serve` creates it on first run and generates secrets if absent.", + after_help = "DATA DIRECTORY ($VCMS_HOME, default ~/.vcms):\n \ + config.toml non-secret config (written by `vcms config init`)\n \ secrets.toml auto-generated HMAC + backup secrets (0600 on unix)\n \ - cms.db default SQLite database (+ -wal / -shm)\n \ + vcms.db default SQLite database (+ -wal / -shm)\n \ logs/ rolling logs when [log] output = \"file\"\n \ storage/ default filesystem storage for uploads\n\n\ KEY ENVIRONMENT (env overrides config; CLI flags override env):\n \ - CMS_HOME home directory [default: ~/.cms]\n \ - DATABASE_URL sqlite/postgres/mysql URL [default: sqlite://~/.cms/cms.db]\n \ + VCMS_HOME home directory [default: ~/.vcms]\n \ + DATABASE_URL sqlite/postgres/mysql URL [default: sqlite://~/.vcms/vcms.db]\n \ HMAC_SECRET token-lookup HMAC key [auto-generated to secrets.toml]" )] pub struct Cli { /// Path to a config file (overrides the search path). - #[arg(long, global = true, env = "CMS_CONFIG", value_name = "PATH")] + #[arg(long, global = true, env = "VCMS_CONFIG", value_name = "PATH")] pub config: Option, /// REST API bind address, e.g. 0.0.0.0:3000 (overrides config + env). @@ -33,7 +33,7 @@ pub struct Cli { pub bind: Option, /// Database URL, e.g. sqlite:path / postgres://… (overrides config + env) - /// [default: sqlite://~/.cms/cms.db]. + /// [default: sqlite://~/.vcms/vcms.db]. #[arg(long, global = true, value_name = "URL")] pub database_url: Option, @@ -139,10 +139,10 @@ pub enum ConfigAction { #[derive(Subcommand, Debug)] pub enum AdminAction { - /// Reset a user's password. + /// Reset a user's password. The user is identified by their unique login email. ResetPassword { - #[arg(long, value_name = "USERNAME")] - username: String, + #[arg(long, value_name = "EMAIL")] + email: String, #[arg(long, value_name = "PASSWORD")] password: String, }, @@ -155,7 +155,7 @@ mod tests { #[test] fn parses_mcp_stdio_command() { - let cli = Cli::try_parse_from(["cms", "mcp", "stdio"]).expect("command should parse"); + let cli = Cli::try_parse_from(["vcms", "mcp", "stdio"]).expect("command should parse"); assert!(matches!( cli.command, Some(Command::Mcp { diff --git a/apps/backend/src/config.rs b/apps/backend/src/config.rs index e3c01c8a..60df4d41 100644 --- a/apps/backend/src/config.rs +++ b/apps/backend/src/config.rs @@ -90,8 +90,9 @@ pub struct Config { pub log_dir: String, } -static DEFAULT_HMAC_SECRET: &str = "cms-hmac-secret-change-in-production"; -static DEFAULT_LOG_LEVEL: &str = "cms=debug,tower_http=debug,axum=debug"; +// `cms` is the library crate (handlers/services/etc.); `vcms` is the binary crate +// (main.rs lifecycle/startup logs). Both must be enabled to see all of our logs. +static DEFAULT_LOG_LEVEL: &str = "cms=debug,vcms=debug,tower_http=debug,axum=debug"; /// Intermediate, fully-optional config deserialized from the merged figment /// layers (defaults < TOML file < env vars < CLI flags). Converted into the @@ -136,7 +137,7 @@ struct RawConfig { public_registration_enabled: Option, #[serde(default, deserialize_with = "de_opt_str_list")] allowed_origins: Option>, - /// Maps the `CMS_ENV` env var / `env` TOML key; "production" => production. + /// Maps the `VCMS_ENV` env var / `env` TOML key; "production" => production. env: Option, db_max_connections: Option, @@ -185,7 +186,7 @@ impl Config { let mut figment = Figment::new(); // Lowest-precedence secret layer: persisted HMAC secret from - // `~/.cms/secrets.toml`. Read-only here (generation happens in + // `~/.vcms/secrets.toml`. Read-only here (generation happens in // `secrets::ensure()` during serve/admin). Best-effort: a missing or // unreadable file just leaves the built-in defaults in play. Env vars // and CLI flags still override these. @@ -198,7 +199,7 @@ impl Config { } // Environment layer: keep the existing unprefixed names. Remap the few - // that don't match a field 1:1 (legacy log vars, CMS_ENV) and project + // that don't match a field 1:1 (legacy log vars, VCMS_ENV) and project // log keys into the nested `log` table via the "." separator. figment = figment.merge( Env::raw() @@ -210,7 +211,7 @@ impl Config { "log_format" => "log.format".into(), "log_annotations" => "log.annotations".into(), "log_dir" => "log.dir".into(), - "cms_env" => "env".into(), + "vcms_env" => "env".into(), _ => k.into(), } }) @@ -228,7 +229,7 @@ impl Config { figment = figment.merge(Serialized::default("log.level", v)); } - Ok(figment.extract::().map_err(Box::new)?.into_config()) + figment.extract::().map_err(Box::new)?.into_config() } /// Convenience for callers that only want env + defaults (no CLI flags). @@ -249,40 +250,23 @@ impl Config { } pub fn validate_security(&self) -> Result<(), String> { - let hmac_default = self.hmac_secret == DEFAULT_HMAC_SECRET; - + // The secret is always real here: config load hard-errors when no + // secret is resolved, so there is no built-in placeholder to guard + // against. We only enforce strength/cookie policy in production. if self.production { - if hmac_default { - return Err("HMAC_SECRET must be changed in production".into()); - } if self.hmac_secret.len() < 32 { return Err("HMAC_SECRET must be at least 32 bytes long".into()); } if !self.cookie_secure { return Err("COOKIE_SECURE must be enabled in production".into()); } - } else if hmac_default && binds_publicly(&self.bind_address) { - // Not production, but exposing a public listener with the built-in default - // secret lets anyone reachable forge sessions/tokens. Warn loudly - // rather than hard-fail so local default startup keeps working. - eprintln!( - "\n\ - ============================ SECURITY WARNING ============================\n\ - Binding to public address {} while using the built-in default\n\ - HMAC_SECRET. Anyone who can reach this port can forge\n\ - sessions and access tokens.\n\ - Set HMAC_SECRET (>=32 random bytes) before exposing this\n\ - server, or bind to 127.0.0.1 for local development.\n\ - =========================================================================\n", - self.bind_address - ); } Ok(()) } /// Render the effective configuration as TOML with secrets redacted. - /// Used by `cms config show`. + /// Used by `vcms config show`. pub fn redacted_toml(&self) -> String { format!( "# Effective configuration (secrets redacted)\n\ @@ -367,15 +351,22 @@ impl Config { } impl RawConfig { - fn into_config(self) -> Config { - let hmac_secret = self.hmac_secret.unwrap_or_else(|| { - eprintln!("WARNING: Using default HMAC secret. Set HMAC_SECRET environment variable in production!"); - DEFAULT_HMAC_SECRET.to_string() - }); + fn into_config(self) -> Result> { + // No silent placeholder: a secret must come from secrets.toml, config, or + // the HMAC_SECRET env var. `serve`/`admin` generate one via + // `secrets::ensure()` and `mcp stdio` guards on its presence, so this only + // fires on a genuinely uninitialized instance. + let hmac_secret = self.hmac_secret.ok_or_else(|| { + Box::new(figment::Error::from( + "No HMAC secret resolved; run `vcms serve` once to generate ~/.vcms/secrets.toml, \ + or set HMAC_SECRET" + .to_string(), + )) + })?; let log = self.log.unwrap_or_default(); - Config { + Ok(Config { database_url: self.database_url.unwrap_or_else(paths::default_database_url), hmac_secret, bind_address: self.bind_address.unwrap_or_else(|| "0.0.0.0:3000".into()), @@ -427,12 +418,12 @@ impl RawConfig { log_dir: log .dir .unwrap_or_else(|| paths::logs_dir().to_string_lossy().into_owned()), - } + }) } } /// Resolve the config file path. First match wins; a missing file is fine. -/// Order: `--config` / `CMS_CONFIG` > `./cms.toml` > user config dir > `/etc/cms`. +/// Order: `--config` / `VCMS_CONFIG` > `./vcms.toml` > user config dir > `/etc/vcms`. pub fn resolve_config_path(cli: &Cli) -> Option { if let Some(path) = &cli.config { return Some(path.clone()); @@ -442,21 +433,21 @@ pub fn resolve_config_path(cli: &Cli) -> Option { /// The ordered list of locations searched when no `--config` is given. pub fn config_search_paths() -> Vec { - let mut paths = vec![PathBuf::from("cms.toml")]; + let mut paths = vec![PathBuf::from("vcms.toml")]; if let Some(path) = user_config_path() { paths.push(path); } - paths.push(PathBuf::from("/etc/cms/config.toml")); + paths.push(PathBuf::from("/etc/vcms/config.toml")); paths } -/// The user-config location for the config file: `~/.cms/config.toml` -/// (or `$CMS_HOME/config.toml`). +/// The user-config location for the config file: `~/.vcms/config.toml` +/// (or `$VCMS_HOME/config.toml`). pub fn user_config_path() -> Option { Some(paths::config_file()) } -/// The scaffold written by `cms config init` — non-secret keys only, with +/// The scaffold written by `vcms config init` — non-secret keys only, with /// secrets shown as commented env-var hints. pub fn default_config_toml() -> String { let hosts = default_mcp_allowed_hosts() @@ -507,7 +498,7 @@ pub fn default_config_toml() -> String { # --- Backups ---\n\ # Run the scheduled-backup poller and allow on-demand backups.\n\ backup_enabled = true\n\ - # Destination for backup artifacts: \"filesystem\" (default, under ~/.cms/backups)\n\ + # Destination for backup artifacts: \"filesystem\" (default, under ~/.vcms/backups)\n\ # or \"s3\". S3 credentials are secrets — set them via env (see below).\n\ backup_destination = \"filesystem\"\n\ # backup_local_path = \"./backups\"\n\ @@ -524,7 +515,7 @@ pub fn default_config_toml() -> String { # Build a local inverted index so entry search is ranked + tokenized.\n\ # When false, search falls back to a basic SQL LIKE match.\n\ search_enabled = true\n\ - # search_index_path = \"./search\" # defaults to ~/.cms/search\n\n\ + # search_index_path = \"./search\" # defaults to ~/.vcms/search\n\n\ # --- MCP ---\n\ mcp_enabled = true\n\ mcp_allowed_hosts = [{hosts}]\n\ @@ -539,20 +530,6 @@ pub fn default_config_toml() -> String { ) } -/// Whether a listen address is reachable beyond loopback. `0.0.0.0` / -/// `[::]` (unspecified) and any routable IP count as public; only `127.0.0.0/8` -/// and `::1` are treated as loopback-only. Unparseable addresses are treated as -/// public unless they obviously reference localhost. -fn binds_publicly(bind_address: &str) -> bool { - match bind_address.parse::() { - Ok(addr) => !addr.ip().is_loopback(), - Err(_) => { - let lower = bind_address.to_ascii_lowercase(); - !(lower.contains("127.0.0.1") || lower.contains("::1") || lower.contains("localhost")) - } - } -} - fn default_mcp_allowed_hosts() -> Vec { vec![ "localhost".to_string(), @@ -699,9 +676,14 @@ mod tests { #[test] fn test_raw_config_into_config_applies_defaults() { - let config = RawConfig::default().into_config(); + let config = RawConfig { + hmac_secret: Some("test-secret".to_string()), + ..RawConfig::default() + } + .into_config() + .expect("a supplied secret should produce a config"); assert!(config.database_url.starts_with("sqlite://")); - assert!(config.database_url.ends_with("cms.db")); + assert!(config.database_url.ends_with("vcms.db")); assert_eq!(config.bind_address, "0.0.0.0:3000"); assert_eq!(config.max_upload_size_bytes, 50 * 1024 * 1024); assert_eq!(config.log_level, DEFAULT_LOG_LEVEL); @@ -710,13 +692,21 @@ mod tests { assert!(!config.production); } + #[test] + fn test_into_config_requires_hmac_secret() { + // No secret from any layer => hard error, never a silent placeholder. + assert!(RawConfig::default().into_config().is_err()); + } + #[test] fn test_env_maps_to_production() { let config = RawConfig { + hmac_secret: Some("test-secret".to_string()), env: Some("production".to_string()), ..RawConfig::default() } - .into_config(); + .into_config() + .expect("a supplied secret should produce a config"); assert!(config.production); } } diff --git a/apps/backend/src/database/mod.rs b/apps/backend/src/database/mod.rs index ce594b8f..3f9922dc 100644 --- a/apps/backend/src/database/mod.rs +++ b/apps/backend/src/database/mod.rs @@ -52,14 +52,14 @@ pub async fn connect_db_without_migrations( let pool = DbPool::from_existing_with_config(config).await.map_err(|error| { format!( "Unable to connect to the existing CMS database without creating or migrating it: {error}. \ - Run `cms serve` once to initialize and migrate the database." + Run `vcms serve` once to initialize and migrate the database." ) })?; pool.validate_migrations().await.map_err(|error| { format!( "CMS database schema is missing, outdated, or incompatible: {error}. \ - Run `cms serve` once to apply migrations." + Run `vcms serve` once to apply migrations." ) })?; diff --git a/apps/backend/src/database/pool.rs b/apps/backend/src/database/pool.rs index cdd128d3..04c59e27 100644 --- a/apps/backend/src/database/pool.rs +++ b/apps/backend/src/database/pool.rs @@ -57,6 +57,7 @@ impl DbPool { let options = sqlx::sqlite::SqliteConnectOptions::from_str(&config.database_url) .map_err(|e| Error::Configuration(e.to_string().into()))? .create_if_missing(create_sqlite_if_missing) + .foreign_keys(true) .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) .busy_timeout(Duration::from_secs(30)); let pool = SqlitePoolOptions::new() diff --git a/apps/backend/src/graphql/context.rs b/apps/backend/src/graphql/context.rs index ee85b05a..87e9907f 100644 --- a/apps/backend/src/graphql/context.rs +++ b/apps/backend/src/graphql/context.rs @@ -24,7 +24,7 @@ impl GqlContext { if let Some(header) = auth_header && let Some(token) = header.strip_prefix("Bearer ") - && token.starts_with("cms_site_") + && token.starts_with("vcms_site_") && let Ok(auth_actor) = verify_access_token(token, &repository, hmac_secret).await { if let Actor::ApiKey(k) = &auth_actor { diff --git a/apps/backend/src/grpc/auth.rs b/apps/backend/src/grpc/auth.rs index 08e927f7..8678a470 100644 --- a/apps/backend/src/grpc/auth.rs +++ b/apps/backend/src/grpc/auth.rs @@ -18,7 +18,7 @@ pub struct InvalidToken; /// /// Returns an `AuthContext` on success, or `InvalidToken` on any validation failure. pub fn parse_token(token: &str, config: &Config) -> Result { - if !token.starts_with("cms_site_") { + if !token.starts_with("vcms_site_") { return Err(InvalidToken); } @@ -42,7 +42,7 @@ mod tests { hmac_secret: "secret".to_string(), ..Default::default() }; - let token = "cms_site_abc1234567890123456"; + let token = "vcms_site_abc1234567890123456"; let ctx = parse_token(token, &config).unwrap(); assert_eq!(ctx.token, token); assert_eq!(ctx.prefix, token.chars().take(24).collect::()); diff --git a/apps/backend/src/handlers/auth_handler.rs b/apps/backend/src/handlers/auth_handler.rs index a4156498..980d45c8 100644 --- a/apps/backend/src/handlers/auth_handler.rs +++ b/apps/backend/src/handlers/auth_handler.rs @@ -7,21 +7,21 @@ use axum::{ use serde_json::json; use tracing::instrument; -use crate::models::user::{ChangePasswordRequest, CreateUser, LoginRequest}; +use crate::models::user::{ChangePasswordRequest, CreateUser, LoginRequest, UpdateSelfProfile}; use crate::services::Services; #[instrument(skip(services, payload))] pub async fn register(Extension(services): Extension, Json(payload): Json) -> Response { let user = match services .auth - .register(&payload.username, &payload.email, &payload.password) + .register(&payload.name, &payload.email, &payload.password) .await { Ok(u) => u, Err(e) => return e.into_response(), }; - let (token, csrf_token) = match services.auth.login(&payload.username, &payload.password).await { + let (token, csrf_token) = match services.auth.login(&payload.email, &payload.password).await { Ok((_, token, csrf)) => (token, csrf), Err(e) => return e.into_response(), }; @@ -31,7 +31,7 @@ pub async fn register(Extension(services): Extension, Json(payload): J #[instrument(skip(services, payload))] pub async fn login(Extension(services): Extension, Json(payload): Json) -> Response { - let (user, token, csrf_token) = match services.auth.login(&payload.username, &payload.password).await { + let (user, token, csrf_token) = match services.auth.login(&payload.email, &payload.password).await { Ok(result) => result, Err(e) => return e.into_response(), }; @@ -129,3 +129,21 @@ pub async fn change_password( Err(error) => error.into_response(), } } + +pub async fn update_me( + Extension(services): Extension, + auth: crate::middleware::auth::AuthContext, + Json(payload): Json, +) -> Response { + let crate::middleware::auth::Actor::User(user) = &auth.actor else { + return ( + StatusCode::UNAUTHORIZED, + Json(json!({"error": "Session authentication required"})), + ) + .into_response(); + }; + match services.auth.update_self_name(&user.user_id, &payload.name).await { + Ok(user) => (StatusCode::OK, Json(user)).into_response(), + Err(error) => error.into_response(), + } +} diff --git a/apps/backend/src/handlers/instance_handler.rs b/apps/backend/src/handlers/instance_handler.rs index 68a11a17..ed27861d 100644 --- a/apps/backend/src/handlers/instance_handler.rs +++ b/apps/backend/src/handlers/instance_handler.rs @@ -5,12 +5,35 @@ use axum::{ response::{IntoResponse, Response}, }; -use crate::middleware::auth::{AuthContext, require_instance_action}; +use crate::middleware::auth::{Actor, AuthContext, require_instance_action}; use crate::models::authorization::Action; -use crate::models::user::{CreateManagedUser, UpdateInstanceRole}; +use crate::models::user::{AdminSetPassword, CreateManagedUser, UpdateInstanceRole, UpdateUserProfile}; use crate::repository::Repository; +use crate::repository::error::RepositoryError; use crate::services::Services; +/// True when the target user currently holds the instance-owner role. Editing or deleting +/// an owner is owner-only (`InstanceRolesGrant`); everything else needs `InstanceManage`. +/// Errors propagate so a DB failure fails closed (denies) instead of silently reporting +/// "not an owner" and dropping to the weaker `InstanceManage` gate. +async fn target_is_owner(repository: &Repository, user_id: &str) -> Result { + Ok(repository + .user + .find_by_id(user_id) + .await? + .map(|user| user.instance_role.as_deref() == Some("instance_owner")) + .unwrap_or(false)) +} + +/// Shared 500 response when the owner check itself fails (DB error). +fn owner_check_failed() -> Response { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": "Failed to verify target user" })), + ) + .into_response() +} + pub async fn list_users( auth: AuthContext, Extension(repository): Extension, @@ -43,7 +66,7 @@ pub async fn create_user( match services .auth .create_managed_user( - &payload.username, + &payload.name, &payload.email, &payload.temporary_password, payload.instance_role.as_deref(), @@ -66,11 +89,11 @@ pub async fn update_instance_role( return (status, error).into_response(); } // Anything that grants or revokes the owner role is owner-only. - let target_is_owner = matches!( - repository.user.find_by_id(&user_id).await, - Ok(Some(user)) if user.instance_role.as_deref() == Some("instance_owner") - ); - if (payload.instance_role.as_deref() == Some("instance_owner") || target_is_owner) + let target_owner = match target_is_owner(&repository, &user_id).await { + Ok(value) => value, + Err(_) => return owner_check_failed(), + }; + if (payload.instance_role.as_deref() == Some("instance_owner") || target_owner) && let Err((status, error)) = require_instance_action(&auth, &repository, Action::InstanceRolesGrant).await { return (status, error).into_response(); @@ -84,3 +107,94 @@ pub async fn update_instance_role( Err(error) => error.into_response(), } } + +pub async fn update_user( + auth: AuthContext, + Path(user_id): Path, + Extension(repository): Extension, + Extension(services): Extension, + Json(payload): Json, +) -> Response { + if let Err((status, error)) = require_instance_action(&auth, &repository, Action::InstanceManage).await { + return (status, error).into_response(); + } + match target_is_owner(&repository, &user_id).await { + Ok(true) => { + if let Err((status, error)) = require_instance_action(&auth, &repository, Action::InstanceRolesGrant).await + { + return (status, error).into_response(); + } + } + Ok(false) => {} + Err(_) => return owner_check_failed(), + } + match services + .auth + .update_user_profile(&user_id, &payload.name, &payload.email) + .await + { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(error) => error.into_response(), + } +} + +pub async fn set_user_password( + auth: AuthContext, + Path(user_id): Path, + Extension(repository): Extension, + Extension(services): Extension, + Json(payload): Json, +) -> Response { + if let Err((status, error)) = require_instance_action(&auth, &repository, Action::InstanceManage).await { + return (status, error).into_response(); + } + match target_is_owner(&repository, &user_id).await { + Ok(true) => { + if let Err((status, error)) = require_instance_action(&auth, &repository, Action::InstanceRolesGrant).await + { + return (status, error).into_response(); + } + } + Ok(false) => {} + Err(_) => return owner_check_failed(), + } + match services.auth.admin_set_password(&user_id, &payload.new_password).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(error) => error.into_response(), + } +} + +pub async fn delete_user( + auth: AuthContext, + Path(user_id): Path, + Extension(repository): Extension, + Extension(services): Extension, +) -> Response { + if let Err((status, error)) = require_instance_action(&auth, &repository, Action::InstanceManage).await { + return (status, error).into_response(); + } + // Operators cannot delete their own account from here. + if let Actor::User(actor) = &auth.actor + && actor.user_id == user_id + { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ "error": "You cannot delete your own account" })), + ) + .into_response(); + } + match target_is_owner(&repository, &user_id).await { + Ok(true) => { + if let Err((status, error)) = require_instance_action(&auth, &repository, Action::InstanceRolesGrant).await + { + return (status, error).into_response(); + } + } + Ok(false) => {} + Err(_) => return owner_check_failed(), + } + match services.auth.delete_user(&user_id).await { + Ok(()) => StatusCode::NO_CONTENT.into_response(), + Err(error) => error.into_response(), + } +} diff --git a/apps/backend/src/handlers/site_handler.rs b/apps/backend/src/handlers/site_handler.rs index d9bfb733..c1500487 100644 --- a/apps/backend/src/handlers/site_handler.rs +++ b/apps/backend/src/handlers/site_handler.rs @@ -160,7 +160,7 @@ pub async fn invite_member( match services .site - .invite_member(&site_id, &payload.username, &payload.role, &actor_id) + .invite_member(&site_id, &payload.email, &payload.role, &actor_id) .await { Ok(member) => (StatusCode::CREATED, Json(member)).into_response(), diff --git a/apps/backend/src/main.rs b/apps/backend/src/main.rs index 5ad7fa23..3d85ba65 100644 --- a/apps/backend/src/main.rs +++ b/apps/backend/src/main.rs @@ -84,6 +84,14 @@ async fn run_serve(cli: &Cli) -> Result<(), Box> { backup_service.clone(), ); + // Reconcile backups/restore jobs left mid-flight by a previous process: any + // running/pending row at startup is orphaned (backups only run in-process). + match cms::services::backup::meta::fail_orphaned(&pool, &cms::services::backup::now_iso()).await { + Ok(n) if n > 0 => info!("Reconciled {n} interrupted backup job(s) to failed"), + Ok(_) => {} + Err(e) => tracing::error!("Failed to reconcile interrupted backups: {e}"), + } + if config.backup_enabled { let scheduler_service = backup_service.clone(); tokio::spawn(async move { @@ -154,11 +162,11 @@ async fn run_serve(cli: &Cli) -> Result<(), Box> { async fn run_mcp_stdio(cli: &Cli) -> Result<(), Box> { // Read-only: never create the home dir, database, or secrets file here. The - // persisted secrets written by `cms serve` are what make this process verify + // persisted secrets written by `vcms serve` are what make this process verify // the site token with the same HMAC secret the server signed it with. if cms::secrets::load()?.is_none() && std::env::var("HMAC_SECRET").is_err() { - return Err("No instance secrets found. Run `cms serve` once to initialize \ - ~/.cms (or set HMAC_SECRET) before `cms mcp stdio`." + return Err("No instance secrets found. Run `vcms serve` once to initialize \ + ~/.vcms (or set HMAC_SECRET) before `vcms mcp stdio`." .into()); } @@ -175,9 +183,9 @@ async fn run_mcp_stdio(cli: &Cli) -> Result<(), Box> { return Err(format!("Invalid production security configuration: {error}").into()); } - let token = std::env::var("CMS_MCP_TOKEN").map_err(|_| "CMS_MCP_TOKEN is required for `cms mcp stdio`")?; + let token = std::env::var("VCMS_MCP_TOKEN").map_err(|_| "VCMS_MCP_TOKEN is required for `vcms mcp stdio`")?; if token.trim().is_empty() { - return Err("CMS_MCP_TOKEN must not be empty".into()); + return Err("VCMS_MCP_TOKEN must not be empty".into()); } let pool = connect_db_without_migrations(&config).await?; @@ -215,7 +223,7 @@ async fn run_mcp_stdio(cli: &Cli) -> Result<(), Box> { fn initialize_storage(config: &Config) -> Arc { let mut storage_registry = StorageRegistry::new(); - // Use an explicit filesystem path if set; otherwise default to ~/.cms/storage + // Use an explicit filesystem path if set; otherwise default to ~/.vcms/storage // so uploads work out of the box — unless S3 is configured and takes over. let fs_path = match (&config.storage_fs_path, config.has_s3()) { (Some(path), _) => Some(path.clone()), @@ -284,8 +292,8 @@ fn run_config(action: &ConfigAction, cli: &Cli) -> Result<(), Box> { } println!("\nSearch order (first existing wins):"); match &cli.config { - Some(explicit) => println!(" 1. --config / CMS_CONFIG: {}", explicit.display()), - None => println!(" 1. --config / CMS_CONFIG: "), + Some(explicit) => println!(" 1. --config / VCMS_CONFIG: {}", explicit.display()), + None => println!(" 1. --config / VCMS_CONFIG: "), } for (i, p) in config::config_search_paths().iter().enumerate() { let marker = if p.exists() { " (exists)" } else { "" }; @@ -304,14 +312,14 @@ async fn run_admin(action: &AdminAction, cli: &Cli) -> Result<(), Box let repository = Repository::new(&pool); match action { - AdminAction::ResetPassword { username, password } => { - let id = match repository.user.find_id_by_username(username).await? { - Some(id) => id, - None => return Err(format!("User '{username}' not found").into()), + AdminAction::ResetPassword { email, password } => { + let id = match repository.user.find_by_email(email).await? { + Some(user) => user.id, + None => return Err(format!("User with email '{email}' not found").into()), }; let password_hash = bcrypt::hash(password, bcrypt::DEFAULT_COST)?; repository.user.update_password(&id, &password_hash, false).await?; - println!("Password updated for user '{username}'."); + println!("Password updated for user with email '{email}'."); } } Ok(()) @@ -435,6 +443,14 @@ async fn run_restore( }) .await?; println!("Restore complete."); + let reindex_path = match (scope, site) { + ("site", Some(sid)) => format!("/api/dashboard/sites/{sid}/search/reindex"), + _ => "/api/dashboard/instance/search/reindex".to_string(), + }; + println!( + "Note: the full-text search index may now be stale. Rebuild it from the dashboard \ + (Settings → Backups → Rebuild search index) or POST {reindex_path} once the server is running." + ); Ok(()) } @@ -449,15 +465,19 @@ fn parse_scope(scope: &str, site: Option<&str>) -> Result\n\n\ + A default admin account was created: email 'admin@cms.local' password 'admin'\n\ + Sign in with the email and password. Anyone who can reach this server can log\n\ + in until you change it. Run:\n\ + \n vcms admin reset-password --email admin@cms.local --password \n\n\ or change it from the dashboard now. Do NOT expose this server until done.\n\ =========================================================================\n" ); diff --git a/apps/backend/src/mcp/auth.rs b/apps/backend/src/mcp/auth.rs index c3e5cf47..3e41e443 100644 --- a/apps/backend/src/mcp/auth.rs +++ b/apps/backend/src/mcp/auth.rs @@ -81,8 +81,8 @@ pub async fn authenticate_mcp_request(mut request: Request, next: Next) -> }; let token = match bearer_token(&request) { - Some(token) if token.starts_with("cms_site_") => token, - Some(_) => return auth_response(StatusCode::UNAUTHORIZED, "MCP requires a CMS access token"), + Some(token) if token.starts_with("vcms_site_") => token, + Some(_) => return auth_response(StatusCode::UNAUTHORIZED, "MCP requires a vcms_site_* access token"), None => return auth_response(StatusCode::UNAUTHORIZED, "Missing Authorization bearer token"), }; diff --git a/apps/backend/src/mcp/transports/http.rs b/apps/backend/src/mcp/transports/http.rs index 78889103..9d254037 100644 --- a/apps/backend/src/mcp/transports/http.rs +++ b/apps/backend/src/mcp/transports/http.rs @@ -44,7 +44,10 @@ pub fn mcp_router( Router::new() .nest_service("/mcp", mcp_service) - .layer(middleware::from_fn(crate::mcp::auth::authenticate_mcp_request)) + // `route_layer` (not `layer`) so MCP auth wraps only the matched `/mcp` routes + // and never the router's fallback — otherwise, once merged into the main router, + // this fallback would authenticate every unmatched path (e.g. `/dashboard/`). + .route_layer(middleware::from_fn(crate::mcp::auth::authenticate_mcp_request)) .layer(Extension(repository.clone())) .layer(Extension(config.clone())) } diff --git a/apps/backend/src/middleware/api_auth.rs b/apps/backend/src/middleware/api_auth.rs index 3f0504fd..7f684f20 100644 --- a/apps/backend/src/middleware/api_auth.rs +++ b/apps/backend/src/middleware/api_auth.rs @@ -19,7 +19,7 @@ pub async fn api_auth_middleware(mut request: Request, next: Next) -> Response { .map(|v| v.trim().to_string()); let token = match auth_header { - Some(t) if t.starts_with("cms_site_") => t, + Some(t) if t.starts_with("vcms_site_") => t, _ => { return ( StatusCode::UNAUTHORIZED, diff --git a/apps/backend/src/middleware/auth.rs b/apps/backend/src/middleware/auth.rs index 003d5993..1dd04b9f 100644 --- a/apps/backend/src/middleware/auth.rs +++ b/apps/backend/src/middleware/auth.rs @@ -207,7 +207,7 @@ pub(crate) async fn verify_access_token( repository: &Repository, hmac_secret: &str, ) -> Result)> { - if !token.starts_with("cms_site_") { + if !token.starts_with("vcms_site_") { return Err(AuthError::unauthorized("Invalid access token")); } @@ -421,7 +421,7 @@ mod tests { #[test] fn test_compute_key_hmac_deterministic() { - let key = "cms_site_abcdefgh_1234567890123456789012"; + let key = "vcms_site_abcdefgh_1234567890123456789012"; let secret = "test-hmac-secret"; let h1 = compute_key_hmac(key, secret); let h2 = compute_key_hmac(key, secret); diff --git a/apps/backend/src/middleware/error.rs b/apps/backend/src/middleware/error.rs index 44242833..7e44496b 100644 --- a/apps/backend/src/middleware/error.rs +++ b/apps/backend/src/middleware/error.rs @@ -17,7 +17,7 @@ impl AuthError { StatusCode::UNAUTHORIZED, Json(Self { error: "site_token_required".into(), - message: "This endpoint requires a cms_site_* token.".into(), + message: "This endpoint requires a vcms_site_* token.".into(), }), ) } diff --git a/apps/backend/src/models/site.rs b/apps/backend/src/models/site.rs index da0dff61..d12defd8 100644 --- a/apps/backend/src/models/site.rs +++ b/apps/backend/src/models/site.rs @@ -39,7 +39,7 @@ pub struct SiteMember { pub id: String, pub site_id: String, pub user_id: String, - pub username: String, + pub name: String, pub email: String, pub role: String, pub created_at: String, @@ -47,7 +47,8 @@ pub struct SiteMember { #[derive(Deserialize, ToSchema)] pub struct InviteMember { - pub username: String, + /// Email of the existing user to add (email is the login identity). + pub email: String, pub role: String, } diff --git a/apps/backend/src/models/user.rs b/apps/backend/src/models/user.rs index 7d70a99c..3484d97b 100644 --- a/apps/backend/src/models/user.rs +++ b/apps/backend/src/models/user.rs @@ -5,7 +5,7 @@ use utoipa::ToSchema; #[derive(Serialize, FromRow, ToSchema, Clone)] pub struct User { pub id: String, - pub username: String, + pub name: String, pub email: String, #[serde(skip_serializing)] pub password_hash: String, @@ -17,14 +17,15 @@ pub struct User { #[derive(Deserialize, ToSchema)] pub struct CreateUser { - pub username: String, + pub name: String, pub email: String, pub password: String, } #[derive(Deserialize, ToSchema)] pub struct LoginRequest { - pub username: String, + /// Login identity is the user's email address. + pub email: String, pub password: String, } @@ -36,7 +37,7 @@ pub struct ChangePasswordRequest { #[derive(Deserialize, ToSchema)] pub struct CreateManagedUser { - pub username: String, + pub name: String, pub email: String, pub temporary_password: String, /// `"instance_owner"`, `"instance_admin"`, or `null` for a non-operator user. @@ -51,6 +52,25 @@ pub struct UpdateInstanceRole { pub instance_role: Option, } +/// Operator-driven update of another user's display name and email. +#[derive(Deserialize, ToSchema)] +pub struct UpdateUserProfile { + pub name: String, + pub email: String, +} + +/// Operator-driven password reset for another user. +#[derive(Deserialize, ToSchema)] +pub struct AdminSetPassword { + pub new_password: String, +} + +/// Self-service update of the signed-in user's own display name. +#[derive(Deserialize, ToSchema)] +pub struct UpdateSelfProfile { + pub name: String, +} + #[derive(Debug, Serialize, Deserialize)] pub struct Claims { pub sub: String, @@ -65,7 +85,7 @@ pub struct AuthResponse { #[derive(Serialize, ToSchema)] pub struct UserPublic { pub id: String, - pub username: String, + pub name: String, pub email: String, pub instance_role: Option, pub must_change_password: bool, diff --git a/apps/backend/src/paths.rs b/apps/backend/src/paths.rs index 22852347..2466a24a 100644 --- a/apps/backend/src/paths.rs +++ b/apps/backend/src/paths.rs @@ -4,27 +4,27 @@ //! install "just works" regardless of the current working directory: //! //! ```text -//! ~/.cms/ +//! ~/.vcms/ //! config.toml # non-secret configuration //! secrets.toml # auto-generated HMAC + backup encryption secrets (0600) -//! cms.db # default SQLite database (+ -wal / -shm) +//! vcms.db # default SQLite database (+ -wal / -shm) //! logs/ # rolling logs when log output = "file" //! storage/ # default filesystem storage for uploads //! ``` //! -//! The root is `$CMS_HOME` when set, otherwise `~/.cms` resolved cross-platform +//! The root is `$VCMS_HOME` when set, otherwise `~/.vcms` resolved cross-platform //! via the `directories` crate. This mirrors the convention used by tools like //! `CARGO_HOME` / `PGDATA`: one predictable, overridable home. use std::path::PathBuf; /// Environment variable that overrides the home directory location. -pub const CMS_HOME_ENV: &str = "CMS_HOME"; +pub const CMS_HOME_ENV: &str = "VCMS_HOME"; /// The CMS home directory root. /// -/// `$CMS_HOME` wins if set and non-empty. Otherwise `~/.cms`. As a last resort -/// (no detectable home directory) falls back to `.cms` in the current dir. +/// `$VCMS_HOME` wins if set and non-empty. Otherwise `~/.vcms`. As a last resort +/// (no detectable home directory) falls back to `.vcms` in the current dir. pub fn home() -> PathBuf { if let Some(value) = std::env::var_os(CMS_HOME_ENV) && !value.is_empty() @@ -33,46 +33,46 @@ pub fn home() -> PathBuf { } directories::BaseDirs::new() - .map(|dirs| dirs.home_dir().join(".cms")) - .unwrap_or_else(|| PathBuf::from(".cms")) + .map(|dirs| dirs.home_dir().join(".vcms")) + .unwrap_or_else(|| PathBuf::from(".vcms")) } -/// `~/.cms/config.toml` — the user-level config file. +/// `~/.vcms/config.toml` — the user-level config file. pub fn config_file() -> PathBuf { home().join("config.toml") } -/// `~/.cms/secrets.toml` — auto-generated secrets file. +/// `~/.vcms/secrets.toml` — auto-generated secrets file. pub fn secrets_file() -> PathBuf { home().join("secrets.toml") } -/// `~/.cms/cms.db` — the default SQLite database file. +/// `~/.vcms/vcms.db` — the default SQLite database file. pub fn default_db_path() -> PathBuf { - home().join("cms.db") + home().join("vcms.db") } -/// `~/.cms/logs` — directory for rolling log files. +/// `~/.vcms/logs` — directory for rolling log files. pub fn logs_dir() -> PathBuf { home().join("logs") } -/// `~/.cms/storage` — default filesystem storage directory for uploads. +/// `~/.vcms/storage` — default filesystem storage directory for uploads. pub fn storage_dir() -> PathBuf { home().join("storage") } -/// `~/.cms/backups` — default local destination for backup artifacts. +/// `~/.vcms/backups` — default local destination for backup artifacts. pub fn backups_dir() -> PathBuf { home().join("backups") } -/// `~/.cms/search` — default location for the Tantivy full-text search index. +/// `~/.vcms/search` — default location for the Tantivy full-text search index. pub fn search_dir() -> PathBuf { home().join("search") } -/// Build the default `DATABASE_URL` (`sqlite:///cms.db`). +/// Build the default `DATABASE_URL` (`sqlite:///vcms.db`). /// /// SQLite URLs use forward slashes, so backslashes are normalized for Windows. pub fn default_database_url() -> String { @@ -103,7 +103,7 @@ mod tests { assert_eq!(home(), dir.path()); assert_eq!(config_file(), dir.path().join("config.toml")); assert_eq!(secrets_file(), dir.path().join("secrets.toml")); - assert_eq!(default_db_path(), dir.path().join("cms.db")); + assert_eq!(default_db_path(), dir.path().join("vcms.db")); assert_eq!(logs_dir(), dir.path().join("logs")); assert_eq!(storage_dir(), dir.path().join("storage")); }); diff --git a/apps/backend/src/repository/mysql/site.rs b/apps/backend/src/repository/mysql/site.rs index de4092f4..61b8bffc 100644 --- a/apps/backend/src/repository/mysql/site.rs +++ b/apps/backend/src/repository/mysql/site.rs @@ -94,11 +94,11 @@ impl SiteRepository for MysqlSiteRepository { async fn list_members(&self, site_id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, SiteMember>( - "SELECT sm.id, sm.site_id, sm.user_id, u.username, u.email, sm.role, CAST(sm.created_at AS CHAR) AS created_at + "SELECT sm.id, sm.site_id, sm.user_id, u.name, u.email, sm.role, CAST(sm.created_at AS CHAR) AS created_at FROM site_members sm JOIN users u ON sm.user_id = u.id WHERE sm.site_id = ? - ORDER BY sm.role DESC, u.username", + ORDER BY sm.role DESC, u.name, u.id", ) .bind(site_id) .fetch_all(&self.pool) @@ -123,7 +123,7 @@ impl SiteRepository for MysqlSiteRepository { .await?; let result = sqlx::query_as::<_, SiteMember>( - "SELECT sm.id, sm.site_id, sm.user_id, u.username, u.email, sm.role, CAST(sm.created_at AS CHAR) AS created_at + "SELECT sm.id, sm.site_id, sm.user_id, u.name, u.email, sm.role, CAST(sm.created_at AS CHAR) AS created_at FROM site_members sm JOIN users u ON sm.user_id = u.id WHERE sm.id = ?", ) .bind(id) @@ -151,7 +151,7 @@ impl SiteRepository for MysqlSiteRepository { } let member = sqlx::query_as::<_, SiteMember>( - "SELECT sm.id, sm.site_id, sm.user_id, u.username, u.email, sm.role, CAST(sm.created_at AS CHAR) AS created_at + "SELECT sm.id, sm.site_id, sm.user_id, u.name, u.email, sm.role, CAST(sm.created_at AS CHAR) AS created_at FROM site_members sm JOIN users u ON sm.user_id = u.id WHERE sm.site_id = ? AND sm.user_id = ?", ) diff --git a/apps/backend/src/repository/mysql/user.rs b/apps/backend/src/repository/mysql/user.rs index 388d9782..e46fabbd 100644 --- a/apps/backend/src/repository/mysql/user.rs +++ b/apps/backend/src/repository/mysql/user.rs @@ -18,12 +18,12 @@ impl MysqlUserRepository { #[async_trait] impl UserRepository for MysqlUserRepository { - async fn find_by_username(&self, username: &str) -> Result, RepositoryError> { - debug!("Finding user by username"); + async fn find_by_email(&self, email: &str) -> Result, RepositoryError> { + debug!("Finding user by email"); let result = sqlx::query_as::<_, User>( - "SELECT id, username, email, password_hash, instance_role, must_change_password, CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at FROM users WHERE username = ?", + "SELECT id, name, email, password_hash, instance_role, must_change_password, CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at FROM users WHERE email = ?", ) - .bind(username) + .bind(email) .fetch_optional(&self.pool) .await?; @@ -33,7 +33,7 @@ impl UserRepository for MysqlUserRepository { async fn find_by_id(&self, id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, User>( - "SELECT id, username, email, password_hash, instance_role, must_change_password, CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at FROM users WHERE id = ?", + "SELECT id, name, email, password_hash, instance_role, must_change_password, CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at FROM users WHERE id = ?", ) .bind(id) .fetch_optional(&self.pool) @@ -44,23 +44,14 @@ impl UserRepository for MysqlUserRepository { async fn list(&self) -> Result, RepositoryError> { Ok(sqlx::query_as::<_, User>( - "SELECT id, username, email, password_hash, instance_role, must_change_password, CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at FROM users ORDER BY created_at, username" + "SELECT id, name, email, password_hash, instance_role, must_change_password, CAST(created_at AS CHAR) AS created_at, CAST(updated_at AS CHAR) AS updated_at FROM users ORDER BY created_at, name" ).fetch_all(&self.pool).await?) } - async fn find_id_by_username(&self, username: &str) -> Result, RepositoryError> { - let result: Option<(String,)> = sqlx::query_as("SELECT id FROM users WHERE username = ?") - .bind(username) - .fetch_optional(&self.pool) - .await?; - - Ok(result.map(|(id,)| id)) - } - - async fn create(&self, id: &str, username: &str, email: &str, password_hash: &str) -> Result<(), RepositoryError> { - sqlx::query("INSERT INTO users (id, username, email, password_hash) VALUES (?, ?, ?, ?)") + async fn create(&self, id: &str, name: &str, email: &str, password_hash: &str) -> Result<(), RepositoryError> { + sqlx::query("INSERT INTO users (id, name, email, password_hash) VALUES (?, ?, ?, ?)") .bind(id) - .bind(username) + .bind(name) .bind(email) .bind(password_hash) .execute(&self.pool) @@ -69,9 +60,9 @@ impl UserRepository for MysqlUserRepository { Ok(()) } - async fn exists(&self, username: &str) -> Result { - let result: Option<(String,)> = sqlx::query_as("SELECT id FROM users WHERE username = ?") - .bind(username) + async fn exists(&self, email: &str) -> Result { + let result: Option<(String,)> = sqlx::query_as("SELECT id FROM users WHERE email = ?") + .bind(email) .fetch_optional(&self.pool) .await?; @@ -130,4 +121,24 @@ impl UserRepository for MysqlUserRepository { .rows_affected(), ) } + + async fn update_profile(&self, user_id: &str, name: &str, email: &str) -> Result { + Ok( + sqlx::query("UPDATE users SET name = ?, email = ?, updated_at = NOW() WHERE id = ?") + .bind(name) + .bind(email) + .bind(user_id) + .execute(&self.pool) + .await? + .rows_affected(), + ) + } + + async fn delete(&self, user_id: &str) -> Result { + Ok(sqlx::query("DELETE FROM users WHERE id = ?") + .bind(user_id) + .execute(&self.pool) + .await? + .rows_affected()) + } } diff --git a/apps/backend/src/repository/postgres/site.rs b/apps/backend/src/repository/postgres/site.rs index fc734952..23c9bf99 100644 --- a/apps/backend/src/repository/postgres/site.rs +++ b/apps/backend/src/repository/postgres/site.rs @@ -94,11 +94,11 @@ impl SiteRepository for PostgresSiteRepository { async fn list_members(&self, site_id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, SiteMember>( - "SELECT sm.id, sm.site_id, sm.user_id, u.username, u.email, sm.role, sm.created_at::text as created_at + "SELECT sm.id, sm.site_id, sm.user_id, u.name, u.email, sm.role, sm.created_at::text as created_at FROM site_members sm JOIN users u ON sm.user_id = u.id WHERE sm.site_id = $1 - ORDER BY sm.role DESC, u.username", + ORDER BY sm.role DESC, u.name, u.id", ) .bind(site_id) .fetch_all(&self.pool) @@ -123,7 +123,7 @@ impl SiteRepository for PostgresSiteRepository { .await?; let result = sqlx::query_as::<_, SiteMember>( - "SELECT sm.id, sm.site_id, sm.user_id, u.username, u.email, sm.role, sm.created_at::text as created_at + "SELECT sm.id, sm.site_id, sm.user_id, u.name, u.email, sm.role, sm.created_at::text as created_at FROM site_members sm JOIN users u ON sm.user_id = u.id WHERE sm.id = $1", ) .bind(id) @@ -151,7 +151,7 @@ impl SiteRepository for PostgresSiteRepository { } let member = sqlx::query_as::<_, SiteMember>( - "SELECT sm.id, sm.site_id, sm.user_id, u.username, u.email, sm.role, sm.created_at::text as created_at + "SELECT sm.id, sm.site_id, sm.user_id, u.name, u.email, sm.role, sm.created_at::text as created_at FROM site_members sm JOIN users u ON sm.user_id = u.id WHERE sm.site_id = $1 AND sm.user_id = $2", ) diff --git a/apps/backend/src/repository/postgres/user.rs b/apps/backend/src/repository/postgres/user.rs index 7af81fb1..64e8da16 100644 --- a/apps/backend/src/repository/postgres/user.rs +++ b/apps/backend/src/repository/postgres/user.rs @@ -18,12 +18,12 @@ impl PostgresUserRepository { #[async_trait] impl UserRepository for PostgresUserRepository { - async fn find_by_username(&self, username: &str) -> Result, RepositoryError> { - debug!("Finding user by username"); + async fn find_by_email(&self, email: &str) -> Result, RepositoryError> { + debug!("Finding user by email"); let result = sqlx::query_as::<_, User>( - "SELECT id, username, email, password_hash, instance_role, must_change_password, created_at::text as created_at, updated_at::text as updated_at FROM users WHERE username = $1", + "SELECT id, name, email, password_hash, instance_role, must_change_password, created_at::text as created_at, updated_at::text as updated_at FROM users WHERE email = $1", ) - .bind(username) + .bind(email) .fetch_optional(&self.pool) .await?; @@ -34,7 +34,7 @@ impl UserRepository for PostgresUserRepository { async fn find_by_id(&self, id: &str) -> Result, RepositoryError> { debug!("Finding user by id"); let result = sqlx::query_as::<_, User>( - "SELECT id, username, email, password_hash, instance_role, must_change_password, created_at::text as created_at, updated_at::text as updated_at FROM users WHERE id = $1", + "SELECT id, name, email, password_hash, instance_role, must_change_password, created_at::text as created_at, updated_at::text as updated_at FROM users WHERE id = $1", ) .bind(id) .fetch_optional(&self.pool) @@ -46,23 +46,14 @@ impl UserRepository for PostgresUserRepository { async fn list(&self) -> Result, RepositoryError> { Ok(sqlx::query_as::<_, User>( - "SELECT id, username, email, password_hash, instance_role, must_change_password, created_at::text as created_at, updated_at::text as updated_at FROM users ORDER BY created_at, username" + "SELECT id, name, email, password_hash, instance_role, must_change_password, created_at::text as created_at, updated_at::text as updated_at FROM users ORDER BY created_at, name" ).fetch_all(&self.pool).await?) } - async fn find_id_by_username(&self, username: &str) -> Result, RepositoryError> { - let result: Option<(String,)> = sqlx::query_as("SELECT id FROM users WHERE username = $1") - .bind(username) - .fetch_optional(&self.pool) - .await?; - - Ok(result.map(|(id,)| id)) - } - - async fn create(&self, id: &str, username: &str, email: &str, password_hash: &str) -> Result<(), RepositoryError> { - sqlx::query("INSERT INTO users (id, username, email, password_hash) VALUES ($1, $2, $3, $4)") + async fn create(&self, id: &str, name: &str, email: &str, password_hash: &str) -> Result<(), RepositoryError> { + sqlx::query("INSERT INTO users (id, name, email, password_hash) VALUES ($1, $2, $3, $4)") .bind(id) - .bind(username) + .bind(name) .bind(email) .bind(password_hash) .execute(&self.pool) @@ -71,9 +62,9 @@ impl UserRepository for PostgresUserRepository { Ok(()) } - async fn exists(&self, username: &str) -> Result { - let result: Option<(String,)> = sqlx::query_as("SELECT id FROM users WHERE username = $1") - .bind(username) + async fn exists(&self, email: &str) -> Result { + let result: Option<(String,)> = sqlx::query_as("SELECT id FROM users WHERE email = $1") + .bind(email) .fetch_optional(&self.pool) .await?; @@ -130,4 +121,24 @@ impl UserRepository for PostgresUserRepository { .await? .rows_affected()) } + + async fn update_profile(&self, user_id: &str, name: &str, email: &str) -> Result { + Ok( + sqlx::query("UPDATE users SET name = $1, email = $2, updated_at = NOW() WHERE id = $3") + .bind(name) + .bind(email) + .bind(user_id) + .execute(&self.pool) + .await? + .rows_affected(), + ) + } + + async fn delete(&self, user_id: &str) -> Result { + Ok(sqlx::query("DELETE FROM users WHERE id = $1") + .bind(user_id) + .execute(&self.pool) + .await? + .rows_affected()) + } } diff --git a/apps/backend/src/repository/sqlite/site.rs b/apps/backend/src/repository/sqlite/site.rs index 0617d681..cfd22b47 100644 --- a/apps/backend/src/repository/sqlite/site.rs +++ b/apps/backend/src/repository/sqlite/site.rs @@ -94,11 +94,11 @@ impl SiteRepository for SqliteSiteRepository { async fn list_members(&self, site_id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, SiteMember>( - "SELECT sm.id, sm.site_id, sm.user_id, u.username, u.email, sm.role, sm.created_at + "SELECT sm.id, sm.site_id, sm.user_id, u.name, u.email, sm.role, sm.created_at FROM site_members sm JOIN users u ON sm.user_id = u.id WHERE sm.site_id = ? - ORDER BY sm.role DESC, u.username", + ORDER BY sm.role DESC, u.name, u.id", ) .bind(site_id) .fetch_all(&self.pool) @@ -123,7 +123,7 @@ impl SiteRepository for SqliteSiteRepository { .await?; let result = sqlx::query_as::<_, SiteMember>( - "SELECT sm.id, sm.site_id, sm.user_id, u.username, u.email, sm.role, sm.created_at + "SELECT sm.id, sm.site_id, sm.user_id, u.name, u.email, sm.role, sm.created_at FROM site_members sm JOIN users u ON sm.user_id = u.id WHERE sm.id = ?", ) .bind(id) @@ -151,7 +151,7 @@ impl SiteRepository for SqliteSiteRepository { } let member = sqlx::query_as::<_, SiteMember>( - "SELECT sm.id, sm.site_id, sm.user_id, u.username, u.email, sm.role, sm.created_at + "SELECT sm.id, sm.site_id, sm.user_id, u.name, u.email, sm.role, sm.created_at FROM site_members sm JOIN users u ON sm.user_id = u.id WHERE sm.site_id = ? AND sm.user_id = ?", ) diff --git a/apps/backend/src/repository/sqlite/user.rs b/apps/backend/src/repository/sqlite/user.rs index 305ec63c..c2a79a39 100644 --- a/apps/backend/src/repository/sqlite/user.rs +++ b/apps/backend/src/repository/sqlite/user.rs @@ -18,12 +18,12 @@ impl SqliteUserRepository { #[async_trait] impl UserRepository for SqliteUserRepository { - async fn find_by_username(&self, username: &str) -> Result, RepositoryError> { - debug!("Finding user by username"); + async fn find_by_email(&self, email: &str) -> Result, RepositoryError> { + debug!("Finding user by email"); let result = sqlx::query_as::<_, User>( - "SELECT id, username, email, password_hash, instance_role, must_change_password, created_at, updated_at FROM users WHERE username = ?", + "SELECT id, name, email, password_hash, instance_role, must_change_password, created_at, updated_at FROM users WHERE email = ?", ) - .bind(username) + .bind(email) .fetch_optional(&self.pool) .await?; @@ -34,7 +34,7 @@ impl UserRepository for SqliteUserRepository { async fn find_by_id(&self, id: &str) -> Result, RepositoryError> { debug!("Finding user by id"); let result = sqlx::query_as::<_, User>( - "SELECT id, username, email, password_hash, instance_role, must_change_password, created_at, updated_at FROM users WHERE id = ?", + "SELECT id, name, email, password_hash, instance_role, must_change_password, created_at, updated_at FROM users WHERE id = ?", ) .bind(id) .fetch_optional(&self.pool) @@ -46,25 +46,16 @@ impl UserRepository for SqliteUserRepository { async fn list(&self) -> Result, RepositoryError> { Ok(sqlx::query_as::<_, User>( - "SELECT id, username, email, password_hash, instance_role, must_change_password, created_at, updated_at FROM users ORDER BY created_at, username" + "SELECT id, name, email, password_hash, instance_role, must_change_password, created_at, updated_at FROM users ORDER BY created_at, name" ).fetch_all(&self.pool).await?) } - async fn find_id_by_username(&self, username: &str) -> Result, RepositoryError> { - let result: Option<(String,)> = sqlx::query_as("SELECT id FROM users WHERE username = ?") - .bind(username) - .fetch_optional(&self.pool) - .await?; - - Ok(result.map(|(id,)| id)) - } - - async fn create(&self, id: &str, username: &str, email: &str, password_hash: &str) -> Result<(), RepositoryError> { + async fn create(&self, id: &str, name: &str, email: &str, password_hash: &str) -> Result<(), RepositoryError> { debug!("Creating user"); - match sqlx::query("INSERT INTO users (id, username, email, password_hash) VALUES (?, ?, ?, ?)") + match sqlx::query("INSERT INTO users (id, name, email, password_hash) VALUES (?, ?, ?, ?)") .bind(id) - .bind(username) + .bind(name) .bind(email) .bind(password_hash) .execute(&self.pool) @@ -81,9 +72,9 @@ impl UserRepository for SqliteUserRepository { } } - async fn exists(&self, username: &str) -> Result { - let result: Option<(String,)> = sqlx::query_as("SELECT id FROM users WHERE username = ?") - .bind(username) + async fn exists(&self, email: &str) -> Result { + let result: Option<(String,)> = sqlx::query_as("SELECT id FROM users WHERE email = ?") + .bind(email) .fetch_optional(&self.pool) .await?; @@ -140,4 +131,24 @@ impl UserRepository for SqliteUserRepository { .await? .rows_affected()) } + + async fn update_profile(&self, user_id: &str, name: &str, email: &str) -> Result { + Ok( + sqlx::query("UPDATE users SET name = ?, email = ?, updated_at = datetime('now') WHERE id = ?") + .bind(name) + .bind(email) + .bind(user_id) + .execute(&self.pool) + .await? + .rows_affected(), + ) + } + + async fn delete(&self, user_id: &str) -> Result { + Ok(sqlx::query("DELETE FROM users WHERE id = ?") + .bind(user_id) + .execute(&self.pool) + .await? + .rows_affected()) + } } diff --git a/apps/backend/src/repository/traits.rs b/apps/backend/src/repository/traits.rs index 51cdc909..f0aa425f 100644 --- a/apps/backend/src/repository/traits.rs +++ b/apps/backend/src/repository/traits.rs @@ -15,12 +15,11 @@ use crate::repository::error::RepositoryError; #[async_trait] pub trait UserRepository: Send + Sync { - async fn find_by_username(&self, username: &str) -> Result, RepositoryError>; + async fn find_by_email(&self, email: &str) -> Result, RepositoryError>; async fn find_by_id(&self, id: &str) -> Result, RepositoryError>; async fn list(&self) -> Result, RepositoryError>; - async fn find_id_by_username(&self, username: &str) -> Result, RepositoryError>; - async fn create(&self, id: &str, username: &str, email: &str, password_hash: &str) -> Result<(), RepositoryError>; - async fn exists(&self, username: &str) -> Result; + async fn create(&self, id: &str, name: &str, email: &str, password_hash: &str) -> Result<(), RepositoryError>; + async fn exists(&self, email: &str) -> Result; async fn get_role(&self, user_id: &str, site_id: &str) -> Result, RepositoryError>; async fn count(&self) -> Result; async fn count_instance_owners(&self) -> Result; @@ -31,6 +30,8 @@ pub trait UserRepository: Send + Sync { password_hash: &str, must_change: bool, ) -> Result; + async fn update_profile(&self, user_id: &str, name: &str, email: &str) -> Result; + async fn delete(&self, user_id: &str) -> Result; } #[async_trait] diff --git a/apps/backend/src/router/auth.rs b/apps/backend/src/router/auth.rs index b4e8f2fb..ba2c18c0 100644 --- a/apps/backend/src/router/auth.rs +++ b/apps/backend/src/router/auth.rs @@ -4,14 +4,16 @@ use axum::{ routing::{get, post}, }; -use crate::handlers::auth_handler::{change_password, list_sessions, login, logout, me, register, revoke_all_sessions}; +use crate::handlers::auth_handler::{ + change_password, list_sessions, login, logout, me, register, revoke_all_sessions, update_me, +}; use crate::middleware::dashboard_auth::dashboard_auth_middleware; use crate::middleware::rate_limit::rate_limit_middleware; pub fn auth_routes() -> Router { let protected = Router::new() .route("/api/auth/logout", post(logout)) - .route("/api/auth/me", get(me)) + .route("/api/auth/me", get(me).put(update_me)) .route("/api/auth/sessions", get(list_sessions)) .route("/api/auth/sessions/revoke-all", post(revoke_all_sessions)) .route("/api/auth/change-password", post(change_password)) diff --git a/apps/backend/src/router/dashboard.rs b/apps/backend/src/router/dashboard.rs index 92a07d4a..58739107 100644 --- a/apps/backend/src/router/dashboard.rs +++ b/apps/backend/src/router/dashboard.rs @@ -8,5 +8,12 @@ pub fn dashboard_routes() -> Router { "/dashboard", get(|| async { dashboard_handler(axum::extract::Path("".into())).await }), ) + // Bare `/dashboard/` (trailing slash) matches neither `/dashboard` nor the + // `{*file}` wildcard (which needs ≥1 segment); serve the SPA shell here too so a + // refresh on a client route URL ending in `/` still loads the app. + .route( + "/dashboard/", + get(|| async { dashboard_handler(axum::extract::Path("".into())).await }), + ) .route("/dashboard/{*file}", get(dashboard_handler)) } diff --git a/apps/backend/src/router/instance.rs b/apps/backend/src/router/instance.rs index 2c2d3378..1cb6f7f4 100644 --- a/apps/backend/src/router/instance.rs +++ b/apps/backend/src/router/instance.rs @@ -1,13 +1,18 @@ use axum::{ Router, - routing::{get, post, put}, + routing::{delete, get, post, put}, }; -use crate::handlers::instance_handler::{create_user, list_users, update_instance_role}; +use crate::handlers::instance_handler::{ + create_user, delete_user, list_users, set_user_password, update_instance_role, update_user, +}; pub fn routes() -> Router { Router::new() .route("/instance/users", get(list_users)) .route("/instance/users", post(create_user)) + .route("/instance/users/{user_id}", put(update_user)) + .route("/instance/users/{user_id}", delete(delete_user)) .route("/instance/users/{user_id}/role", put(update_instance_role)) + .route("/instance/users/{user_id}/password", post(set_user_password)) } diff --git a/apps/backend/src/router/mod.rs b/apps/backend/src/router/mod.rs index 1339f307..b119a163 100644 --- a/apps/backend/src/router/mod.rs +++ b/apps/backend/src/router/mod.rs @@ -52,7 +52,7 @@ fn public_api_v1_routes(max_upload_bytes: usize) -> Router { .layer(from_fn(authz_middleware)) // Middle — runs second (after api_auth sets Actor) .layer(from_fn(api_site_resolver)) - // Outer — runs first (validates Bearer cms_site_* token) + // Outer — runs first (validates Bearer vcms_site_* token) .layer(from_fn(api_auth_middleware)) } diff --git a/apps/backend/src/router/openapi.rs b/apps/backend/src/router/openapi.rs index 74427f20..df9fda4f 100644 --- a/apps/backend/src/router/openapi.rs +++ b/apps/backend/src/router/openapi.rs @@ -8,11 +8,11 @@ use crate::models::site::Site; #[derive(OpenApi)] #[openapi( info( - title = "CMS API", - version = "1.0.0", - description = "Headless CMS unified API. Consumer access uses site-bound cms_site_* tokens with read or write permission. Dashboard access uses revocable opaque sessions.", - contact(name = "CMS", url = "https://cms.velopulent.com"), - license(name = "MIT") + title = "Velopulent CMS REST API", + version = "0.1.0", + description = "Headless CMS unified API. Consumer access uses site-bound vcms_site_* tokens with read or write permission. Dashboard access uses revocable opaque sessions.", + contact(name = "Velopulent CMS", url = "https://cms.velopulent.com"), + license(name = "AGPL-3.0", url = "https://github.com/velopulent/cms/blob/main/LICENSE"), ), paths( // Public API: Site info @@ -94,7 +94,7 @@ impl utoipa::Modify for SecurityAddon { utoipa::openapi::security::SecurityScheme::Http( utoipa::openapi::security::HttpBuilder::new() .scheme(utoipa::openapi::security::HttpAuthScheme::Bearer) - .bearer_format("Access Token (cms_site_...)") + .bearer_format("Access Token (vcms_site_...)") .build(), ), ); diff --git a/apps/backend/src/secrets.rs b/apps/backend/src/secrets.rs index 8f3487e9..e3800a3b 100644 --- a/apps/backend/src/secrets.rs +++ b/apps/backend/src/secrets.rs @@ -1,14 +1,14 @@ -//! Persisted instance secrets (`~/.cms/secrets.toml`). +//! Persisted instance secrets (`~/.vcms/secrets.toml`). //! //! On first `serve`/`admin`, a random `HMAC_SECRET` value is //! generated and written to `secrets.toml` (perms `0600` on unix). Every later -//! process — including a `cms mcp stdio` child launched from an unknown working +//! process — including a `vcms mcp stdio` child launched from an unknown working //! directory — reads the *same* values, so site-token verification matches the //! server that signed the token. Environment variables still override the file. //! //! These secrets intentionally live in a dedicated, restricted file rather than //! `config.toml`: the TOML config is for non-secret settings, while this file is -//! machine-managed and never scaffolded by `cms config init`. +//! machine-managed and never scaffolded by `vcms config init`. use rand::Rng; use serde::{Deserialize, Serialize}; diff --git a/apps/backend/src/services/access_token.rs b/apps/backend/src/services/access_token.rs index 6a6f0530..c521dad1 100644 --- a/apps/backend/src/services/access_token.rs +++ b/apps/backend/src/services/access_token.rs @@ -11,7 +11,7 @@ use crate::middleware::auth::compute_key_hmac; use crate::models::access_token::{AccessToken, AccessTokenPermission, AccessTokenResponse}; use crate::repository::traits::{AccessTokenRepository, NewAccessToken}; -const SITE_TOKEN_PREFIX: &str = "cms_site_"; +const SITE_TOKEN_PREFIX: &str = "vcms_site_"; #[derive(Clone)] pub struct AccessTokenService { diff --git a/apps/backend/src/services/auth.rs b/apps/backend/src/services/auth.rs index 79966a30..66946a5a 100644 --- a/apps/backend/src/services/auth.rs +++ b/apps/backend/src/services/auth.rs @@ -72,13 +72,10 @@ impl AuthError { pub fn into_response(self) -> Response { let (status, body) = match self { AuthError::ValidationError(msg) => (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))), - AuthError::UserExists => ( - StatusCode::CONFLICT, - Json(json!({"error": "Username or email already exists"})), - ), + AuthError::UserExists => (StatusCode::CONFLICT, Json(json!({"error": "Email already exists"}))), AuthError::InvalidCredentials => ( StatusCode::UNAUTHORIZED, - Json(json!({"error": "Invalid username or password"})), + Json(json!({"error": "Invalid email or password"})), ), AuthError::RegistrationDisabled => ( StatusCode::FORBIDDEN, @@ -114,7 +111,7 @@ impl AuthService { } } - pub async fn register(&self, username: &str, email: &str, password: &str) -> Result { + pub async fn register(&self, name: &str, email: &str, password: &str) -> Result { let user_count = self .user_repo .count() @@ -123,22 +120,17 @@ impl AuthService { if user_count > 0 && !self.public_registration_enabled { return Err(AuthError::RegistrationDisabled); } - let username = username.trim(); + let name = name.trim(); let email = email.trim(); let password = password.trim(); debug!("Attempting to register user"); - if username.is_empty() { - warn!("Registration failed: username is empty"); - return Err(AuthError::ValidationError("Username is required".into())); - } - - if username.len() < 3 { - warn!("Registration failed: username too short (length={})", username.len()); - return Err(AuthError::ValidationError( - "Username must be at least 3 characters".into(), - )); + // `name` is now a human display name (e.g. "John Doe"): non-unique, spaces + // allowed. The login identity is the email, validated below. + if name.is_empty() { + warn!("Registration failed: name is empty"); + return Err(AuthError::ValidationError("Name is required".into())); } if password.is_empty() { @@ -163,7 +155,7 @@ impl AuthService { let id = Uuid::now_v7().to_string(); info!("Creating new user: id={}", id); - match self.user_repo.create(&id, username, email, &password_hash).await { + match self.user_repo.create(&id, name, email, &password_hash).await { Ok(_) => { let instance_role = if user_count == 0 { self.user_repo @@ -177,7 +169,7 @@ impl AuthService { info!("User registered successfully: id={}", id); Ok(UserPublic { id, - username: username.to_string(), + name: name.to_string(), email: email.to_string(), instance_role, must_change_password: false, @@ -193,21 +185,23 @@ impl AuthService { } } - pub async fn login(&self, username: &str, password: &str) -> Result<(UserPublic, String, String), AuthError> { - debug!("Attempting login for username={}", username); + pub async fn login(&self, email: &str, password: &str) -> Result<(UserPublic, String, String), AuthError> { + let email = email.trim(); + let password = password.trim(); + debug!("Attempting login for email={}", email); let user = self .user_repo - .find_by_username(username) + .find_by_email(email) .await .map_err(|e| AuthError::DatabaseError(e.to_string()))? .ok_or(AuthError::InvalidCredentials)?; - debug!("User found for username={}, verifying password", username); + debug!("User found for email={}, verifying password", email); match verify(password, &user.password_hash) { Ok(true) => { - info!("Login successful for user: id={}, username={}", user.id, user.username); + info!("Login successful for user: id={}, name={}", user.id, user.name); let token = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); let csrf_token = Uuid::new_v4().to_string(); let expires_at = @@ -225,7 +219,7 @@ impl AuthService { Ok(( UserPublic { id: user.id, - username: user.username, + name: user.name, email: user.email, instance_role: user.instance_role, must_change_password: user.must_change_password, @@ -235,7 +229,7 @@ impl AuthService { )) } _ => { - warn!("Login failed: invalid credentials for username={}", username); + warn!("Login failed: invalid credentials for email={}", email); Err(AuthError::InvalidCredentials) } } @@ -245,10 +239,10 @@ impl AuthService { debug!("Fetching user by id: {}", user_id); match self.user_repo.find_by_id(user_id).await { Ok(Some(user)) => { - debug!("User found: id={}, username={}", user.id, user.username); + debug!("User found: id={}, name={}", user.id, user.name); Ok(Some(UserPublic { id: user.id, - username: user.username, + name: user.name, email: user.email, instance_role: user.instance_role, must_change_password: user.must_change_password, @@ -275,7 +269,7 @@ impl AuthService { .into_iter() .map(|user| UserPublic { id: user.id, - username: user.username, + name: user.name, email: user.email, instance_role: user.instance_role, must_change_password: user.must_change_password, @@ -285,32 +279,36 @@ impl AuthService { pub async fn create_managed_user( &self, - username: &str, + name: &str, email: &str, temporary_password: &str, instance_role: Option<&str>, ) -> Result { + let temporary_password = temporary_password.trim(); if temporary_password.len() < 8 { return Err(AuthError::ValidationError( "Temporary password must be at least 8 characters".into(), )); } - let username = username.trim(); + let name = name.trim(); let email = email.trim(); - if username.len() < 3 || !EMAIL_RE.is_match(email) { - return Err(AuthError::ValidationError("Invalid username or email".into())); + // `name` is a display name (non-unique, spaces allowed); only the email is the + // login identity and must be valid + unique. + if name.is_empty() || !EMAIL_RE.is_match(email) { + return Err(AuthError::ValidationError("Invalid name or email".into())); } + // Validate the role before any DB write so a bad role never leaves an orphan user. + let instance_role = normalize_instance_role(instance_role)?; let id = Uuid::now_v7().to_string(); let password_hash = hash(temporary_password, self.bcrypt_cost).map_err(|e| AuthError::HashError(e.to_string()))?; self.user_repo - .create(&id, username, email, &password_hash) + .create(&id, name, email, &password_hash) .await .map_err(|error| match error { RepositoryError::UniqueViolation(_) => AuthError::UserExists, other => AuthError::DatabaseError(other.to_string()), })?; - let instance_role = normalize_instance_role(instance_role)?; self.user_repo .set_instance_role(&id, instance_role) .await @@ -321,7 +319,7 @@ impl AuthService { .map_err(|e| AuthError::DatabaseError(e.to_string()))?; Ok(UserPublic { id, - username: username.to_string(), + name: name.to_string(), email: email.to_string(), instance_role: instance_role.map(ToString::to_string), must_change_password: true, @@ -357,6 +355,101 @@ impl AuthService { Ok(()) } + /// Operator-driven update of another user's display name and email. + pub async fn update_user_profile(&self, user_id: &str, name: &str, email: &str) -> Result<(), AuthError> { + let name = name.trim(); + let email = email.trim(); + if name.is_empty() || !EMAIL_RE.is_match(email) { + return Err(AuthError::ValidationError("Invalid name or email".into())); + } + self.user_repo + .find_by_id(user_id) + .await + .map_err(|e| AuthError::DatabaseError(e.to_string()))? + .ok_or(AuthError::NotFound)?; + self.user_repo + .update_profile(user_id, name, email) + .await + .map_err(|error| match error { + RepositoryError::UniqueViolation(_) => AuthError::UserExists, + other => AuthError::DatabaseError(other.to_string()), + })?; + Ok(()) + } + + /// Self-service update of the signed-in user's own display name. + pub async fn update_self_name(&self, user_id: &str, name: &str) -> Result { + let name = name.trim(); + if name.is_empty() { + return Err(AuthError::ValidationError("Name is required".into())); + } + let user = self + .user_repo + .find_by_id(user_id) + .await + .map_err(|e| AuthError::DatabaseError(e.to_string()))? + .ok_or(AuthError::NotFound)?; + self.user_repo + .update_profile(user_id, name, &user.email) + .await + .map_err(|e| AuthError::DatabaseError(e.to_string()))?; + Ok(UserPublic { + id: user.id, + name: name.to_string(), + email: user.email, + instance_role: user.instance_role, + must_change_password: user.must_change_password, + }) + } + + /// Operator-driven password reset; forces a change on the user's next login. + pub async fn admin_set_password(&self, user_id: &str, new_password: &str) -> Result<(), AuthError> { + let new_password = new_password.trim(); + if new_password.len() < 8 { + return Err(AuthError::ValidationError( + "Password must be at least 8 characters".into(), + )); + } + self.user_repo + .find_by_id(user_id) + .await + .map_err(|e| AuthError::DatabaseError(e.to_string()))? + .ok_or(AuthError::NotFound)?; + let password_hash = hash(new_password, self.bcrypt_cost).map_err(|e| AuthError::HashError(e.to_string()))?; + self.user_repo + .update_password(user_id, &password_hash, true) + .await + .map_err(|e| AuthError::DatabaseError(e.to_string()))?; + Ok(()) + } + + /// Delete a user; the last instance owner cannot be removed. + pub async fn delete_user(&self, user_id: &str) -> Result<(), AuthError> { + let user = self + .user_repo + .find_by_id(user_id) + .await + .map_err(|e| AuthError::DatabaseError(e.to_string()))? + .ok_or(AuthError::NotFound)?; + if user.instance_role.as_deref() == Some("instance_owner") + && self + .user_repo + .count_instance_owners() + .await + .map_err(|e| AuthError::DatabaseError(e.to_string()))? + <= 1 + { + return Err(AuthError::ValidationError( + "At least one instance owner is required".into(), + )); + } + self.user_repo + .delete(user_id) + .await + .map_err(|e| AuthError::DatabaseError(e.to_string()))?; + Ok(()) + } + pub fn cookie_secure(&self) -> bool { self.cookie_secure } @@ -464,6 +557,8 @@ impl AuthService { current_password: &str, new_password: &str, ) -> Result<(), AuthError> { + let current_password = current_password.trim(); + let new_password = new_password.trim(); if new_password.len() < 8 { return Err(AuthError::ValidationError( "Password must be at least 8 characters".into(), @@ -500,7 +595,7 @@ mod tests { fn create_test_user() -> User { User { id: "user-123".to_string(), - username: "testuser".to_string(), + name: "testuser".to_string(), email: "test@example.com".to_string(), password_hash: bcrypt::hash("password123", bcrypt::DEFAULT_COST).unwrap(), instance_role: None, @@ -534,7 +629,7 @@ mod tests { let result = auth.register("newuser", "new@example.com", "password123").await; assert!(result.is_ok()); let user = result.unwrap(); - assert_eq!(user.username, "newuser"); + assert_eq!(user.name, "newuser"); assert_eq!(user.email, "new@example.com"); assert!(!user.id.is_empty()); } @@ -549,26 +644,27 @@ mod tests { .await; assert!(result.is_ok()); let user = result.unwrap(); - assert_eq!(user.username, "newuser"); + assert_eq!(user.name, "newuser"); assert_eq!(user.email, "new@example.com"); } #[tokio::test] - async fn test_register_empty_username() { + async fn test_register_empty_name() { let user_repo = test_user_repo(); let auth = test_auth(user_repo, false); let result = auth.register("", "test@example.com", "password123").await; - assert!(matches!(result, Err(AuthError::ValidationError(msg)) if msg.contains("Username is required"))); + assert!(matches!(result, Err(AuthError::ValidationError(msg)) if msg.contains("Name is required"))); } #[tokio::test] - async fn test_register_username_too_short() { + async fn test_register_short_display_name_allowed() { + // `name` is a display name now: short, non-unique values are fine. let user_repo = test_user_repo(); let auth = test_auth(user_repo, false); let result = auth.register("ab", "test@example.com", "password123").await; - assert!(matches!(result, Err(AuthError::ValidationError(msg)) if msg.contains("at least 3 characters"))); + assert!(result.is_ok()); } #[tokio::test] @@ -626,12 +722,13 @@ mod tests { } #[tokio::test] - async fn test_register_duplicate_username() { + async fn test_register_duplicate_email() { + // Email is the unique login identity; the display name may collide. let user_repo = test_user_repo(); user_repo.add_user(create_test_user()); let auth = test_auth(user_repo, false); - let result = auth.register("testuser", "other@example.com", "password123").await; + let result = auth.register("Another Name", "test@example.com", "password123").await; assert!(matches!(result, Err(AuthError::UserExists))); } @@ -641,10 +738,10 @@ mod tests { user_repo.add_user(create_test_user()); let auth = test_auth(user_repo, false); - let result = auth.login("testuser", "password123").await; + let result = auth.login("test@example.com", "password123").await; assert!(result.is_ok()); let (user, token, csrf_token) = result.unwrap(); - assert_eq!(user.username, "testuser"); + assert_eq!(user.name, "testuser"); assert!(!token.is_empty()); assert!(!csrf_token.is_empty()); } @@ -655,7 +752,7 @@ mod tests { user_repo.add_user(create_test_user()); let auth = test_auth(user_repo, false); - let result = auth.login("testuser", "wrongpassword").await; + let result = auth.login("test@example.com", "wrongpassword").await; assert!(matches!(result, Err(AuthError::InvalidCredentials))); } @@ -664,7 +761,7 @@ mod tests { let user_repo = test_user_repo(); let auth = test_auth(user_repo, false); - let result = auth.login("nonexistent", "password123").await; + let result = auth.login("nonexistent@example.com", "password123").await; assert!(matches!(result, Err(AuthError::InvalidCredentials))); } @@ -678,7 +775,7 @@ mod tests { assert!(result.is_ok()); let user = result.unwrap(); assert!(user.is_some()); - assert_eq!(user.unwrap().username, "testuser"); + assert_eq!(user.unwrap().name, "testuser"); } #[tokio::test] @@ -740,7 +837,7 @@ mod tests { let user = UserPublic { id: "user-123".to_string(), - username: "testuser".to_string(), + name: "testuser".to_string(), email: "test@example.com".to_string(), instance_role: None, must_change_password: false, @@ -768,7 +865,7 @@ mod tests { let user = UserPublic { id: "user-123".to_string(), - username: "testuser".to_string(), + name: "testuser".to_string(), email: "test@example.com".to_string(), instance_role: None, must_change_password: false, @@ -792,7 +889,7 @@ mod tests { let result3 = auth.register("user3", "user3@example.com", "password123").await; assert!(result3.is_ok()); - let user1 = auth.login("user1", "password123").await; + let user1 = auth.login("user1@example.com", "password123").await; assert!(user1.is_ok()); } } diff --git a/apps/backend/src/services/backup/meta.rs b/apps/backend/src/services/backup/meta.rs index b71088e4..4ebb5f10 100644 --- a/apps/backend/src/services/backup/meta.rs +++ b/apps/backend/src/services/backup/meta.rs @@ -206,6 +206,46 @@ pub async fn mark_failed(pool: &DbPool, id: &str, error: &str, now: &str) -> Res Ok(()) } +/// Fail backups/restore jobs left mid-flight by a previous process. Any +/// `running`/`pending` row at startup is orphaned, since backups only ever run +/// in-process. Returns the number of backup rows reconciled (for logging). +pub async fn fail_orphaned(pool: &DbPool, now: &str) -> Result { + let backups_sql = q( + pool.backend(), + "UPDATE backups SET status = 'failed', error = 'interrupted: server stopped during backup', \ + completed_at = ? WHERE status IN ('running', 'pending')", + ); + let reconciled = match pool { + DbPool::Sqlite(p) => sqlx::query(sqlx::AssertSqlSafe(backups_sql.as_str())) + .bind(now.to_string()) + .execute(p) + .await + .map_err(dberr)? + .rows_affected(), + DbPool::Postgres(p) => sqlx::query(sqlx::AssertSqlSafe(backups_sql.as_str())) + .bind(now.to_string()) + .execute(p) + .await + .map_err(dberr)? + .rows_affected(), + DbPool::MySql(p) => sqlx::query(sqlx::AssertSqlSafe(backups_sql.as_str())) + .bind(now.to_string()) + .execute(p) + .await + .map_err(dberr)? + .rows_affected(), + }; + + exec!( + pool, + "UPDATE restore_jobs SET status = 'failed', error = 'interrupted: server stopped during restore', \ + completed_at = ? WHERE status IN ('running', 'pending')", + now.to_string(), + ); + + Ok(reconciled) +} + pub async fn get_backup(pool: &DbPool, id: &str) -> Result, BackupError> { let sql = format!("SELECT {BACKUP_COLS} FROM backups WHERE id = ?"); Ok(fetch_opt_as!(pool, BackupRow, &sql, id.to_string())) diff --git a/apps/backend/src/services/backup/schema.rs b/apps/backend/src/services/backup/schema.rs index 4ca20f49..d9061ff1 100644 --- a/apps/backend/src/services/backup/schema.rs +++ b/apps/backend/src/services/backup/schema.rs @@ -61,7 +61,7 @@ pub static TABLES: &[TableSpec] = &[ site_where: SiteWhere::InstanceOnly, columns: &[ col("id", Text), - col("username", Text), + col("name", Text), col("email", Text), col("password_hash", Text), col("instance_role", Text), diff --git a/apps/backend/src/services/mod.rs b/apps/backend/src/services/mod.rs index 3f380074..86908277 100644 --- a/apps/backend/src/services/mod.rs +++ b/apps/backend/src/services/mod.rs @@ -34,7 +34,7 @@ pub struct Services { /// Full-text search engine used for **reads** (ranked queries). `None` when /// search is disabled or the index couldn't be opened — callers then fall back /// to the SQL `LIKE` path. Read-write in the server process, read-only in - /// `cms mcp stdio`. + /// `vcms mcp stdio`. pub search: Option>, /// Durable queue used for **writes**: content changes enqueue here and the /// server's indexer applies them. Present whenever search is enabled, even in @@ -50,7 +50,7 @@ impl Services { Self::assemble(repository, pool, config, search) } - /// Build services for an auxiliary process (e.g. `cms mcp stdio`): opens the + /// Build services for an auxiliary process (e.g. `vcms mcp stdio`): opens the /// index **read-only** so it can search without contending for the writer lock. /// Its writes still enqueue for the server to index. pub fn new_read_only(repository: Arc, pool: &DbPool, config: &Config) -> Self { diff --git a/apps/backend/src/services/search/indexer.rs b/apps/backend/src/services/search/indexer.rs index 6c249198..5e2f9f52 100644 --- a/apps/backend/src/services/search/indexer.rs +++ b/apps/backend/src/services/search/indexer.rs @@ -4,7 +4,7 @@ //! [`SearchQueue`] — populated by content writes from *any* process — and applies //! the changes to the index. It wakes immediately on a local enqueue (via the //! queue's `Notify`) and also polls on an interval to pick up enqueues from other -//! processes (e.g. `cms mcp stdio`). +//! processes (e.g. `vcms mcp stdio`). use std::collections::HashSet; use std::sync::Arc; diff --git a/apps/backend/src/services/search/mod.rs b/apps/backend/src/services/search/mod.rs index ad6bc002..80cf35a3 100644 --- a/apps/backend/src/services/search/mod.rs +++ b/apps/backend/src/services/search/mod.rs @@ -13,7 +13,7 @@ //! //! - **Reading** needs no lock. Any process opens the index [read-only] //! ([`SearchService::open_read_only`]) and gets full ranked search — including a -//! separate `cms mcp stdio` process running alongside the server. +//! separate `vcms mcp stdio` process running alongside the server. //! - **Writing** goes through a durable database queue ([`queue`]) instead of the //! index directly. Any process enqueues on a content change; the one running //! server owns the writer ([`SearchService::open`]) and is the sole consumer @@ -84,7 +84,7 @@ pub struct SearchHits { /// Embedded full-text search engine for entries. /// /// Read-write when opened with [`open`](Self::open) (the running server), read-only -/// when opened with [`open_read_only`](Self::open_read_only) (e.g. `cms mcp stdio`). +/// when opened with [`open_read_only`](Self::open_read_only) (e.g. `vcms mcp stdio`). /// Read-only instances can [`search_entries`](Self::search_entries) but return /// [`SearchError::ReadOnly`] from any write/commit/rebuild call. pub struct SearchService { diff --git a/apps/backend/src/services/search/queue.rs b/apps/backend/src/services/search/queue.rs index 58f0f9b2..dab97821 100644 --- a/apps/backend/src/services/search/queue.rs +++ b/apps/backend/src/services/search/queue.rs @@ -4,7 +4,7 @@ //! Producers (any process that writes entry content) call [`SearchQueue::enqueue`] //! after a write. The single writer-owning server drains the queue via the //! [`indexer`](super::indexer). Because the queue lives in the database it works -//! across processes (e.g. a separate `cms mcp stdio`) and survives restarts. +//! across processes (e.g. a separate `vcms mcp stdio`) and survives restarts. use std::sync::Arc; diff --git a/apps/backend/src/services/site.rs b/apps/backend/src/services/site.rs index 3be55d23..4bc6bb4b 100644 --- a/apps/backend/src/services/site.rs +++ b/apps/backend/src/services/site.rs @@ -255,13 +255,13 @@ impl SiteService { pub async fn invite_member( &self, site_id: &str, - username: &str, + email: &str, role: &str, _actor_user_id: &str, ) -> Result { debug!( - "Inviting member to site: site_id={}, username={}, role={}", - site_id, username, role + "Inviting member to site: site_id={}, email={}, role={}", + site_id, email, role ); if !VALID_ROLES.contains(&role) { @@ -269,13 +269,13 @@ impl SiteService { return Err(SiteError::InvalidRole("Invalid role. Must be editor or viewer".into())); } - debug!("Looking up user by username: {}", username); + debug!("Looking up user by email: {}", email); let user = self .user_repo - .find_by_username(username) + .find_by_email(email) .await .map_err(|e| { - error!("Failed to look up user by username={}: error={}", username, e); + error!("Failed to look up user by email={}: error={}", email, e); SiteError::DatabaseError(e.to_string()) })? .ok_or(SiteError::UserNotFound)?; @@ -283,10 +283,7 @@ impl SiteService { // Instance operators (owner/admin) already have full access to every site; // they must not be added as editors/viewers. if user.instance_role.is_some() { - warn!( - "Invite member rejected: user is an instance operator, username={}", - username - ); + warn!("Invite member rejected: user is an instance operator, email={}", email); return Err(SiteError::CannotInviteOperator); } @@ -625,7 +622,7 @@ mod tests { let service = SiteService::new(site_repo, user_repo); let result = service - .invite_member("site-123", "username", "invalid_role", "user-123") + .invite_member("site-123", "name", "invalid_role", "user-123") .await; assert!(matches!(result, Err(SiteError::InvalidRole(msg)) if msg.contains("editor or viewer"))); } @@ -649,7 +646,7 @@ mod tests { user_repo.add_user(crate::models::user::User { id: "user-456".to_string(), - username: "newuser".to_string(), + name: "newuser".to_string(), email: "new@example.com".to_string(), password_hash: "hash".to_string(), instance_role: None, @@ -660,7 +657,9 @@ mod tests { let service = SiteService::new(site_repo, user_repo.clone()); - let result = service.invite_member("site-123", "newuser", "editor", "user-123").await; + let result = service + .invite_member("site-123", "new@example.com", "editor", "user-123") + .await; assert!(result.is_ok()); let member = result.unwrap(); assert_eq!(member.role, "editor"); @@ -674,7 +673,7 @@ mod tests { for role in ["editor", "viewer"] { user_repo.add_user(crate::models::user::User { id: format!("user-{}", role), - username: format!("user_{}", role), + name: format!("user_{}", role), email: format!("{}@example.com", role), password_hash: "hash".to_string(), instance_role: None, @@ -688,7 +687,7 @@ mod tests { for role in ["editor", "viewer"] { let result = service - .invite_member("site-123", &format!("user_{}", role), role, "user-123") + .invite_member("site-123", &format!("{}@example.com", role), role, "user-123") .await; assert!(result.is_ok(), "Failed for role: {}", role); } diff --git a/apps/backend/src/test_helpers.rs b/apps/backend/src/test_helpers.rs index a230808f..983c3294 100644 --- a/apps/backend/src/test_helpers.rs +++ b/apps/backend/src/test_helpers.rs @@ -21,7 +21,7 @@ pub fn now_timestamp() -> String { chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string() } -/// Synchronize tests that mutate `CMS_HOME` (process-global env var). +/// Synchronize tests that mutate `VCMS_HOME` (process-global env var). /// The single static `Mutex` here is shared by all callers across modules, /// preventing races between `paths::tests` and `secrets::tests` etc. pub fn with_home(value: &Path, f: impl FnOnce() -> T) -> T { @@ -41,7 +41,7 @@ pub fn with_home(value: &Path, f: impl FnOnce() -> T) -> T { #[derive(Clone)] pub struct InMemoryUserRepository { users: Arc>>, - by_username: Arc>>, + by_name: Arc>>, by_id: Arc>>, } @@ -49,17 +49,17 @@ impl InMemoryUserRepository { pub fn new() -> Self { Self { users: Arc::new(Mutex::new(Vec::new())), - by_username: Arc::new(Mutex::new(std::collections::HashMap::new())), + by_name: Arc::new(Mutex::new(std::collections::HashMap::new())), by_id: Arc::new(Mutex::new(std::collections::HashMap::new())), } } pub fn add_user(&self, user: User) { let mut users = self.users.lock().unwrap(); - let mut by_username = self.by_username.lock().unwrap(); + let mut by_name = self.by_name.lock().unwrap(); let mut by_id = self.by_id.lock().unwrap(); - by_username.insert(user.username.clone(), user.id.clone()); + by_name.insert(user.name.clone(), user.id.clone()); by_id.insert(user.id.clone(), user.id.clone()); users.push(user); } @@ -78,9 +78,9 @@ impl Default for InMemoryUserRepository { #[async_trait] impl UserRepository for InMemoryUserRepository { - async fn find_by_username(&self, username: &str) -> Result, RepositoryError> { + async fn find_by_email(&self, email: &str) -> Result, RepositoryError> { let users = self.users.lock().unwrap(); - Ok(users.iter().find(|u| u.username == username).cloned()) + Ok(users.iter().find(|u| u.email == email).cloned()) } async fn find_by_id(&self, id: &str) -> Result, RepositoryError> { @@ -92,23 +92,18 @@ impl UserRepository for InMemoryUserRepository { Ok(self.users.lock().unwrap().clone()) } - async fn find_id_by_username(&self, username: &str) -> Result, RepositoryError> { - let users = self.users.lock().unwrap(); - Ok(users.iter().find(|u| u.username == username).map(|u| u.id.clone())) - } - - async fn create(&self, id: &str, username: &str, email: &str, password_hash: &str) -> Result<(), RepositoryError> { + async fn create(&self, id: &str, name: &str, email: &str, password_hash: &str) -> Result<(), RepositoryError> { let mut users = self.users.lock().unwrap(); - let mut by_username = self.by_username.lock().unwrap(); + let mut by_name = self.by_name.lock().unwrap(); let mut by_id = self.by_id.lock().unwrap(); - if users.iter().any(|u| u.username == username) { - return Err(RepositoryError::UniqueViolation("username".into())); + if users.iter().any(|u| u.email == email) { + return Err(RepositoryError::UniqueViolation("email".into())); } let user = User { id: id.to_string(), - username: username.to_string(), + name: name.to_string(), email: email.to_string(), password_hash: password_hash.to_string(), instance_role: None, @@ -117,15 +112,15 @@ impl UserRepository for InMemoryUserRepository { updated_at: now_timestamp(), }; - by_username.insert(username.to_string(), id.to_string()); + by_name.insert(name.to_string(), id.to_string()); by_id.insert(id.to_string(), id.to_string()); users.push(user); Ok(()) } - async fn exists(&self, username: &str) -> Result { + async fn exists(&self, email: &str) -> Result { let users = self.users.lock().unwrap(); - Ok(users.iter().any(|u| u.username == username)) + Ok(users.iter().any(|u| u.email == email)) } async fn get_role(&self, _user_id: &str, _site_id: &str) -> Result, RepositoryError> { @@ -169,6 +164,26 @@ impl UserRepository for InMemoryUserRepository { } Ok(0) } + + async fn update_profile(&self, user_id: &str, name: &str, email: &str) -> Result { + let mut users = self.users.lock().unwrap(); + if users.iter().any(|u| u.email == email && u.id != user_id) { + return Err(RepositoryError::UniqueViolation("email".into())); + } + if let Some(user) = users.iter_mut().find(|user| user.id == user_id) { + user.name = name.to_string(); + user.email = email.to_string(); + return Ok(1); + } + Ok(0) + } + + async fn delete(&self, user_id: &str) -> Result { + let mut users = self.users.lock().unwrap(); + let before = users.len(); + users.retain(|user| user.id != user_id); + Ok((before - users.len()) as u64) + } } #[derive(Clone, Default)] @@ -378,7 +393,7 @@ impl SiteRepository for InMemorySiteRepository { id: id.to_string(), site_id: site_id.to_string(), user_id: user_id.to_string(), - username: format!("user_{}", user_id), + name: format!("user_{}", user_id), email: format!("{}@example.com", user_id), role: role.to_string(), created_at: now_timestamp(), diff --git a/apps/backend/tests/common/fixtures.rs b/apps/backend/tests/common/fixtures.rs index 7715558e..42abfaf4 100644 --- a/apps/backend/tests/common/fixtures.rs +++ b/apps/backend/tests/common/fixtures.rs @@ -10,7 +10,7 @@ use super::server::TestServer; /// Returns `(token, csrf, site_id)` — the common starting point for dashboard tests. pub async fn setup(server: &TestServer) -> (String, String, String) { let client = http_client(); - let resp = server.login_user(&client, "admin", "admin").await; + let resp = server.login_user(&client, "admin@cms.local", "admin").await; let (token, csrf) = extract_cookies(&resp); let resp = client @@ -30,7 +30,7 @@ pub async fn setup(server: &TestServer) -> (String, String, String) { /// `permission` (`"read"` or `"write"`). Returns `(site_id, token)`. pub async fn create_site_and_token(server: &TestServer, permission: &str) -> (String, String) { let client = http_client(); - let resp = server.login_user(&client, "admin", "admin").await; + let resp = server.login_user(&client, "admin@cms.local", "admin").await; let (token, csrf) = extract_cookies(&resp); let resp = client diff --git a/apps/backend/tests/common/grpc.rs b/apps/backend/tests/common/grpc.rs index 7df1239e..a50f3446 100644 --- a/apps/backend/tests/common/grpc.rs +++ b/apps/backend/tests/common/grpc.rs @@ -198,7 +198,7 @@ impl GrpcTestContext { let resp = client .post(format!("{}/api/auth/login", self.rest_base_url)) .json(&serde_json::json!({ - "username": "admin", + "email": "admin@cms.local", "password": "admin", })) .send() @@ -246,7 +246,7 @@ impl GrpcTestContext { let resp = client .post(format!("{}/api/auth/login", self.rest_base_url)) .json(&serde_json::json!({ - "username": "admin", + "email": "admin@cms.local", "password": "admin", })) .send() diff --git a/apps/backend/tests/common/server.rs b/apps/backend/tests/common/server.rs index f47851db..7f5803fa 100644 --- a/apps/backend/tests/common/server.rs +++ b/apps/backend/tests/common/server.rs @@ -156,11 +156,11 @@ impl TestServer { } } - pub async fn login_user(&self, client: &reqwest::Client, username: &str, password: &str) -> reqwest::Response { + pub async fn login_user(&self, client: &reqwest::Client, email: &str, password: &str) -> reqwest::Response { client .post(format!("{}/api/auth/login", self.base_url)) .json(&serde_json::json!({ - "username": username, + "email": email, "password": password, })) .send() @@ -170,7 +170,7 @@ impl TestServer { } pub(crate) async fn seed_admin(repository: &Repository) { - if !repository.user.exists("admin").await.unwrap_or(false) { + if !repository.user.exists("admin@cms.local").await.unwrap_or(false) { let id = uuid::Uuid::now_v7().to_string(); let password_hash = bcrypt::hash("admin", bcrypt::DEFAULT_COST).expect("Failed to hash password"); repository diff --git a/apps/backend/tests/graphql/auth_tests.rs b/apps/backend/tests/graphql/auth_tests.rs index 1d537b97..5e3b95bd 100644 --- a/apps/backend/tests/graphql/auth_tests.rs +++ b/apps/backend/tests/graphql/auth_tests.rs @@ -18,7 +18,7 @@ async fn gql(server: &TestServer, token: Option<&str>, query: &str) -> reqwest:: async fn setup_site_token(server: &TestServer) -> (reqwest::Client, String) { let client = reqwest::Client::builder().build().unwrap(); - let resp = server.login_user(&client, "admin", "admin").await; + let resp = server.login_user(&client, "admin@cms.local", "admin").await; let headers = resp.headers(); let mut token = String::new(); let mut csrf = String::new(); @@ -71,7 +71,7 @@ async fn setup_site_token(server: &TestServer) -> (reqwest::Client, String) { async fn setup_read_token(server: &TestServer) -> String { let client = reqwest::Client::builder().build().unwrap(); - let resp = server.login_user(&client, "admin", "admin").await; + let resp = server.login_user(&client, "admin@cms.local", "admin").await; let headers = resp.headers(); let mut token = String::new(); let mut csrf = String::new(); @@ -159,7 +159,7 @@ async fn test_wrong_site_token() { let server = TestServer::start().await; let client = reqwest::Client::builder().build().unwrap(); - let resp = server.login_user(&client, "admin", "admin").await; + let resp = server.login_user(&client, "admin@cms.local", "admin").await; let headers = resp.headers(); let mut token = String::new(); let mut csrf = String::new(); diff --git a/apps/backend/tests/graphql/collections_tests.rs b/apps/backend/tests/graphql/collections_tests.rs index 0caeedb4..0047c16d 100644 --- a/apps/backend/tests/graphql/collections_tests.rs +++ b/apps/backend/tests/graphql/collections_tests.rs @@ -5,7 +5,7 @@ use crate::common::TestServer; async fn setup(server: &TestServer) -> (String, String) { let client = reqwest::Client::builder().build().unwrap(); - let resp = server.login_user(&client, "admin", "admin").await; + let resp = server.login_user(&client, "admin@cms.local", "admin").await; let headers = resp.headers(); let mut token = String::new(); let mut csrf = String::new(); diff --git a/apps/backend/tests/graphql/entries_tests.rs b/apps/backend/tests/graphql/entries_tests.rs index a976c691..62f14579 100644 --- a/apps/backend/tests/graphql/entries_tests.rs +++ b/apps/backend/tests/graphql/entries_tests.rs @@ -5,7 +5,7 @@ use crate::common::TestServer; async fn setup(server: &TestServer) -> (String, String) { let client = reqwest::Client::builder().build().unwrap(); - let resp = server.login_user(&client, "admin", "admin").await; + let resp = server.login_user(&client, "admin@cms.local", "admin").await; let headers = resp.headers(); let mut token = String::new(); let mut csrf = String::new(); diff --git a/apps/backend/tests/graphql/files_tests.rs b/apps/backend/tests/graphql/files_tests.rs index 7b09b700..857571be 100644 --- a/apps/backend/tests/graphql/files_tests.rs +++ b/apps/backend/tests/graphql/files_tests.rs @@ -5,7 +5,7 @@ use crate::common::TestServer; async fn setup(server: &TestServer) -> (String, String) { let client = reqwest::Client::builder().build().unwrap(); - let resp = server.login_user(&client, "admin", "admin").await; + let resp = server.login_user(&client, "admin@cms.local", "admin").await; let headers = resp.headers(); let mut token = String::new(); let mut csrf = String::new(); diff --git a/apps/backend/tests/graphql/sites_tests.rs b/apps/backend/tests/graphql/sites_tests.rs index 9b900f07..c48efe8b 100644 --- a/apps/backend/tests/graphql/sites_tests.rs +++ b/apps/backend/tests/graphql/sites_tests.rs @@ -5,7 +5,7 @@ use crate::common::TestServer; async fn setup(server: &TestServer) -> (reqwest::Client, String, String) { let client = reqwest::Client::builder().build().unwrap(); - let resp = server.login_user(&client, "admin", "admin").await; + let resp = server.login_user(&client, "admin@cms.local", "admin").await; let headers = resp.headers(); let mut token = String::new(); let mut csrf = String::new(); diff --git a/apps/backend/tests/graphql/webhooks_tests.rs b/apps/backend/tests/graphql/webhooks_tests.rs index 0a80a89d..b9ce0ca4 100644 --- a/apps/backend/tests/graphql/webhooks_tests.rs +++ b/apps/backend/tests/graphql/webhooks_tests.rs @@ -7,7 +7,7 @@ use crate::common::TestServer; async fn setup(server: &TestServer) -> (String, String) { let client = reqwest::Client::builder().build().unwrap(); - let resp = server.login_user(&client, "admin", "admin").await; + let resp = server.login_user(&client, "admin@cms.local", "admin").await; let headers = resp.headers(); let mut token = String::new(); let mut csrf = String::new(); diff --git a/apps/backend/tests/mcp/protocol_tests.rs b/apps/backend/tests/mcp/protocol_tests.rs index 74b60375..adbf018e 100644 --- a/apps/backend/tests/mcp/protocol_tests.rs +++ b/apps/backend/tests/mcp/protocol_tests.rs @@ -190,7 +190,7 @@ async fn test_auth_wrong_token_type_returns_401() { ); let msg = error["message"].as_str().unwrap(); assert!( - msg.contains("MCP requires a CMS access token"), + msg.contains("MCP requires a vcms_site_* access token"), "Expected auth error message, got: {}", msg ); @@ -200,7 +200,7 @@ async fn test_auth_wrong_token_type_returns_401() { async fn test_auth_invalid_token_returns_401() { let server = start_mcp_server().await; - let resp = mcp_request(&server.base_url, "cms_site_invalid_token_abc123", "tools/list", None).await; + let resp = mcp_request(&server.base_url, "vcms_site_invalid_token_abc123", "tools/list", None).await; let error = resp.get("error"); assert!(error.is_some(), "Expected error for invalid token, got: {}", resp); @@ -228,7 +228,7 @@ async fn test_auth_instance_token_rejected() { assert!(error.is_some(), "Instance token should be rejected, got: {}", resp); let msg = error.unwrap()["message"].as_str().unwrap(); assert!( - msg.contains("MCP requires a CMS access token"), + msg.contains("MCP requires a vcms_site_* access token"), "Expected MCP token error, got: {}", msg ); diff --git a/apps/backend/tests/mcp_stdio.rs b/apps/backend/tests/mcp_stdio.rs index 41178493..d8d36456 100644 --- a/apps/backend/tests/mcp_stdio.rs +++ b/apps/backend/tests/mcp_stdio.rs @@ -17,14 +17,14 @@ struct StdioClient { impl StdioClient { async fn start(database_url: &str, hmac_secret: &str, token: &str) -> Self { - let mut child = Command::new(env!("CARGO_BIN_EXE_cms")) + let mut child = Command::new(env!("CARGO_BIN_EXE_vcms")) .args(["mcp", "stdio"]) .env("DATABASE_URL", database_url) .env("HMAC_SECRET", hmac_secret) - .env("CMS_MCP_TOKEN", token) + .env("VCMS_MCP_TOKEN", token) .env("DB_MIN_CONNECTIONS", "1") .env("DB_MAX_CONNECTIONS", "2") - .env("RUST_LOG", "cms=debug") + .env("RUST_LOG", "cms=debug,vcms=debug") .env("LOG_FORMAT", "pretty") .stdin(Stdio::piped()) .stdout(Stdio::piped()) @@ -39,19 +39,19 @@ impl StdioClient { } /// Start the stdio process the way a real MCP client does: it knows only - /// `CMS_HOME` and `CMS_MCP_TOKEN`. No `DATABASE_URL` / `HMAC_SECRET` in the + /// `VCMS_HOME` and `VCMS_MCP_TOKEN`. No `DATABASE_URL` / `HMAC_SECRET` in the /// environment, and a working directory with no `.env` — so both the database - /// path and the HMAC secret must be resolved from `~/.cms`. + /// path and the HMAC secret must be resolved from `~/.vcms`. async fn start_from_home(home: &std::path::Path, token: &str) -> Self { - let mut child = Command::new(env!("CARGO_BIN_EXE_cms")) + let mut child = Command::new(env!("CARGO_BIN_EXE_vcms")) .args(["mcp", "stdio"]) .env_remove("DATABASE_URL") .env_remove("HMAC_SECRET") - .env("CMS_HOME", home) - .env("CMS_MCP_TOKEN", token) + .env("VCMS_HOME", home) + .env("VCMS_MCP_TOKEN", token) .env("DB_MIN_CONNECTIONS", "1") .env("DB_MAX_CONNECTIONS", "2") - .env("RUST_LOG", "cms=debug") + .env("RUST_LOG", "cms=debug,vcms=debug") .env("LOG_FORMAT", "pretty") .current_dir(home) .stdin(Stdio::piped()) @@ -107,7 +107,7 @@ impl StdioClient { async fn setup_database(permission: AccessTokenPermission) -> (tempfile::TempDir, Config, String, String) { let directory = tempfile::tempdir().expect("temp directory"); - let database_path = directory.path().join("cms.db"); + let database_path = directory.path().join("vcms.db"); let database_url = format!("sqlite://{}", database_path.to_string_lossy().replace('\\', "/")); let hmac_secret = "stdio-test-hmac-secret".to_string(); let config = Config { @@ -143,15 +143,15 @@ async fn setup_database(permission: AccessTokenPermission) -> (tempfile::TempDir (directory, config, token.id, token.token) } -/// Provision a `~/.cms`-style home: a `secrets.toml` and a database at the -/// default location (`/cms.db`), with a site token signed by the persisted -/// HMAC secret. Mirrors what `cms serve` leaves behind on first run. +/// Provision a `~/.vcms`-style home: a `secrets.toml` and a database at the +/// default location (`/vcms.db`), with a site token signed by the persisted +/// HMAC secret. Mirrors what `vcms serve` leaves behind on first run. async fn setup_home_instance(home: &std::path::Path) -> String { let hmac_secret = "home-instance-hmac-secret".to_string(); std::fs::write(home.join("secrets.toml"), format!("hmac_secret = \"{hmac_secret}\"\n")) .expect("write secrets.toml"); - let database_path = home.join("cms.db"); + let database_path = home.join("vcms.db"); let database_url = format!("sqlite://{}", database_path.to_string_lossy().replace('\\', "/")); let config = Config { database_url, @@ -192,8 +192,8 @@ async fn setup_home_instance(home: &std::path::Path) -> String { } /// Proves the MCP-stdio fix: a cwd-less client process authenticates and serves -/// using only `CMS_HOME` + `CMS_MCP_TOKEN`, with the database path and HMAC -/// secret resolved from `~/.cms` rather than a cwd `.env`. +/// using only `VCMS_HOME` + `VCMS_MCP_TOKEN`, with the database path and HMAC +/// secret resolved from `~/.vcms` rather than a cwd `.env`. #[tokio::test] async fn stdio_resolves_database_and_secret_from_cms_home() { let home = tempfile::tempdir().expect("temp home"); @@ -205,7 +205,7 @@ async fn stdio_resolves_database_and_secret_from_cms_home() { let site = client .request(2, "tools/call", Some(json!({"name": "get_site", "arguments": {}}))) .await; - assert_eq!(site["result"]["isError"], false, "stdio must authenticate from ~/.cms"); + assert_eq!(site["result"]["isError"], false, "stdio must authenticate from ~/.vcms"); let (status, logs) = client.close().await; assert!(status.success(), "stdio process should exit cleanly; logs:\n{logs}"); diff --git a/apps/backend/tests/rest/access_tokens_tests.rs b/apps/backend/tests/rest/access_tokens_tests.rs index 65e1b29e..5942201d 100644 --- a/apps/backend/tests/rest/access_tokens_tests.rs +++ b/apps/backend/tests/rest/access_tokens_tests.rs @@ -20,7 +20,7 @@ async fn test_create_token() { let body: Value = resp.json().await.unwrap(); assert_eq!(body["name"], "Test Token"); assert_eq!(body["permission"], "read"); - assert!(body["token"].as_str().unwrap().starts_with("cms_site_")); + assert!(body["token"].as_str().unwrap().starts_with("vcms_site_")); } #[tokio::test] diff --git a/apps/backend/tests/rest/auth_tests.rs b/apps/backend/tests/rest/auth_tests.rs index e7acd65c..97bc7ad6 100644 --- a/apps/backend/tests/rest/auth_tests.rs +++ b/apps/backend/tests/rest/auth_tests.rs @@ -3,14 +3,14 @@ use crate::common::TestServer; async fn register_user( server: &TestServer, client: &reqwest::Client, - username: &str, + name: &str, email: &str, password: &str, ) -> reqwest::Response { let resp = client .post(format!("{}/api/auth/register", server.base_url)) .json(&serde_json::json!({ - "username": username, + "name": name, "email": email, "password": password, })) @@ -31,14 +31,14 @@ async fn register_user( async fn register_user_expect_error( server: &TestServer, client: &reqwest::Client, - username: &str, + name: &str, email: &str, password: &str, ) -> reqwest::Response { client .post(format!("{}/api/auth/register", server.base_url)) .json(&serde_json::json!({ - "username": username, + "name": name, "email": email, "password": password, })) @@ -91,13 +91,13 @@ async fn test_register_success() { let resp = register_user(&server, &client, "newuser", "new@example.com", "password123").await; let body: serde_json::Value = resp.json().await.unwrap(); - assert_eq!(body["user"]["username"], "newuser"); + assert_eq!(body["user"]["name"], "newuser"); assert_eq!(body["user"]["email"], "new@example.com"); assert!(body["user"]["id"].is_string()); } #[tokio::test] -async fn test_register_validation_empty_username() { +async fn test_register_validation_empty_name() { let server = TestServer::start().await; let client = reqwest::Client::builder().build().unwrap(); @@ -107,13 +107,15 @@ async fn test_register_validation_empty_username() { } #[tokio::test] -async fn test_register_validation_short_username() { +async fn test_register_short_display_name_allowed() { + // `name` is now a display name (non-unique, no length floor beyond non-empty), + // so a short name like "ab" is accepted. let server = TestServer::start().await; let client = reqwest::Client::builder().build().unwrap(); - let resp = register_user_expect_error(&server, &client, "ab", "test@example.com", "password123").await; + let resp = register_user(&server, &client, "ab", "test@example.com", "password123").await; - assert_eq!(resp.status(), 400); + assert!(resp.status().is_success()); } #[tokio::test] @@ -137,21 +139,21 @@ async fn test_register_validation_invalid_email() { } #[tokio::test] -async fn test_register_duplicate_username() { +async fn test_register_duplicate_email() { + // Email is the unique login identity; a second registration with the same email + // (even under a different display name) is rejected. let server = TestServer::start().await; let client = reqwest::Client::builder().build().unwrap(); - register_user(&server, &client, "testuser", "test@example.com", "password123").await; + register_user(&server, &client, "First User", "dupe@example.com", "password123").await; - let resp = register_user_expect_error(&server, &client, "testuser", "other@example.com", "password123").await; + let resp = register_user_expect_error(&server, &client, "Second User", "dupe@example.com", "password123").await; let status = resp.status(); let body: serde_json::Value = resp.json().await.unwrap_or_default(); - assert!( - status == 409 || status == 400, - "Expected 409 or 400 for duplicate username, got {}: {:?}", - status, - body + assert_eq!( + status, 409, + "Expected 409 Conflict for duplicate email, got {status}: {body:?}" ); } @@ -162,7 +164,7 @@ async fn test_login_success() { register_user(&server, &client, "testuser", "test@example.com", "password123").await; - let resp = server.login_user(&client, "testuser", "password123").await; + let resp = server.login_user(&client, "test@example.com", "password123").await; assert_eq!(resp.status(), 200); } @@ -173,7 +175,7 @@ async fn test_login_wrong_password() { register_user(&server, &client, "testuser", "test@example.com", "password123").await; - let resp = server.login_user(&client, "testuser", "wrongpassword").await; + let resp = server.login_user(&client, "test@example.com", "wrongpassword").await; assert_eq!(resp.status(), 401); } @@ -193,7 +195,7 @@ async fn test_me_authenticated() { register_user(&server, &client, "testuser", "test@example.com", "password123").await; - let resp = server.login_user(&client, "testuser", "password123").await; + let resp = server.login_user(&client, "test@example.com", "password123").await; assert_eq!(resp.status(), 200); let token = extract_token_from_cookies(&resp).expect("No token cookie"); @@ -209,7 +211,53 @@ async fn test_me_authenticated() { assert_eq!(me_resp.status(), 200); let body: serde_json::Value = me_resp.json().await.unwrap(); - assert_eq!(body["username"], "testuser"); + assert_eq!(body["name"], "testuser"); +} + +// ── routing: the MCP auth layer must not leak onto the global fallback (issue 1) ── + +#[tokio::test] +async fn test_dashboard_trailing_slash_does_not_hit_mcp_auth() { + let server = TestServer::start().await; + let client = reqwest::Client::builder().build().unwrap(); + + // `/dashboard/` must route to the SPA handler, never the MCP auth fallback. + let resp = client + .get(format!("{}/dashboard/", server.base_url)) + .send() + .await + .unwrap(); + assert_ne!(resp.status(), 401, "/dashboard/ must not require MCP auth"); + let body = resp.text().await.unwrap_or_default(); + assert!( + !body.contains("Missing Authorization bearer token"), + "/dashboard/ returned the MCP auth error: {body}" + ); +} + +#[tokio::test] +async fn test_unknown_path_returns_404_not_mcp_auth() { + let server = TestServer::start().await; + let client = reqwest::Client::builder().build().unwrap(); + + let resp = client + .get(format!("{}/this-route-does-not-exist", server.base_url)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 404, "unknown paths should 404, not hit MCP auth"); + let body = resp.text().await.unwrap_or_default(); + assert!(!body.contains("Missing Authorization bearer token")); +} + +#[tokio::test] +async fn test_mcp_still_requires_bearer_token() { + let server = TestServer::start().await; + let client = reqwest::Client::builder().build().unwrap(); + + // The /mcp endpoint must remain protected by the MCP auth layer. + let resp = client.get(format!("{}/mcp", server.base_url)).send().await.unwrap(); + assert_eq!(resp.status(), 401); } #[tokio::test] @@ -228,7 +276,7 @@ async fn test_logout() { register_user(&server, &client, "testuser", "test@example.com", "password123").await; - let resp = server.login_user(&client, "testuser", "password123").await; + let resp = server.login_user(&client, "test@example.com", "password123").await; assert_eq!(resp.status(), 200); let token = extract_token_from_cookies(&resp).expect("No token cookie"); diff --git a/apps/backend/tests/rest/backups_tests.rs b/apps/backend/tests/rest/backups_tests.rs index 2627b8b8..8cfe16b3 100644 --- a/apps/backend/tests/rest/backups_tests.rs +++ b/apps/backend/tests/rest/backups_tests.rs @@ -4,9 +4,15 @@ use crate::common::{TestServer, auth::auth_header}; // ── helpers ── -async fn login(server: &TestServer, username: &str, password: &str) -> (String, String) { +async fn login(server: &TestServer, name: &str, password: &str) -> (String, String) { let client = reqwest::Client::builder().build().unwrap(); - let resp = server.login_user(&client, username, password).await; + // Login is by email; map the logical name to the email these helpers assign. + let email = if name == "admin" { + "admin@cms.local".to_string() + } else { + format!("{name}@example.com") + }; + let resp = server.login_user(&client, &email, password).await; let mut token = String::new(); let mut csrf = String::new(); for cookie in resp.headers().get_all("set-cookie").iter() { @@ -213,29 +219,31 @@ async fn encrypted_backup_round_trips() { assert_eq!(get_entry_status(&server, &token, &csrf, &site_id, &entry_id).await, 200); } -async fn create_user(server: &TestServer, token: &str, csrf: &str, username: &str, instance_role: Option<&str>) { +async fn create_user(server: &TestServer, token: &str, csrf: &str, name: &str, instance_role: Option<&str>) { let client = reqwest::Client::new(); let resp = client .post(format!("{}/api/dashboard/instance/users", server.base_url)) .headers(auth_header(token, csrf)) .json(&json!({ - "username": username, - "email": format!("{username}@example.com"), + "name": name, + "email": format!("{name}@example.com"), "temporary_password": "password123", "instance_role": instance_role, })) .send() .await .unwrap(); - assert!(resp.status().is_success(), "create user {username}: {}", resp.status()); + assert!(resp.status().is_success(), "create user {name}: {}", resp.status()); } -async fn invite_member(server: &TestServer, token: &str, csrf: &str, site_id: &str, username: &str, role: &str) { +async fn invite_member(server: &TestServer, token: &str, csrf: &str, site_id: &str, name: &str, role: &str) { let client = reqwest::Client::new(); + // Members are invited by email; map the logical name to its assigned email. + let email = format!("{name}@example.com"); let resp = client .post(format!("{}/api/dashboard/sites/{}/members", server.base_url, site_id)) .headers(auth_header(token, csrf)) - .json(&json!({"username": username, "role": role})) + .json(&json!({"email": email, "role": role})) .send() .await .unwrap(); diff --git a/apps/backend/tests/rest/roles_tests.rs b/apps/backend/tests/rest/roles_tests.rs index 74e91e30..6562f74c 100644 --- a/apps/backend/tests/rest/roles_tests.rs +++ b/apps/backend/tests/rest/roles_tests.rs @@ -4,10 +4,17 @@ use crate::common::{TestServer, auth::auth_header}; // ── helpers ── -async fn login(server: &TestServer, username: &str, password: &str) -> (String, String) { +async fn login(server: &TestServer, name: &str, password: &str) -> (String, String) { let client = reqwest::Client::builder().build().unwrap(); - let resp = server.login_user(&client, username, password).await; - assert_eq!(resp.status(), 200, "login failed for {username}"); + // Login is by email; map the logical name to the email these helpers assign + // (the seeded admin is admin@cms.local; managed users are {name}@example.com). + let email = if name == "admin" { + "admin@cms.local".to_string() + } else { + format!("{name}@example.com") + }; + let resp = server.login_user(&client, &email, password).await; + assert_eq!(resp.status(), 200, "login failed for {name}"); let mut token = String::new(); let mut csrf = String::new(); for cookie in resp.headers().get_all("set-cookie").iter() { @@ -43,7 +50,7 @@ async fn create_user( server: &TestServer, token: &str, csrf: &str, - username: &str, + name: &str, instance_role: Option<&str>, ) -> reqwest::Response { let client = reqwest::Client::builder().build().unwrap(); @@ -51,8 +58,8 @@ async fn create_user( .post(format!("{}/api/dashboard/instance/users", server.base_url)) .headers(auth_header(token, csrf)) .json(&json!({ - "username": username, - "email": format!("{username}@example.com"), + "name": name, + "email": format!("{name}@example.com"), "temporary_password": "password123", "instance_role": instance_role, })) @@ -66,14 +73,20 @@ async fn invite_member( token: &str, csrf: &str, site_id: &str, - username: &str, + name: &str, role: &str, ) -> reqwest::Response { let client = reqwest::Client::builder().build().unwrap(); + // Members are invited by email; map the logical name to its assigned email. + let email = if name == "admin" { + "admin@cms.local".to_string() + } else { + format!("{name}@example.com") + }; client .post(format!("{}/api/dashboard/sites/{}/members", server.base_url, site_id)) .headers(auth_header(token, csrf)) - .json(&json!({"username": username, "role": role})) + .json(&json!({"email": email, "role": role})) .send() .await .unwrap() @@ -422,3 +435,120 @@ async fn editor_cannot_delete_site() { .unwrap(); assert_eq!(resp.status(), 403); } + +// ── instance user management (update / reset password / delete) ── + +async fn user_id_from(resp: reqwest::Response) -> String { + let body: Value = resp.json().await.unwrap(); + body["id"].as_str().unwrap().to_string() +} + +#[tokio::test] +async fn operator_updates_user_profile() { + let server = TestServer::start().await; + let (token, csrf) = login(&server, "admin", "admin").await; + let id = user_id_from(create_user(&server, &token, &csrf, "member", None).await).await; + + let client = reqwest::Client::builder().build().unwrap(); + let resp = client + .put(format!("{}/api/dashboard/instance/users/{}", server.base_url, id)) + .headers(auth_header(&token, &csrf)) + .json(&json!({"name": "Renamed Member", "email": "renamed@example.com"})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 204); + + // The user can now log in with the new email. + let login = server.login_user(&client, "renamed@example.com", "password123").await; + assert_eq!(login.status(), 200); +} + +#[tokio::test] +async fn operator_resets_user_password() { + let server = TestServer::start().await; + let (token, csrf) = login(&server, "admin", "admin").await; + let id = user_id_from(create_user(&server, &token, &csrf, "member", None).await).await; + + let client = reqwest::Client::builder().build().unwrap(); + let resp = client + .post(format!( + "{}/api/dashboard/instance/users/{}/password", + server.base_url, id + )) + .headers(auth_header(&token, &csrf)) + .json(&json!({"new_password": "brandnewpass"})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 204); + + let login = server.login_user(&client, "member@example.com", "brandnewpass").await; + assert_eq!(login.status(), 200); +} + +#[tokio::test] +async fn operator_deletes_user() { + let server = TestServer::start().await; + let (token, csrf) = login(&server, "admin", "admin").await; + let id = user_id_from(create_user(&server, &token, &csrf, "member", None).await).await; + + let client = reqwest::Client::builder().build().unwrap(); + let resp = client + .delete(format!("{}/api/dashboard/instance/users/{}", server.base_url, id)) + .headers(auth_header(&token, &csrf)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 204); + + // The deleted user can no longer authenticate. + let login = server.login_user(&client, "member@example.com", "password123").await; + assert_eq!(login.status(), 401); +} + +#[tokio::test] +async fn cannot_delete_own_account() { + let server = TestServer::start().await; + let (token, csrf) = login(&server, "admin", "admin").await; + + let client = reqwest::Client::builder().build().unwrap(); + let me: Value = client + .get(format!("{}/api/auth/me", server.base_url)) + .headers(auth_header(&token, &csrf)) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let my_id = me["id"].as_str().unwrap(); + + let resp = client + .delete(format!("{}/api/dashboard/instance/users/{}", server.base_url, my_id)) + .headers(auth_header(&token, &csrf)) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 400); +} + +#[tokio::test] +async fn user_updates_own_display_name() { + let server = TestServer::start().await; + let (token, csrf) = login(&server, "admin", "admin").await; + create_user(&server, &token, &csrf, "member", None).await; + + let (m_token, m_csrf) = login(&server, "member", "password123").await; + let client = reqwest::Client::builder().build().unwrap(); + let resp = client + .put(format!("{}/api/auth/me", server.base_url)) + .headers(auth_header(&m_token, &m_csrf)) + .json(&json!({"name": "Jane Doe"})) + .send() + .await + .unwrap(); + assert_eq!(resp.status(), 200); + let body: Value = resp.json().await.unwrap(); + assert_eq!(body["name"], "Jane Doe"); +} diff --git a/apps/backend/tests/rest/sites_tests.rs b/apps/backend/tests/rest/sites_tests.rs index 133ba952..7005ec04 100644 --- a/apps/backend/tests/rest/sites_tests.rs +++ b/apps/backend/tests/rest/sites_tests.rs @@ -5,10 +5,16 @@ use crate::common::{TestServer, auth::auth_header}; async fn login_and_get_cookies( server: &TestServer, client: &reqwest::Client, - username: &str, + name: &str, password: &str, ) -> (String, String) { - let resp = server.login_user(client, username, password).await; + // Login is by email; the seeded admin is admin@cms.local. + let email = if name == "admin" { + "admin@cms.local".to_string() + } else { + format!("{name}@example.com") + }; + let resp = server.login_user(client, &email, password).await; assert_eq!( resp.status(), 200, diff --git a/apps/dashboard/biome.json b/apps/dashboard/biome.json index c6679dd2..2f2933b1 100644 --- a/apps/dashboard/biome.json +++ b/apps/dashboard/biome.json @@ -26,7 +26,7 @@ "linter": { "enabled": true, "rules": { - "recommended": true, + "preset": "recommended", "correctness": { "noChildrenProp": "off" } diff --git a/apps/dashboard/src/components/backups/backups-section.tsx b/apps/dashboard/src/components/backups/backups-section.tsx index 86a406f4..3452e4c4 100644 --- a/apps/dashboard/src/components/backups/backups-section.tsx +++ b/apps/dashboard/src/components/backups/backups-section.tsx @@ -6,6 +6,7 @@ import { Lock, Play, Plus, + RefreshCw, RotateCcw, Trash2, } from "lucide-react"; @@ -64,6 +65,7 @@ import { inspectBackupUpload, listBackupSchedules, listBackups, + reindexSearch, restoreBackup, restoreBackupUpload, runBackupSchedule, @@ -128,6 +130,15 @@ export function BackupsSection({ scope }: { scope: BackupScope }) { const backupsQuery = useQuery({ queryKey: ["backups", scopeKey], queryFn: () => listBackups(scope), + // Poll while a backup is in flight so the list (incl. scheduled backups the UI + // never hears about) and status transitions appear without a manual refresh. + refetchInterval: (query) => + query.state.data?.some( + (b) => b.status === "running" || b.status === "pending", + ) + ? 2500 + : false, + refetchOnWindowFocus: true, }); const schedulesQuery = useQuery({ queryKey: ["backup-schedules", scopeKey], @@ -158,6 +169,14 @@ export function BackupsSection({ scope }: { scope: BackupScope }) { onError: (e: Error) => toast.error(e.message), }); + const reindexMutation = useMutation({ + mutationFn: () => reindexSearch(scope), + onSuccess: (res) => { + toast.success(`Search index rebuilt — ${res.reindexed} entries`); + }, + onError: (e: Error) => toast.error(e.message), + }); + const restoreMutation = useMutation({ mutationFn: async () => { if (!restoreSource) return; @@ -432,6 +451,28 @@ export function BackupsSection({ scope }: { scope: BackupScope }) { }} /> + {/* Search index */} + + + Search index + + The full-text search index is derived from your content and rebuilt + automatically. Rebuild it manually after a CLI restore, or if search + results look stale. + + + + + + + {/* Restore confirmation */}
- ({ + value: p.value, + label: p.label, + }))} + value={preset} + onValueChange={(v) => setPreset(v ?? "")} + > + diff --git a/apps/dashboard/src/components/dashboard-header.tsx b/apps/dashboard/src/components/dashboard-header.tsx index 35c6f670..8eee43ac 100644 --- a/apps/dashboard/src/components/dashboard-header.tsx +++ b/apps/dashboard/src/components/dashboard-header.tsx @@ -19,7 +19,7 @@ export function DashboardHeader() { const auth = useAuth(); const navigate = useNavigate(); const showInstanceSettings = isOperator(auth.user?.instance_role); - const name = auth.user?.username ?? "User"; + const name = auth.user?.name ?? "User"; const email = auth.user?.email ?? ""; const handleLogout = async () => { @@ -61,13 +61,13 @@ export function DashboardHeader() { /> } > - +
- +
{name} diff --git a/apps/dashboard/src/components/file-picker-dialog.tsx b/apps/dashboard/src/components/file-picker-dialog.tsx index 76bc7879..8ea2a4aa 100644 --- a/apps/dashboard/src/components/file-picker-dialog.tsx +++ b/apps/dashboard/src/components/file-picker-dialog.tsx @@ -8,7 +8,7 @@ import { Upload, Video, } from "lucide-react"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; @@ -25,6 +25,7 @@ import { Input } from "@/components/ui/input"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Skeleton } from "@/components/ui/skeleton"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { type FileItem, getFiles, uploadFile } from "@/lib/api"; // --------------------------------------------------------------------------- @@ -120,6 +121,7 @@ export function FilePickerDialog({ uploadProvider = "filesystem", }: FilePickerDialogProps) { const [search, setSearch] = useState(""); + const debouncedSearch = useDebouncedValue(search, 300); const [page, setPage] = useState(1); const [tab, setTab] = useState("library"); const [dragOver, setDragOver] = useState(false); @@ -135,16 +137,22 @@ export function FilePickerDialog({ ); const { data, isLoading, isError } = useQuery({ - queryKey: ["files", siteId, page, search, fileType], + queryKey: ["files", siteId, page, debouncedSearch, fileType], queryFn: () => getFiles(siteId, { page, - search: search || undefined, + search: debouncedSearch || undefined, type: fileType, }), enabled: open, }); + // Reset to the first page when the debounced search term changes. + // biome-ignore lint/correctness/useExhaustiveDependencies: reset only on term change + useEffect(() => { + setPage(1); + }, [debouncedSearch]); + // Client-side filtering on top of the server-side type filter, to handle // cases where the accept prop contains multiple specific MIME types. const filteredItems = useMemo(() => { @@ -203,7 +211,6 @@ export function FilePickerDialog({ const handleSearchChange = useCallback( (e: React.ChangeEvent) => { setSearch(e.target.value); - setPage(1); }, [], ); diff --git a/apps/dashboard/src/components/instance/create-user-dialog.tsx b/apps/dashboard/src/components/instance/create-user-dialog.tsx index 176b6e1f..0ffe62fe 100644 --- a/apps/dashboard/src/components/instance/create-user-dialog.tsx +++ b/apps/dashboard/src/components/instance/create-user-dialog.tsx @@ -22,15 +22,14 @@ import { } from "@/components/ui/select"; import { createManagedUser, - type InstanceRole, + INSTANCE_ROLE_ITEMS, + ROLE_USER, + type RoleValue, type UserPublic, } from "@/lib/api"; -const ROLE_USER = "user"; -type RoleValue = InstanceRole | typeof ROLE_USER; - const EMPTY = { - username: "", + name: "", email: "", temporary_password: "", role: ROLE_USER as RoleValue, @@ -56,7 +55,7 @@ export function CreateUserDialog({ const createMutation = useMutation({ mutationFn: () => createManagedUser({ - username: form.username, + name: form.name, email: form.email, temporary_password: form.temporary_password, instance_role: form.role === ROLE_USER ? null : form.role, @@ -90,16 +89,17 @@ export function CreateUserDialog({ > - Username + Name setForm((current) => ({ ...current, - username: event.target.value, + name: event.target.value, })) } /> @@ -140,6 +140,7 @@ export function CreateUserDialog({ Access field.handleChange(v as "read" | "write") diff --git a/apps/dashboard/src/components/site-settings/general-section.tsx b/apps/dashboard/src/components/site-settings/general-section.tsx index 4ee42a74..b3a40a2a 100644 --- a/apps/dashboard/src/components/site-settings/general-section.tsx +++ b/apps/dashboard/src/components/site-settings/general-section.tsx @@ -1,5 +1,6 @@ import { useForm } from "@tanstack/react-form"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; import { useEffect, useState } from "react"; import { toast } from "sonner"; import { z } from "zod"; @@ -11,6 +12,15 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { Field, FieldError, @@ -19,7 +29,7 @@ import { } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { Skeleton } from "@/components/ui/skeleton"; -import { getSite, updateSite } from "@/lib/api"; +import { deleteSite, getSite, updateSite } from "@/lib/api"; const siteSettingsSchema = z.object({ name: z.string().min(1, "Site name is required"), @@ -33,13 +43,26 @@ export function GeneralSection({ canManage: boolean; }) { const queryClient = useQueryClient(); + const navigate = useNavigate(); const [initialized, setInitialized] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); const { data: site, isLoading } = useQuery({ queryKey: ["site", siteId], queryFn: () => getSite(siteId), }); + const deleteMutation = useMutation({ + mutationFn: () => deleteSite(siteId), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["sites"] }); + setConfirmDelete(false); + toast.success("Site deleted"); + navigate({ to: "/" }); + }, + onError: (err: Error) => toast.error(err.message), + }); + const form = useForm({ defaultValues: { name: "" }, validators: { onSubmit: siteSettingsSchema }, @@ -75,61 +98,110 @@ export function GeneralSection({ } return ( -
{ - e.preventDefault(); - form.handleSubmit(); - }} - className="flex flex-col gap-6" - > - - - General - Basic information about this site. - - - - { - const isInvalid = - field.state.meta.isTouched && !field.state.meta.isValid; - return ( - - Site Name - field.handleChange(e.target.value)} - className="max-w-md" - aria-invalid={isInvalid} - disabled={!canManage} - /> - {isInvalid && ( - - )} - - ); - }} - /> - - - - - -
+ + + General + + Basic information about this site. + + + + + { + const isInvalid = + field.state.meta.isTouched && !field.state.meta.isValid; + return ( + + Site Name + field.handleChange(e.target.value)} + className="max-w-md" + aria-invalid={isInvalid} + disabled={!canManage} + /> + {isInvalid && ( + + )} + + ); + }} + /> + + + + + + + + {canManage && ( + + + Danger zone + + Deleting a site permanently removes its content, files, schema, + and members. This cannot be undone. + + + + + + + )} + + + + + Delete site + + Permanently delete {site.name} and all of its + content, files, and members. This cannot be undone. + + + + }> + Cancel + + + + + +
); } diff --git a/apps/dashboard/src/components/site-settings/members-section.tsx b/apps/dashboard/src/components/site-settings/members-section.tsx index 2eb70123..5a5758cc 100644 --- a/apps/dashboard/src/components/site-settings/members-section.tsx +++ b/apps/dashboard/src/components/site-settings/members-section.tsx @@ -33,6 +33,11 @@ import { updateMemberRole, } from "@/lib/api"; +const ROLE_ITEMS = [ + { value: "editor", label: siteRoleLabel("editor") }, + { value: "viewer", label: siteRoleLabel("viewer") }, +]; + export function MembersSection({ siteId, canManage, @@ -70,7 +75,7 @@ export function MembersSection({ queryClient.invalidateQueries({ queryKey: ["site-members", siteId] }); const inviteMutation = useMutation({ - mutationFn: (username: string) => inviteMember(siteId, { username, role }), + mutationFn: (email: string) => inviteMember(siteId, { email, role }), onSuccess: () => { invalidate(); setSelectedUserId(null); @@ -100,7 +105,7 @@ export function MembersSection({ const user = candidates.find( (candidate) => candidate.id === selectedUserId, ); - if (user) inviteMutation.mutate(user.username); + if (user) inviteMutation.mutate(user.email); }; return ( @@ -145,6 +150,7 @@ export function MembersSection({ Role { if (nextRole) { diff --git a/apps/dashboard/src/components/site-settings/user-combobox.tsx b/apps/dashboard/src/components/site-settings/user-combobox.tsx index ae7358e5..2a6b5841 100644 --- a/apps/dashboard/src/components/site-settings/user-combobox.tsx +++ b/apps/dashboard/src/components/site-settings/user-combobox.tsx @@ -51,7 +51,7 @@ export function UserCombobox({ - {selected ? selected.username : placeholder} + {selected ? selected.name : placeholder} @@ -70,7 +70,7 @@ export function UserCombobox({ {users.map((user) => ( { onChange(user.id); setOpen(false); @@ -83,9 +83,7 @@ export function UserCombobox({ )} />
- - {user.username} - + {user.name} {user.email} diff --git a/apps/dashboard/src/components/user-avatar.tsx b/apps/dashboard/src/components/user-avatar.tsx index a2dd9397..ee9dad7a 100644 --- a/apps/dashboard/src/components/user-avatar.tsx +++ b/apps/dashboard/src/components/user-avatar.tsx @@ -4,21 +4,21 @@ import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { cn } from "@/lib/utils"; type UserAvatarProps = { - username: string; + name: string; image?: string | null; className?: string | undefined; }; -export function UserAvatar({ username, image, className }: UserAvatarProps) { +export function UserAvatar({ name, image, className }: UserAvatarProps) { return ( - + diff --git a/apps/dashboard/src/contexts/auth-context.tsx b/apps/dashboard/src/contexts/auth-context.tsx index c21482f7..8e808708 100644 --- a/apps/dashboard/src/contexts/auth-context.tsx +++ b/apps/dashboard/src/contexts/auth-context.tsx @@ -23,8 +23,14 @@ export function AuthProvider({ children }: { children: ReactNode }) { }); const handleLogin = async () => { - // After successful login API call elsewhere - await queryClient.invalidateQueries({ queryKey: ["me"] }); + // A previous user's data may still be cached. Drop every query EXCEPT + // ["me"]: that query is watched by this provider's always-mounted observer, + // and removing it (clear/removeQueries) orphans the observer — it never + // refetches, so the UI keeps showing the previous user until a hard reload. + // Instead refetch ["me"] in place so the provider re-renders with the + // freshly signed-in user. + queryClient.removeQueries({ predicate: (q) => q.queryKey[0] !== "me" }); + await queryClient.refetchQueries({ queryKey: ["me"] }); }; const handleLogout = async () => { @@ -34,7 +40,12 @@ export function AuthProvider({ children }: { children: ReactNode }) { // ignore errors (expired session etc.) } - queryClient.removeQueries({ queryKey: ["me"] }); + // Same reasoning as handleLogin: purge all other user-scoped queries + // (sites/sessions/collections/files…) but keep + refetch ["me"] so the + // observer stays attached. The refetch hits the now-invalid session → + // 401 → user becomes null. + queryClient.removeQueries({ predicate: (q) => q.queryKey[0] !== "me" }); + await queryClient.refetchQueries({ queryKey: ["me"] }); }; return ( diff --git a/apps/dashboard/src/hooks/use-debounced-value.ts b/apps/dashboard/src/hooks/use-debounced-value.ts new file mode 100644 index 00000000..b0ee6f9c --- /dev/null +++ b/apps/dashboard/src/hooks/use-debounced-value.ts @@ -0,0 +1,17 @@ +import { useEffect, useState } from "react"; + +/** + * Returns a copy of `value` that only updates after it has stopped changing for + * `delayMs`. Useful for keeping an input responsive while debouncing the value + * that drives a query. + */ +export function useDebouncedValue(value: T, delayMs = 300): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const id = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(id); + }, [value, delayMs]); + + return debounced; +} diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index 017356c9..b2613122 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -81,12 +81,26 @@ export type InstanceRole = "instance_owner" | "instance_admin"; export interface UserPublic { id: string; - username: string; + name: string; email: string; instance_role: InstanceRole | null; must_change_password: boolean; } +/** The non-operator role, modelled on the frontend as the string "user". */ +export const ROLE_USER = "user"; +export type RoleValue = InstanceRole | typeof ROLE_USER; + +/** + * value→label items for an instance-role ` setDisplayName(event.target.value)} + placeholder="John Doe" + required + /> + + {auth.user?.email ? ( + + Email + + + ) : null} + + + + + + Change password diff --git a/apps/dashboard/src/routes/_admin/_shell/settings/index.tsx b/apps/dashboard/src/routes/_admin/_shell/settings/index.tsx index c791657b..98c11461 100644 --- a/apps/dashboard/src/routes/_admin/_shell/settings/index.tsx +++ b/apps/dashboard/src/routes/_admin/_shell/settings/index.tsx @@ -35,7 +35,7 @@ function GeneralInstanceSettings() { - + diff --git a/apps/dashboard/src/routes/_admin/_shell/settings/users.tsx b/apps/dashboard/src/routes/_admin/_shell/settings/users.tsx index 32b7a65e..292b88d6 100644 --- a/apps/dashboard/src/routes/_admin/_shell/settings/users.tsx +++ b/apps/dashboard/src/routes/_admin/_shell/settings/users.tsx @@ -1,6 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createFileRoute } from "@tanstack/react-router"; -import { UserPlus } from "lucide-react"; +import { MoreHorizontal, UserPlus } from "lucide-react"; +import { useEffect, useState } from "react"; import { toast } from "sonner"; import { CreateUserDialog } from "@/components/instance/create-user-dialog"; import { Badge } from "@/components/ui/badge"; @@ -13,6 +14,24 @@ import { CardHeader, CardTitle, } from "@/components/ui/card"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; import { Select, SelectContent, @@ -22,21 +41,25 @@ import { } from "@/components/ui/select"; import { Skeleton } from "@/components/ui/skeleton"; import { + adminSetUserPassword, + deleteUser, getInstanceUsers, getMe, + INSTANCE_ROLE_ITEMS, type InstanceRole, instanceRoleLabel, isOperator, + ROLE_USER, + type RoleValue, + type UserPublic, updateInstanceRole, + updateUser, } from "@/lib/api"; export const Route = createFileRoute("/_admin/_shell/settings/users")({ component: InstanceUsers, }); -const ROLE_USER = "user"; -type RoleValue = InstanceRole | typeof ROLE_USER; - function toRole(value: RoleValue): InstanceRole | null { return value === ROLE_USER ? null : value; } @@ -51,6 +74,13 @@ function InstanceUsers() { queryFn: getInstanceUsers, }); + const [editUser, setEditUser] = useState(null); + const [passwordUser, setPasswordUser] = useState(null); + const [removeUser, setRemoveUser] = useState(null); + + const invalidate = () => + queryClient.invalidateQueries({ queryKey: ["instance-users"] }); + const roleMutation = useMutation({ mutationFn: ({ userId, @@ -60,12 +90,22 @@ function InstanceUsers() { role: InstanceRole | null; }) => updateInstanceRole(userId, role), onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ["instance-users"] }); + invalidate(); toast.success("Instance role updated"); }, onError: (error: Error) => toast.error(error.message), }); + const removeMutation = useMutation({ + mutationFn: (userId: string) => deleteUser(userId), + onSuccess: () => { + invalidate(); + setRemoveUser(null); + toast.success("User deleted"); + }, + onError: (error: Error) => toast.error(error.message), + }); + return (
@@ -108,36 +148,23 @@ function InstanceUsers() { {users?.map((user) => { const isOwner = user.instance_role === "instance_owner"; - const targetIsOperator = - isOwner || user.instance_role === "instance_admin"; - // Operators may change roles; only owners may touch an owner. - const canEditRole = + const isSelf = user.id === me?.id; + // Operators may manage users; only owners may touch an owner. + const canManage = currentIsOperator && (currentIsOwner || !isOwner); + const canEditRole = canManage && !isSelf; return ( -
{user.username}
+
{user.name}
{user.email}
- - {instanceRoleLabel(user.instance_role)} - - - - {user.must_change_password ? ( - Change required - ) : ( - "Configured" - )} - - - {canEditRole && user.id !== me?.id ? ( + {canEditRole ? ( + ) : ( + + {instanceRoleLabel(user.instance_role)} + + )} + + + {user.must_change_password ? ( + Change required + ) : ( + "Configured" + )} + + + {canManage ? ( + + + } + > + + Manage user + + + setEditUser(user)} + > + Edit details + + setPasswordUser(user)} + > + Reset password + + {!isSelf && ( + <> + + setRemoveUser(user)} + > + Delete user + + + )} + + ) : ( )} @@ -174,6 +253,201 @@ function InstanceUsers() { )}
+ + !open && setEditUser(null)} + onSaved={invalidate} + /> + !open && setPasswordUser(null)} + /> + !open && setRemoveUser(null)} + > + + + Delete user + + Permanently delete {removeUser?.name} ( + {removeUser?.email}). This cannot be undone. + + + + }> + Cancel + + + + +
); } + +function EditUserDialog({ + user, + onOpenChange, + onSaved, +}: { + user: UserPublic | null; + onOpenChange: (open: boolean) => void; + onSaved: () => void; +}) { + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + + // The dialog is controlled via `open={!!user}`, so Radix's onOpenChange does not + // fire when the parent selects a user. Prefill from the user prop instead. + useEffect(() => { + if (user) { + setName(user.name); + setEmail(user.email); + } + }, [user]); + + const mutation = useMutation({ + mutationFn: () => { + if (!user) throw new Error("No user selected"); + return updateUser(user.id, { name, email }); + }, + onSuccess: () => { + onSaved(); + onOpenChange(false); + toast.success("User updated"); + }, + onError: (error: Error) => toast.error(error.message), + }); + + return ( + + + + Edit user + + Update this user's display name and email. + + +
{ + event.preventDefault(); + mutation.mutate(); + }} + > + + + Name + setName(event.target.value)} + /> + + + Email + setEmail(event.target.value)} + /> + + + + }> + Cancel + + + +
+
+
+ ); +} + +function ResetPasswordDialog({ + user, + onOpenChange, +}: { + user: UserPublic | null; + onOpenChange: (open: boolean) => void; +}) { + const [password, setPassword] = useState(""); + + const mutation = useMutation({ + mutationFn: () => { + if (!user) throw new Error("No user selected"); + return adminSetUserPassword(user.id, password); + }, + onSuccess: () => { + onOpenChange(false); + toast.success("Password reset. The user must change it on next sign in."); + }, + onError: (error: Error) => toast.error(error.message), + }); + + return ( + { + if (open) setPassword(""); + onOpenChange(open); + }} + > + + + Reset password + + Set a temporary password for {user?.name}. They + must change it after signing in. + + +
{ + event.preventDefault(); + mutation.mutate(); + }} + > + + + + Temporary password + + setPassword(event.target.value)} + /> + + + + }> + Cancel + + + +
+
+
+ ); +} diff --git a/apps/dashboard/src/routes/_admin/sites.$siteId/entries.$collectionSlug/index.tsx b/apps/dashboard/src/routes/_admin/sites.$siteId/entries.$collectionSlug/index.tsx index 7da4998b..e5fc5299 100644 --- a/apps/dashboard/src/routes/_admin/sites.$siteId/entries.$collectionSlug/index.tsx +++ b/apps/dashboard/src/routes/_admin/sites.$siteId/entries.$collectionSlug/index.tsx @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { createFileRoute, Link } from "@tanstack/react-router"; import { Plus, Search } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; import { createColumns } from "@/components/entries/columns"; import { buttonVariants } from "@/components/ui/button"; @@ -15,6 +15,7 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; +import { useDebouncedValue } from "@/hooks/use-debounced-value"; import { deleteEntry, getCollection, @@ -33,10 +34,18 @@ function EntriesListPage() { const { siteId, collectionSlug } = Route.useParams(); const queryClient = useQueryClient(); const [search, setSearch] = useState(""); + const debouncedSearch = useDebouncedValue(search, 300); const [statusFilter, setStatusFilter] = useState(""); const [page, setPage] = useState(1); const [pageSize, setPageSize] = useState(20); + // Reset to the first page when the debounced search term actually changes, + // so paging tracks the fetched results rather than each keystroke. + // biome-ignore lint/correctness/useExhaustiveDependencies: reset only on term change + useEffect(() => { + setPage(1); + }, [debouncedSearch]); + const { data: collection, isLoading: collectionLoading } = useQuery({ queryKey: ["collection", siteId, collectionSlug], queryFn: () => getCollection(siteId, collectionSlug), @@ -48,7 +57,7 @@ function EntriesListPage() { siteId, collectionSlug, statusFilter, - search, + debouncedSearch, page, pageSize, ], @@ -56,7 +65,7 @@ function EntriesListPage() { getEntries(siteId, { type: collectionSlug, status: statusFilter || undefined, - search: search || undefined, + search: debouncedSearch || undefined, page, pageSize, }), @@ -67,7 +76,6 @@ function EntriesListPage() { const handleSearchChange = (value: string | null) => { setSearch(value || ""); - setPage(1); }; const handleStatusChange = (value: string | null) => { @@ -79,7 +87,7 @@ function EntriesListPage() { mutationFn: (id: string) => deleteEntry(siteId, id), onSuccess: () => { queryClient.invalidateQueries({ - queryKey: ["entries", siteId, collectionSlug], + queryKey: ["entries", siteId], }); toast.success("Entry deleted"); }, @@ -90,7 +98,7 @@ function EntriesListPage() { mutationFn: (id: string) => publishEntry(siteId, id), onSuccess: () => { queryClient.invalidateQueries({ - queryKey: ["entries", siteId, collectionSlug], + queryKey: ["entries", siteId], }); toast.success("Published"); }, @@ -101,7 +109,7 @@ function EntriesListPage() { mutationFn: (id: string) => unpublishEntry(siteId, id), onSuccess: () => { queryClient.invalidateQueries({ - queryKey: ["entries", siteId, collectionSlug], + queryKey: ["entries", siteId], }); toast.success("Unpublished"); }, @@ -166,7 +174,7 @@ function EntriesListPage() {
{ - setSearch(e.target.value); - setPage(1); - }} + onChange={(e) => setSearch(e.target.value)} className="pl-8" />
diff --git a/apps/dashboard/src/routes/_admin/sites.$siteId/index.tsx b/apps/dashboard/src/routes/_admin/sites.$siteId/index.tsx index 99cfbb89..c9b6d173 100644 --- a/apps/dashboard/src/routes/_admin/sites.$siteId/index.tsx +++ b/apps/dashboard/src/routes/_admin/sites.$siteId/index.tsx @@ -56,10 +56,17 @@ function DashboardPage() { const singletons = collectionsArray.filter((c) => c.is_singleton); const allEntriesArray = entriesResponse?.items ?? []; - const publishedCount = allEntriesArray.filter( + // Singletons have no publish lifecycle — their backing entry just carries the + // default "draft" status — so exclude them from content stats and the + // recently-updated list. + const singletonIds = new Set(singletons.map((c) => c.id)); + const contentEntries = allEntriesArray.filter( + (e: Entry) => !singletonIds.has(e.collection_id), + ); + const publishedCount = contentEntries.filter( (e: Entry) => e.status === "published", ).length; - const draftCount = allEntriesArray.filter( + const draftCount = contentEntries.filter( (e: Entry) => e.status === "draft", ).length; @@ -189,11 +196,11 @@ function DashboardPage() { )} {/* Recently updated */} - {allEntriesArray.length > 0 && ( + {contentEntries.length > 0 && (

Recently updated

- {allEntriesArray.slice(0, 5).map((item: Entry) => { + {contentEntries.slice(0, 5).map((item: Entry) => { const collectionName = collectionsArray.find( (c: Collection) => c.id === item.collection_id, )?.name; diff --git a/apps/dashboard/src/routes/login.tsx b/apps/dashboard/src/routes/login.tsx index ac9de58b..c923331a 100644 --- a/apps/dashboard/src/routes/login.tsx +++ b/apps/dashboard/src/routes/login.tsx @@ -22,7 +22,7 @@ import { useAuth } from "@/contexts/auth-context"; import { login as apiLogin } from "@/lib/api"; const loginSchema = z.object({ - username: z.string().min(1, "Username is required"), + email: z.string().email("Enter a valid email address"), password: z.string().min(1, "Password is required"), }); @@ -35,15 +35,10 @@ function LoginPage() { const auth = useAuth(); const loginMutation = useMutation({ - mutationFn: ({ - username, - password, - }: { - username: string; - password: string; - }) => apiLogin(username, password), - onSuccess: () => { - auth.login(); + mutationFn: ({ email, password }: { email: string; password: string }) => + apiLogin(email, password), + onSuccess: async () => { + await auth.login(); toast.success("Logged in!"); navigate({ to: "/" }); }, @@ -54,7 +49,7 @@ function LoginPage() { const form = useForm({ defaultValues: { - username: "", + email: "", password: "", }, validators: { @@ -82,22 +77,23 @@ function LoginPage() { > { const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; return ( - Username + Email field.handleChange(e.target.value)} disabled={loginMutation.isPending} aria-invalid={isInvalid} - autoComplete="username" + autoComplete="email" /> {isInvalid && ( diff --git a/apps/dashboard/src/routes/register.tsx b/apps/dashboard/src/routes/register.tsx index 66c26dc3..f560a9a6 100644 --- a/apps/dashboard/src/routes/register.tsx +++ b/apps/dashboard/src/routes/register.tsx @@ -22,10 +22,10 @@ import { useAuth } from "@/contexts/auth-context"; import { register as apiRegister } from "@/lib/api"; const registerSchema = z.object({ - username: z + name: z .string() - .min(3, "Username must be at least 3 characters") - .max(32, "Username must be at most 32 characters"), + .min(1, "Name is required") + .max(64, "Name must be at most 64 characters"), email: z.string().email("Enter a valid email address"), password: z.string().min(8, "Password must be at least 8 characters"), }); @@ -40,16 +40,16 @@ function RegisterPage() { const registerMutation = useMutation({ mutationFn: ({ - username, + name, email, password, }: { - username: string; + name: string; email: string; password: string; - }) => apiRegister(username, email, password), - onSuccess: () => { - auth.login(); + }) => apiRegister(name, email, password), + onSuccess: async () => { + await auth.login(); toast.success("Account created!"); navigate({ to: "/" }); }, @@ -60,7 +60,7 @@ function RegisterPage() { const form = useForm({ defaultValues: { - username: "", + name: "", email: "", password: "", }, @@ -89,22 +89,23 @@ function RegisterPage() { > { const isInvalid = field.state.meta.isTouched && !field.state.meta.isValid; return ( - Username + Name field.handleChange(e.target.value)} disabled={registerMutation.isPending} aria-invalid={isInvalid} - autoComplete="username" + autoComplete="name" /> {isInvalid && ( diff --git a/apps/web/biome.json b/apps/web/biome.json index a2cf8bc3..27eae4a7 100644 --- a/apps/web/biome.json +++ b/apps/web/biome.json @@ -25,7 +25,7 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "preset": "recommended" }, "domains": { "next": "recommended", diff --git a/apps/web/src/app/(home)/_download/page.tsx b/apps/web/src/app/(home)/_download/page.tsx index 839a9ba4..fe2c1aa6 100644 --- a/apps/web/src/app/(home)/_download/page.tsx +++ b/apps/web/src/app/(home)/_download/page.tsx @@ -23,35 +23,35 @@ const releases: ReleaseInfo[] = [ { os: "linux", arch: "x86_64", - filename: "velopulent-cms-linux-x86_64", + filename: "vcms-linux-x86_64", size: "12.5 MB", sha256: "abc123...", }, { os: "linux", arch: "aarch64", - filename: "velopulent-cms-linux-aarch64", + filename: "vcms-linux-aarch64", size: "11.8 MB", sha256: "def456...", }, { os: "macos", arch: "x86_64", - filename: "velopulent-cms-macos-x86_64", + filename: "vcms-macos-x86_64", size: "13.2 MB", sha256: "ghi789...", }, { os: "macos", arch: "aarch64", - filename: "velopulent-cms-macos-aarch64", + filename: "vcms-macos-aarch64", size: "12.1 MB", sha256: "jkl012...", }, { os: "windows", arch: "x86_64", - filename: "velopulent-cms-windows-x86_64.exe", + filename: "vcms-windows-x86_64.exe", size: "14.5 MB", sha256: "mno345...", }, diff --git a/libs/proto/cms.proto b/libs/proto/cms.proto index 47ba6008..18cf7640 100644 --- a/libs/proto/cms.proto +++ b/libs/proto/cms.proto @@ -2,7 +2,7 @@ syntax = "proto3"; package cms.v1; -// Collection Service - Site-scoped operations (requires cms_site_* token) +// Collection Service - Site-scoped operations (requires vcms_site_* token) service CollectionService { rpc ListCollections(ListCollectionsRequest) returns (ListCollectionsResponse); rpc GetCollection(GetCollectionRequest) returns (Collection); @@ -11,7 +11,7 @@ service CollectionService { rpc DeleteCollection(DeleteCollectionRequest) returns (DeleteResponse); } -// Entry Service - Site-scoped operations (requires cms_site_* token) +// Entry Service - Site-scoped operations (requires vcms_site_* token) service EntryService { rpc ListEntries(ListEntriesRequest) returns (ListEntriesResponse); rpc GetEntry(GetEntryRequest) returns (Entry); @@ -25,13 +25,13 @@ service EntryService { rpc RestoreEntryRevision(RestoreEntryRevisionRequest) returns (Entry); } -// Singleton Service - Site-scoped operations (requires cms_site_* token) +// Singleton Service - Site-scoped operations (requires vcms_site_* token) service SingletonService { rpc GetSingleton(GetSingletonRequest) returns (Singleton); rpc UpdateSingleton(UpdateSingletonRequest) returns (Singleton); } -// File Service - Site-scoped operations (requires cms_site_* token) +// File Service - Site-scoped operations (requires vcms_site_* token) service FileService { rpc ListFiles(ListFilesRequest) returns (ListFilesResponse); rpc GetFile(GetFileRequest) returns (File); @@ -41,7 +41,7 @@ service FileService { rpc BatchRestoreFiles(BatchRestoreFilesRequest) returns (BatchOperationResponse); } -// Site Service - Site-scoped operations (requires cms_site_* token) +// Site Service - Site-scoped operations (requires vcms_site_* token) service SiteService { rpc GetSite(GetSiteRequest) returns (Site); rpc UpdateSite(UpdateSiteRequest) returns (Site); @@ -285,7 +285,7 @@ message SiteMember { string id = 1; string site_id = 2; string user_id = 3; - string username = 4; + string name = 4; string email = 5; string role = 6; string created_at = 7; @@ -300,7 +300,7 @@ message UpdateSiteRequest { optional string name = 2; } -// Webhook Service - Site-scoped operations (requires cms_site_* token) +// Webhook Service - Site-scoped operations (requires vcms_site_* token) service WebhookService { rpc ListWebhooks(ListWebhooksRequest) returns (ListWebhooksResponse); rpc GetWebhook(GetWebhookRequest) returns (SiteWebhook);