CI / ci (push) Canceled after 0s
1. Architecture chosen
Manager pushes environment variables into every business's .env, rather than businesses fetching config at runtime. Manager has no database of its own today (confirmed via code — no Prisma, no ORM dependency anywhere in manager/); every piece of its state is either an env var or a flat file under STACKS_DIR. A "runtime shared-config source" design would make every single email send (including OTP login) depend on Manager being reachable — a new failure mode this app has never had, for no real benefit. Instead:
Storage: one JSON file, stacks/manager/global-settings.json (gitignored), shaped for future sections (SMS, storage, backups, branding...) to slot in as sibling keys.
Secret at rest: the SMTP password is AES-256-GCM encrypted with a new MANAGER_SETTINGS_ENCRYPTION_KEY, never returned to the browser.
Rollout: saving complete settings automatically pushes the 7 EMAIL_* keys into every business's .env via the exact same surgical upsertEnvValues helper updateBusiness() already uses for name/domain edits, then force-recreates each container (composeUp(dir, {forceRecreate:true})) — the identical mechanism that already applies domain changes today.
New businesses get the current global settings baked into their .env at provisioning time — no manual step, ever.
2. Files changed
New (Manager): global-settings.ts (storage+encryption), email-rollout.ts (push-to-all-businesses), test-email.ts (standalone nodemailer test-send), dashboard/settings/ (page.tsx, actions.ts, email-settings-form.tsx).
Modified (Manager): compose-template.ts (optional email block in generated .env), provisioning.ts (reads global settings at creation), dashboard/layout.tsx (Settings nav link), package.json/package-lock.json (added nodemailer).
Modified (main app): brevo-email-provider.ts (added encryption mode + replyTo, backward-compatible), its test file (8 new tests).
Modified (docs): root .gitignore, .env.example, .env.business.example, manager/.env.example.
3. New volumes/databases/config files
None — no new Docker volume, no database. One new gitignored file, stacks/manager/global-settings.json, living inside the STACKS_DIR bind-mount Manager already has.
4. New environment variables
MANAGER_SETTINGS_ENCRYPTION_KEY (Manager only, required to use Settings) — 32-byte hex, generate with openssl rand -hex 32.
EMAIL_SMTP_ENCRYPTION and EMAIL_REPLY_TO (main app, both optional) — unset EMAIL_SMTP_ENCRYPTION preserves today's exact port-based inference, so existing deployments are unaffected.
Reply-to is now business-specific with global fallback,
5. How settings are persisted
stacks/manager/global-settings.json, written by saveEmailSettings(). The password is encrypted before writing; every other field is plain text (host, port, username, from-address, from-name, reply-to, encryption mode, last-test result) since none of those are secrets.
6. How business containers receive global settings
Via their own .env file (Docker Compose's env_file:, unchanged mechanism) — Manager writes the 7 EMAIL_* keys into it and recreates the container. Nothing in the business app itself needed a new runtime dependency; getEmailProvider() still just reads process.env once at process start, exactly as before.
7. What happens to existing businesses when settings change
Saving complete settings (host+username+password+from-address all present) automatically loops every business, updates its .env, and force-recreates its container in one action — the UI reports "applied to N of M businesses" with per-business failure reasons. A separate "Reapply to all businesses" button retries this without re-entering anything, for recovering from a business that was down during the last save. Saving incomplete settings persists them as a draft and touches no business (verified live — this is what stops a business from being flipped into a broken "brevo with blank credentials" state).
8. Manual deployment/update steps on the VPS
Generate a key: openssl rand -hex 32, add it as MANAGER_SETTINGS_ENCRYPTION_KEY to stacks/manager/.env.
Rebuild/redeploy the Manager image and the main app image (both changed) as usual.
Open Manager → Settings, fill in real SMTP details, send a test email, then Save — this rolls out to every existing business automatically. Nothing else needs to run by hand.
9. Security considerations
Password never logged, never returned to the browser after save (only a hasPassword boolean) — confirmed live: the form re-shows a masked placeholder, not the real value.
Test-email errors are categorized (auth/connection/timeout) before display — the raw SMTP error is never forwarded.
Encrypted at rest with AES-256-GCM; losing/rotating the key makes the stored password undecryptable (documented in manager/.env.example).
Settings routes sit under manager/src/proxy.ts's existing auth gate — no new auth surface.
Validated before rollout: malformed addresses/ports are rejected before Save, and rollout only fires when configuration is actually complete.
On JSON vs. database for Manager: JSON is the right call here. Reasoning:
Manager has zero DB dependencies today, deliberately — its own package.json description says an earlier attempt at giving it business-data access "repeatedly broke." Introducing SQLite/Prisma just for one settings blob breaks that design principle for not much gain.
The access pattern is a poor fit for a DB anyway: read on page-load/provisioning/rollout, written by a human clicking Save occasionally. No queries, no joins, no need for transactions beyond "read-modify-write one small file."
Reconsider if: multiple admins need to edit concurrently with conflict detection, you want an audit history of who-changed-what-when beyond "last test result," or the settings graph grows relational (e.g., per-business overrides tied by foreign key to businesses) rather than a flat global blob. None of that applies yet.
111 lines
5.3 KiB
Bash
111 lines
5.3 KiB
Bash
# One business's config on the shared multi-business VPS setup — see
|
|
# stacks/infra/compose.yaml for the one-time setup this depends on
|
|
# (creating this business's database on the shared MySQL server) and
|
|
# stacks/<slug>/compose.yaml for how this file is used. Copy this to
|
|
# `stacks/<slug>/.env` (e.g. `stacks/blake-heritage/.env` — kept out of
|
|
# git, see .gitignore) per business and fill in real values. Nothing here
|
|
# is a real credential. Normally the management UI (manager/) generates
|
|
# this file for you — this template is for the by-hand setup path.
|
|
#
|
|
# The app starts and runs a complete first-install flow with ONLY the
|
|
# "Required for basic startup" section below filled in — every other
|
|
# integration (email, push, Google sign-in, Google Calendar) shows a
|
|
# disabled state rather than crashing when its variables are left blank.
|
|
|
|
# ── Required for basic startup ─────────────────────────────────────────
|
|
# Manager-only bookkeeping (shown on the dashboard/edit screen, editable
|
|
# there) — the app itself never reads this; its own business name lives
|
|
# in the database, set via /install.
|
|
BUSINESS_NAME="Blake Heritage Barbershop"
|
|
# The public domain this business is served on — must match the .caddy
|
|
# site file's own domain (see stacks/infra/sites/.gitkeep) and that
|
|
# domain's real DNS (pointed at the VPS) before Caddy can issue a
|
|
# certificate for it.
|
|
DOMAIN="blake-heritage.example.com"
|
|
|
|
# The app reaches MySQL via the shared infra's service hostname
|
|
# `next-chair-mysql`, never `localhost` — see stacks/infra/compose.yaml.
|
|
# DATABASE_NAME/USER/PASSWORD must match exactly what you created for
|
|
# this business on the shared server (see stacks/infra/compose.yaml's
|
|
# "CREATE DATABASE ..." step) — this file doesn't create them, it only
|
|
# tells the app what to connect as.
|
|
DATABASE_HOST="next-chair-mysql"
|
|
DATABASE_PORT="3306"
|
|
DATABASE_NAME="blake_heritage"
|
|
DATABASE_USER="blake_heritage"
|
|
DATABASE_PASSWORD="replace-me"
|
|
# Generate with: npx auth secret
|
|
AUTH_SECRET="replace-me-with-a-32-byte-random-string"
|
|
# Auth.js needs to trust the host it's served from when not on localhost:3000.
|
|
AUTH_TRUST_HOST="true"
|
|
NEXT_PUBLIC_APP_URL="https://blake-heritage.example.com"
|
|
# One-time setup token the installation wizard (/install) requires before
|
|
# it will create the first business/administrator — generate with:
|
|
# openssl rand -hex 32
|
|
INSTALLATION_TOKEN="replace-me"
|
|
# Shared secret an external scheduler presents to
|
|
# /api/cron/process-notifications — generate with: openssl rand -hex 32
|
|
CRON_SECRET="replace-me"
|
|
|
|
# ── Super Administrator (optional — see scripts/ensure-super-admin.ts) ──
|
|
# Platform-level account with every Owner permission for THIS business.
|
|
# Not run automatically by this compose file (that's a Docker-entrypoint-
|
|
# only behaviour, see docker/entrypoint.sh) — run it manually, once:
|
|
# docker exec -it next-chair-blake-heritage npx tsx scripts/ensure-super-admin.ts
|
|
# Leave SUPER_ADMIN_EMAIL blank to skip entirely and use the /install
|
|
# wizard's own administrator account instead.
|
|
SUPER_ADMIN_EMAIL=""
|
|
SUPER_ADMIN_PASSWORD=""
|
|
SUPER_ADMIN_FIRST_NAME=""
|
|
SUPER_ADMIN_LAST_NAME=""
|
|
|
|
# ── Google sign-in (optional — "Continue with Google" on Sign In) ─────
|
|
AUTH_GOOGLE_ID=""
|
|
AUTH_GOOGLE_SECRET=""
|
|
|
|
# ── Google Calendar sync (optional — staff-level, see /staff/settings/calendar) ─
|
|
# From https://console.cloud.google.com/apis/credentials — a separate
|
|
# OAuth client from AUTH_GOOGLE_ID/SECRET above (different scopes).
|
|
GOOGLE_CALENDAR_CLIENT_ID=""
|
|
GOOGLE_CALENDAR_CLIENT_SECRET=""
|
|
GOOGLE_CALENDAR_REDIRECT_URI="https://blake-heritage.example.com/api/staff/calendar/google/callback"
|
|
# Encrypts stored refresh tokens at rest — generate with: openssl rand -hex 32
|
|
CALENDAR_TOKEN_ENCRYPTION_KEY=""
|
|
|
|
# ── Media Manager (optional — see src/lib/media/media-storage.ts) ──────
|
|
# Max upload size in bytes for the Media Manager. Defaults to 5,000,000
|
|
# (5MB) if unset.
|
|
MEDIA_MAX_UPLOAD_BYTES="5000000"
|
|
|
|
# ── Google Reviews (optional — see /owner/settings/reviews) ────────────
|
|
# From https://console.cloud.google.com/apis/credentials — needs "Places
|
|
# API (New)" enabled on that project (a different product from the older
|
|
# "Places API" — enabling the wrong one returns REQUEST_DENIED). Leave
|
|
# blank to keep review sync disabled entirely (Home shows clearly-labelled
|
|
# demo reviews instead).
|
|
GOOGLE_PLACES_API_KEY=""
|
|
|
|
# ── Email (optional — defaults to logging to the console instead of sending) ─
|
|
# Managed centrally by the Manager's Settings > Email page and pushed
|
|
# into every business's .env automatically — see manager/src/lib/
|
|
# global-settings.ts. There's normally no reason to hand-edit these for
|
|
# one business only; this by-hand template still needs them set if
|
|
# you're not using the Manager.
|
|
EMAIL_PROVIDER="console"
|
|
EMAIL_SMTP_HOST="smtp-relay.brevo.com"
|
|
EMAIL_SMTP_PORT="587"
|
|
EMAIL_SMTP_USERNAME=""
|
|
EMAIL_SMTP_PASSWORD=""
|
|
EMAIL_SMTP_ENCRYPTION="starttls"
|
|
EMAIL_FROM_ADDRESS=""
|
|
EMAIL_FROM_NAME=""
|
|
EMAIL_REPLY_TO=""
|
|
|
|
# ── Web Push (optional — defaults to logging to the console instead of sending) ─
|
|
# Generate with: npx web-push generate-vapid-keys
|
|
PUSH_PROVIDER="console"
|
|
VAPID_PUBLIC_KEY=""
|
|
NEXT_PUBLIC_VAPID_PUBLIC_KEY=""
|
|
VAPID_PRIVATE_KEY=""
|
|
VAPID_SUBJECT="mailto:owner@example.com"
|