From d9b073ad6c504dfc08e2da1dd75b4c6eec60a27b Mon Sep 17 00:00:00 2001 From: Steven RYDELL Date: Sat, 1 Aug 2026 22:10:43 +0200 Subject: [PATCH] feat(dev): add scripts/dev-seed to populate sample test data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New scripts/dev-seed.sh (+ .ps1), run after dev-server-init: seeds the test server with Users (alice, bob, carol — spanning User/Admin roles, group membership, an alias), Groups (engineering, marketing), Mailing Lists (newsletter, support), and custom Roles (Support Agent, Read-only Auditor), so each admin panel screen has real data to test against instead of an empty list. Idempotent (skips if 'alice' already exists), documented as workflow step 3 in DEVELOPMENT.md (renumbering the steps after it) and listed in AGENTS.md. Verified end-to-end with both scripts against a fresh server, including idempotency and the resulting UI (Accounts, Groups, Mailing Lists, Roles all populated as expected). --- .gitignore | 2 + AGENTS.md | 1 + DEVELOPMENT.md | 42 ++++++++++--- scripts/dev-seed.ps1 | 139 +++++++++++++++++++++++++++++++++++++++++++ scripts/dev-seed.sh | 78 ++++++++++++++++++++++++ 5 files changed, 255 insertions(+), 7 deletions(-) create mode 100644 scripts/dev-seed.ps1 create mode 100644 scripts/dev-seed.sh diff --git a/.gitignore b/.gitignore index d3cc6b7..3ae776f 100644 --- a/.gitignore +++ b/.gitignore @@ -28,6 +28,8 @@ scripts/* !scripts/dev-token.sh !scripts/dev-server-init.ps1 !scripts/dev-server-init.sh +!scripts/dev-seed.ps1 +!scripts/dev-seed.sh *.md !README.md !CHANGELOG.md diff --git a/AGENTS.md b/AGENTS.md index d82d5e4..0edf320 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,7 @@ Community fork of [stalwartlabs/webui](https://github.com/stalwartlabs/webui), a - `npm run dev:server` - Start a local disposable Stalwart test server (Docker); `npm run dev:server:down` to stop it - `bash scripts/dev-server-init.sh` (or `pwsh ./scripts/dev-server-init.ps1`) - One-time setup of that server (bootstrap, dev admin account, 3h default token lifetime); idempotent +- `bash scripts/dev-seed.sh` (or `pwsh ./scripts/dev-seed.ps1`) - Optional: seed sample Users/Groups/Mailing Lists/Roles for testing; idempotent - `bash scripts/dev-token.sh [duration_seconds]` (or `pwsh ./scripts/dev-token.ps1 [-DurationSeconds N]`) - Get a dev access token from that server, 3h by default - `npm run dev` - Dev server (proxies `/api` and `/jmap` to the test server) - `npm run typecheck` - `tsc --noEmit` diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index e37b96e..99d7091 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -65,7 +65,33 @@ idempotent — safe to re-run, it no-ops once the server is already bootstrapped. You only need to re-run it after `docker compose down -v` (which wipes the volumes). -## 3. Get an access token +## 3. Seed sample data (optional) + +```bash +# Windows / PowerShell +pwsh ./scripts/dev-seed.ps1 + +# Linux / macOS / any POSIX shell (including most AI agent sandboxes) +bash ./scripts/dev-seed.sh +``` + +Populates Users, Groups, Mailing Lists, and Roles with sample data so +every admin panel screen has something to test against: + +- **Users**: `alice@example.org` / `AlicePass123!` (User role, in + engineering, 1 alias), `bob@example.org` / `BobPass123!` (User role, in + engineering + marketing), `carol@example.org` / `CarolPass123!` (Admin + role, in marketing) — alongside the `devadmin`/`admin` accounts from + step 2. +- **Groups**: `engineering`, `marketing`. +- **Mailing lists**: `newsletter@example.org`, `support@example.org`. +- **Roles**: `Support Agent`, `Read-only Auditor` (custom, alongside the + built-in System Administrator/Tenant Administrator/Group/User roles). + +Idempotent — does nothing if `alice` already exists. Only needed once per +fresh volume, same as step 2. + +## 4. Get an access token For local development it's simpler to skip interactive login and use a bearer token directly via `VITE_ACCESS_TOKEN` (see `.env.development`). @@ -92,7 +118,7 @@ The `STALWART_RECOVERY_ADMIN` account is intentionally not used here: it's a break-glass credential and its tokens always expire in a fixed 1 hour regardless of server configuration, so it can't honor a custom duration. -## 4. Run the WebUI +## 5. Run the WebUI ```bash npm install # first time only @@ -102,7 +128,7 @@ npm run dev Open `http://localhost:5173`. You should land directly in the admin panel (no login screen) since `VITE_ACCESS_TOKEN` is set. -## 5. Verify your change +## 6. Verify your change ```bash npm run typecheck @@ -125,6 +151,7 @@ npm run dev:server:down docker compose down -v # also removes the stalwart-etc/stalwart-data volumes npm run dev:server bash ./scripts/dev-server-init.sh # re-run: fresh volume needs bootstrapping again +bash ./scripts/dev-seed.sh # optional: re-seed sample data too ``` ## Troubleshooting @@ -148,11 +175,12 @@ bash ./scripts/dev-server-init.sh # re-run: fresh volume needs bootstrapping a ## Notes for AI agents -- This whole workflow (steps 1–4) is scriptable end-to-end without a +- This whole workflow (steps 1–5) is scriptable end-to-end without a browser: `npm run dev:server`, then `bash scripts/dev-server-init.sh` - (first time only), then `bash scripts/dev-token.sh`, then the app is - reachable at `http://localhost:5173` with `VITE_ACCESS_TOKEN` already - set. Verify backend connectivity directly with `curl`, e.g. + and `bash scripts/dev-seed.sh` (first time only), then + `bash scripts/dev-token.sh`, then the app is reachable at + `http://localhost:5173` with `VITE_ACCESS_TOKEN` already set. Verify + backend connectivity directly with `curl`, e.g. `curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/jmap/session`. - Read [AGENTS.md](AGENTS.md) before touching anything under `src/` — the schema-fidelity rule applies to all development, local test server diff --git a/scripts/dev-seed.ps1 b/scripts/dev-seed.ps1 new file mode 100644 index 0000000..f4630ae --- /dev/null +++ b/scripts/dev-seed.ps1 @@ -0,0 +1,139 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Populates the disposable Stalwart test server with sample Users, Groups, + Mailing Lists, and Roles, so each admin panel screen has something to + click through and test against. + +.DESCRIPTION + Local development only. Run after scripts/dev-server-init.ps1. + Idempotent: does nothing if the seed users already exist. Only a fresh + volume (`docker compose down -v`) clears this data. +#> +param( + [string]$ApiBaseUrl = "http://localhost:8080" +) + +$ErrorActionPreference = 'Stop' + +$DevAdminAccount = "devadmin@example.org" +$DevAdminSecret = "DevAdminPass123!" + +$creds = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes("$($DevAdminAccount):$($DevAdminSecret)")) +$authHeader = @{ Authorization = "Basic $creds" } + +function Invoke-Jmap($body) { + Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/" -Method Post -ContentType "application/json" -Headers $authHeader -Body ($body | ConvertTo-Json -Depth 10 -Compress) -TimeoutSec 15 +} + +try { + $session = Invoke-RestMethod -Uri "$ApiBaseUrl/jmap/session" -Headers $authHeader -TimeoutSec 15 +} catch { + throw "Could not reach $ApiBaseUrl as $DevAdminAccount. Run scripts/dev-server-init.ps1 first." +} +$accountId = $session.primaryAccounts.'urn:stalwart:jmap' + +$existing = Invoke-Jmap @{ + using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap") + methodCalls = @(, @("x:Account/query", @{ accountId = $accountId; filter = @{ text = "alice" } }, "0")) +} +if ($existing.methodResponses[0][1].ids.Count -gt 0) { + Write-Host "Seed data already present (found 'alice'), skipping. Use 'docker compose down -v' + dev-server-init.ps1 to start fresh." + return +} + +$domainResult = Invoke-Jmap @{ + using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap") + methodCalls = @(, @("x:Domain/query", @{ accountId = $accountId }, "0")) +} +$domainId = $domainResult.methodResponses[0][1].ids[0] + +Write-Host "Creating groups..." +$groupsResult = Invoke-Jmap @{ + using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap") + methodCalls = @(, @("x:Account/set", @{ + accountId = $accountId + create = @{ + g_eng = @{ "@type" = "Group"; name = "engineering"; domainId = $domainId; description = "Engineering team" } + g_mkt = @{ "@type" = "Group"; name = "marketing"; domainId = $domainId; description = "Marketing team" } + } + }, "0")) +} +$groupEngId = $groupsResult.methodResponses[0][1].created.g_eng.id +$groupMktId = $groupsResult.methodResponses[0][1].created.g_mkt.id + +Write-Host "Creating users..." +$usersResult = Invoke-Jmap @{ + using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap") + methodCalls = @(, @("x:Account/set", @{ + accountId = $accountId + create = @{ + u_alice = @{ + "@type" = "User"; name = "alice"; domainId = $domainId; description = "Alice Smith" + roles = @{ "@type" = "User" } + credentials = @{ "0" = @{ "@type" = "Password"; secret = "AlicePass123!" } } + memberGroupIds = @{ $groupEngId = $true } + } + u_bob = @{ + "@type" = "User"; name = "bob"; domainId = $domainId; description = "Bob Jones" + roles = @{ "@type" = "User" } + credentials = @{ "0" = @{ "@type" = "Password"; secret = "BobPass123!" } } + memberGroupIds = @{ $groupEngId = $true; $groupMktId = $true } + } + u_carol = @{ + "@type" = "User"; name = "carol"; domainId = $domainId; description = "Carol Diaz" + roles = @{ "@type" = "Admin" } + credentials = @{ "0" = @{ "@type" = "Password"; secret = "CarolPass123!" } } + memberGroupIds = @{ $groupMktId = $true } + } + } + }, "0")) +} +$userAliceId = $usersResult.methodResponses[0][1].created.u_alice.id + +Write-Host "Adding an alias to alice (for the Aliases column)..." +Invoke-Jmap @{ + using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap") + methodCalls = @(, @("x:Account/set", @{ + accountId = $accountId + update = @{ $userAliceId = @{ aliases = @{ "0" = @{ name = "a.smith"; domainId = $domainId } } } } + }, "0")) +} | Out-Null + +Write-Host "Creating mailing lists..." +Invoke-Jmap @{ + using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap") + methodCalls = @(, @("x:MailingList/set", @{ + accountId = $accountId + create = @{ + m_news = @{ + name = "newsletter"; domainId = $domainId; description = "Company newsletter" + recipients = @{ "alice@example.org" = $true; "bob@example.org" = $true; "carol@example.org" = $true } + } + m_support = @{ + name = "support"; domainId = $domainId; description = "Support queue" + recipients = @{ "bob@example.org" = $true } + } + } + }, "0")) +} | Out-Null + +Write-Host "Creating roles..." +Invoke-Jmap @{ + using = @("urn:ietf:params:jmap:core", "urn:stalwart:jmap") + methodCalls = @(, @("x:Role/set", @{ + accountId = $accountId + create = @{ + r_support = @{ description = "Support Agent"; enabledPermissions = @{ authenticate = $true; emailSend = $true; emailReceive = $true } } + r_audit = @{ description = "Read-only Auditor"; enabledPermissions = @{ authenticate = $true } } + } + }, "0")) +} | Out-Null + +Write-Host "Done. Seeded:" +Write-Host " Users: alice@example.org / AlicePass123! (User role, in engineering, 1 alias)" +Write-Host " bob@example.org / BobPass123! (User role, in engineering + marketing)" +Write-Host " carol@example.org / CarolPass123! (Admin role, in marketing)" +Write-Host " Groups: engineering, marketing" +Write-Host " Mailing lists: newsletter@example.org, support@example.org" +Write-Host " Roles: Support Agent, Read-only Auditor" diff --git a/scripts/dev-seed.sh b/scripts/dev-seed.sh new file mode 100644 index 0000000..4682ddb --- /dev/null +++ b/scripts/dev-seed.sh @@ -0,0 +1,78 @@ +#!/usr/bin/env bash +# Local development only. Populates the disposable Stalwart test server +# (docker-compose.yml) with sample data across Users, Groups, Mailing Lists, +# and Roles, so each admin panel screen has something to click through and +# test against. Run after scripts/dev-server-init.sh. +# +# Idempotent: does nothing if the seed users already exist (re-running +# scripts/dev-server-init.sh does not wipe this data; only a fresh volume +# via `docker compose down -v` does). +set -euo pipefail + +API_BASE_URL="${1:-http://localhost:8080}" +DEVADMIN_ACCOUNT="devadmin@example.org" +DEVADMIN_SECRET="DevAdminPass123!" + +jmap() { + curl -sf --compressed -u "$DEVADMIN_ACCOUNT:$DEVADMIN_SECRET" \ + -X POST -H "Content-Type: application/json" -d "$1" "$API_BASE_URL/jmap/" +} + +extract_id() { + # $1 = JSON response, $2 = create-id key (e.g. "u1") + printf '%s' "$1" | grep -o "\"$2\":{\"id\":\"[^\"]*\"" | head -1 | grep -o '"id":"[^"]*"' | cut -d'"' -f4 +} + +SESSION=$(curl -sf --compressed -u "$DEVADMIN_ACCOUNT:$DEVADMIN_SECRET" "$API_BASE_URL/jmap/session") || { + echo "Could not reach $API_BASE_URL as $DEVADMIN_ACCOUNT. Run scripts/dev-server-init.sh first." >&2 + exit 1 +} +ACCOUNT_ID=$(printf '%s' "$SESSION" | grep -o '"urn:stalwart:jmap":"[^"]*"' | cut -d'"' -f4) + +EXISTING=$(jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Account/query\",{\"accountId\":\"$ACCOUNT_ID\",\"filter\":{\"text\":\"alice\"}},\"0\"]]}") +if printf '%s' "$EXISTING" | grep -q '"ids":\["'; then + echo "Seed data already present (found 'alice'), skipping. Use 'docker compose down -v' + dev-server-init.sh to start fresh." + exit 0 +fi + +DOMAIN_RESULT=$(jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Domain/query\",{\"accountId\":\"$ACCOUNT_ID\"},\"0\"]]}") +DOMAIN_ID=$(printf '%s' "$DOMAIN_RESULT" | grep -o '"ids":\["[^"]*"' | cut -d'"' -f4) + +echo "Creating groups..." +GROUPS_RESULT=$(jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Account/set\",{\"accountId\":\"$ACCOUNT_ID\",\"create\":{ + \"g_eng\":{\"@type\":\"Group\",\"name\":\"engineering\",\"domainId\":\"$DOMAIN_ID\",\"description\":\"Engineering team\"}, + \"g_mkt\":{\"@type\":\"Group\",\"name\":\"marketing\",\"domainId\":\"$DOMAIN_ID\",\"description\":\"Marketing team\"} +}},\"0\"]]}") +GROUP_ENG_ID=$(extract_id "$GROUPS_RESULT" g_eng) +GROUP_MKT_ID=$(extract_id "$GROUPS_RESULT" g_mkt) + +echo "Creating users..." +USERS_RESULT=$(jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Account/set\",{\"accountId\":\"$ACCOUNT_ID\",\"create\":{ + \"u_alice\":{\"@type\":\"User\",\"name\":\"alice\",\"domainId\":\"$DOMAIN_ID\",\"description\":\"Alice Smith\",\"roles\":{\"@type\":\"User\"},\"credentials\":{\"0\":{\"@type\":\"Password\",\"secret\":\"AlicePass123!\"}},\"memberGroupIds\":{\"$GROUP_ENG_ID\":true}}, + \"u_bob\":{\"@type\":\"User\",\"name\":\"bob\",\"domainId\":\"$DOMAIN_ID\",\"description\":\"Bob Jones\",\"roles\":{\"@type\":\"User\"},\"credentials\":{\"0\":{\"@type\":\"Password\",\"secret\":\"BobPass123!\"}},\"memberGroupIds\":{\"$GROUP_ENG_ID\":true,\"$GROUP_MKT_ID\":true}}, + \"u_carol\":{\"@type\":\"User\",\"name\":\"carol\",\"domainId\":\"$DOMAIN_ID\",\"description\":\"Carol Diaz\",\"roles\":{\"@type\":\"Admin\"},\"credentials\":{\"0\":{\"@type\":\"Password\",\"secret\":\"CarolPass123!\"}},\"memberGroupIds\":{\"$GROUP_MKT_ID\":true}} +}},\"0\"]]}") +USER_ALICE_ID=$(extract_id "$USERS_RESULT" u_alice) + +echo "Adding an alias to alice (for the Aliases column)..." +jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Account/set\",{\"accountId\":\"$ACCOUNT_ID\",\"update\":{\"$USER_ALICE_ID\":{\"aliases\":{\"0\":{\"name\":\"a.smith\",\"domainId\":\"$DOMAIN_ID\"}}}}},\"0\"]]}" > /dev/null + +echo "Creating mailing lists..." +jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:MailingList/set\",{\"accountId\":\"$ACCOUNT_ID\",\"create\":{ + \"m_news\":{\"name\":\"newsletter\",\"domainId\":\"$DOMAIN_ID\",\"description\":\"Company newsletter\",\"recipients\":{\"alice@example.org\":true,\"bob@example.org\":true,\"carol@example.org\":true}}, + \"m_support\":{\"name\":\"support\",\"domainId\":\"$DOMAIN_ID\",\"description\":\"Support queue\",\"recipients\":{\"bob@example.org\":true}} +}},\"0\"]]}" > /dev/null + +echo "Creating roles..." +jmap "{\"using\":[\"urn:ietf:params:jmap:core\",\"urn:stalwart:jmap\"],\"methodCalls\":[[\"x:Role/set\",{\"accountId\":\"$ACCOUNT_ID\",\"create\":{ + \"r_support\":{\"description\":\"Support Agent\",\"enabledPermissions\":{\"authenticate\":true,\"emailSend\":true,\"emailReceive\":true}}, + \"r_audit\":{\"description\":\"Read-only Auditor\",\"enabledPermissions\":{\"authenticate\":true}} +}},\"0\"]]}" > /dev/null + +echo "Done. Seeded:" +echo " Users: alice@example.org / AlicePass123! (User role, in engineering, 1 alias)" +echo " bob@example.org / BobPass123! (User role, in engineering + marketing)" +echo " carol@example.org / CarolPass123! (Admin role, in marketing)" +echo " Groups: engineering, marketing" +echo " Mailing lists: newsletter@example.org, support@example.org" +echo " Roles: Support Agent, Read-only Auditor"