diff --git a/.env.business.example b/.env.business.example index e8184c0..159ae45 100644 --- a/.env.business.example +++ b/.env.business.example @@ -49,18 +49,23 @@ NEXT_PUBLIC_APP_URL="https://example-business.example.com" # 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 +# /api/cron/process-notifications and /api/cron/sync-google-reviews — +# generate with: openssl rand -hex 32. Without something actually calling +# those on a schedule, queued notifications (staff invites, reminders, +# etc.) never get sent — see the root README's "Notification delivery" +# section and stacks/infra/scripts/process-cron.sh for the one-time +# crontab setup this requires. 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-example-business 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. +# Runs automatically on every container start (see docker/entrypoint.sh) +# — filling these in and recreating the container is enough, no manual +# `docker exec` needed. No password — signs in the same OTP way as +# everyone else, via a one-time code emailed to SUPER_ADMIN_EMAIL. 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="" diff --git a/.env.example b/.env.example index 3230702..7227140 100644 --- a/.env.example +++ b/.env.example @@ -82,12 +82,12 @@ INSTALLATION_TOKEN="" # Super Administrator (Phase 9) — platform-level account with every Owner # permission, provisioned from these env vars rather than the installation # wizard (see scripts/ensure-super-admin.ts, run on every container start). -# Has no StaffProfile by design: never appears in Team, the customer-facing -# barber picker, or staff totals, and can never receive an appointment. All -# four optional — leave SUPER_ADMIN_EMAIL unset to skip provisioning one -# entirely. Re-run to rotate the password (re-hashed every run). +# No password — signs in the same OTP way as everyone else, via a +# one-time code emailed to SUPER_ADMIN_EMAIL. Has no StaffProfile by +# design: never appears in Team, the customer-facing barber picker, or +# staff totals, and can never receive an appointment. All three optional +# — leave SUPER_ADMIN_EMAIL unset to skip provisioning one entirely. SUPER_ADMIN_EMAIL="" -SUPER_ADMIN_PASSWORD="" SUPER_ADMIN_FIRST_NAME="" SUPER_ADMIN_LAST_NAME="" diff --git a/README.md b/README.md index a67e408..6adbc9c 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,38 @@ this for you), then finish its setup by visiting Repeat this step for each additional business, whenever you want one. +### 6. Notification delivery (required once, covers every business) + +Staff invitation emails, appointment reminders, loyalty reward emails, +and Google Reviews sync are **not sent automatically** — the app only +queues them; something has to actually trigger delivery on a schedule by +calling two `CRON_SECRET`-gated endpoints +(`/api/cron/process-notifications`, `/api/cron/sync-google-reviews` — see +their own route files for the full reasoning). Skip this step and every +business's queued notifications just sit as `PENDING` forever — the UI +still says "an invitation email has been sent" the moment it's queued, +regardless of whether anything has actually delivered it yet. + +[stacks/infra/scripts/process-cron.sh](stacks/infra/scripts/process-cron.sh) +calls both endpoints for **every** business under `STACKS_DIR` in one +run, so a single crontab entry covers businesses added later too — no +per-business cron line to maintain. Add this to the VPS's crontab +(`crontab -e`) once: + +```bash +# Deliver queued notifications every 2 minutes. +*/2 * * * * STACKS_DIR=/opt/nextchair/stacks /opt/nextchair/stacks/infra/scripts/process-cron.sh notifications >> /var/log/nextchair-cron.log 2>&1 + +# Refresh Google Reviews once a day. +0 3 * * * STACKS_DIR=/opt/nextchair/stacks /opt/nextchair/stacks/infra/scripts/process-cron.sh reviews >> /var/log/nextchair-cron.log 2>&1 +``` + +(Adjust `STACKS_DIR` if you cloned this repo somewhere other than +`/opt/nextchair`.) To confirm it's working, or to flush a notification +that's stuck pending right now, run either line's command by hand and +check `/var/log/nextchair-cron.log` — a healthy run logs ` +process-notifications ok` for every business. + ## Updating after a code change For an existing NextChair server, **do not re-run the fresh VPS setup above** and do not overwrite your production `.env` files. diff --git a/e2e/global-setup.ts b/e2e/global-setup.ts index 83a44c6..dc47a9a 100644 --- a/e2e/global-setup.ts +++ b/e2e/global-setup.ts @@ -25,7 +25,6 @@ export default function globalSetup() { // when the caller hasn't already set their own (same fallback pattern // as AUTH_SECRET in playwright.config.ts). SUPER_ADMIN_EMAIL: process.env.SUPER_ADMIN_EMAIL || "superadmin@blakeheritage.co.uk", - SUPER_ADMIN_PASSWORD: process.env.SUPER_ADMIN_PASSWORD || "password123", SUPER_ADMIN_FIRST_NAME: process.env.SUPER_ADMIN_FIRST_NAME || "Super", SUPER_ADMIN_LAST_NAME: process.env.SUPER_ADMIN_LAST_NAME || "Admin", }; diff --git a/manager/src/app/dashboard/settings/actions.ts b/manager/src/app/dashboard/settings/actions.ts index b17c667..a24067b 100644 --- a/manager/src/app/dashboard/settings/actions.ts +++ b/manager/src/app/dashboard/settings/actions.ts @@ -6,15 +6,19 @@ import { generateVapidKeys, isEmailConfigComplete, isPushConfigComplete, + isSuperAdminConfigComplete, + readSuperAdminSettings, recordLastTestResult, resolveEmailSettings, resolvePushSettings, saveEmailSettings, savePushSubject, + saveSuperAdminSettings, VapidKeysAlreadyExistError, type EmailEncryptionMode, type EmailSettingsInput, } from "@/lib/global-settings"; +import { applySuperAdminToAllBusinesses, type SuperAdminRolloutResult } from "@/lib/super-admin-rollout"; import { sendTestSmtpEmail } from "@/lib/test-email"; function parseEncryption(value: FormDataEntryValue | null): EmailEncryptionMode { @@ -238,3 +242,59 @@ export async function reapplyPushSettingsAction( const rollout = await applyPushSettingsToAllBusinesses(resolved); return { error: null, rollout }; } + +// ── Super Administrator ───────────────────────────────────────────────── + +export interface SaveSuperAdminState { + error: string | null; + saved: boolean; + rollout: SuperAdminRolloutResult[] | null; +} + +/** + * Saves the global default Super Administrator and immediately rolls it + * out to every existing business — unlike email/push, there's no + * "incomplete config, save as a draft" case worth distinguishing (an + * email is either present or it isn't, no secondary field like a + * password gates completeness), so this always rolls out once the email + * itself validates. New businesses pick this up automatically at + * provisioning time (see provisioning.ts) — this action is what applies + * a change to businesses that already exist. + */ +export async function saveSuperAdminSettingsAction( + _prevState: SaveSuperAdminState, + formData: FormData, +): Promise { + const email = String(formData.get("email") ?? "").trim(); + const firstName = String(formData.get("firstName") ?? "").trim(); + const lastName = String(formData.get("lastName") ?? "").trim(); + + if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) { + return { error: "Enter a valid email address.", saved: false, rollout: null }; + } + + const saved = await saveSuperAdminSettings({ email, firstName, lastName }); + if (!isSuperAdminConfigComplete(saved)) { + return { error: null, saved: true, rollout: null }; + } + + const rollout = await applySuperAdminToAllBusinesses(saved); + return { error: null, saved: true, rollout }; +} + +export interface ReapplySuperAdminState { + error: string | null; + rollout: SuperAdminRolloutResult[] | null; +} + +export async function reapplySuperAdminAction( + _prevState: ReapplySuperAdminState, + _formData: FormData, +): Promise { + const saved = await readSuperAdminSettings(); + if (!isSuperAdminConfigComplete(saved)) { + return { error: "No Super Administrator email is saved yet — nothing to reapply.", rollout: null }; + } + const rollout = await applySuperAdminToAllBusinesses(saved); + return { error: null, rollout }; +} diff --git a/manager/src/app/dashboard/settings/page.tsx b/manager/src/app/dashboard/settings/page.tsx index 74db5c9..01a7e65 100644 --- a/manager/src/app/dashboard/settings/page.tsx +++ b/manager/src/app/dashboard/settings/page.tsx @@ -1,19 +1,20 @@ import type { Metadata } from "next"; -import { readEmailSettingsView, readPushSettingsView } from "@/lib/global-settings"; +import { readEmailSettingsView, readPushSettingsView, readSuperAdminSettings } from "@/lib/global-settings"; import { EmailSettingsForm } from "./email-settings-form"; import { PushSettingsForm } from "./push-settings-form"; +import { SuperAdminSettingsForm } from "./super-admin-settings-form"; export const metadata: Metadata = { title: "Settings" }; /** * Platform-wide settings shared by every business on this installation — * see src/lib/global-settings.ts's own header for the storage design. - * "Email" and "Push Notifications" exist today; a later section (SMS, - * storage, backups, branding, locale, monitoring) is a sibling here, not - * a rewrite. + * "Email", "Push Notifications", and "Super Administrator" exist today; a + * later section (SMS, storage, backups, branding, locale, monitoring) is + * a sibling here, not a rewrite. */ export default async function SettingsPage() { - const [email, push] = await Promise.all([readEmailSettingsView(), readPushSettingsView()]); + const [email, push, superAdmin] = await Promise.all([readEmailSettingsView(), readPushSettingsView(), readSuperAdminSettings()]); return (
@@ -23,6 +24,7 @@ export default async function SettingsPage() {
+ ); } diff --git a/manager/src/app/dashboard/settings/super-admin-settings-form.tsx b/manager/src/app/dashboard/settings/super-admin-settings-form.tsx new file mode 100644 index 0000000..f75c2a9 --- /dev/null +++ b/manager/src/app/dashboard/settings/super-admin-settings-form.tsx @@ -0,0 +1,151 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useActionState, useEffect, useState } from "react"; +import type { SuperAdminSettings } from "@/lib/global-settings"; +import { + reapplySuperAdminAction, + saveSuperAdminSettingsAction, + type ReapplySuperAdminState, + type SaveSuperAdminState, +} from "./actions"; + +const saveInitialState: SaveSuperAdminState = { error: null, saved: false, rollout: null }; +const reapplyInitialState: ReapplySuperAdminState = { error: null, rollout: null }; + +function StatusBadge({ initial, saveState }: { initial: SuperAdminSettings | null; saveState: SaveSuperAdminState }) { + const configured = (saveState.saved && !!saveState.rollout) || !!initial?.email; + return configured ? ( + Configured + ) : ( + Not configured + ); +} + +/** Controlled inputs — same reasoning as email-settings-form.tsx: two actions (Save / Reapply) share one field set, and an uncontrolled defaultValue input would reset after either resolves. */ +export function SuperAdminSettingsForm({ initial }: { initial: SuperAdminSettings | null }) { + const router = useRouter(); + const [saveState, saveAction, isSaving] = useActionState(saveSuperAdminSettingsAction, saveInitialState); + const [reapplyState, reapplyAction, isReapplying] = useActionState(reapplySuperAdminAction, reapplyInitialState); + + useEffect(() => { + if (saveState.saved) router.refresh(); + }, [saveState, router]); + + const [email, setEmail] = useState(initial?.email ?? ""); + const [firstName, setFirstName] = useState(initial?.firstName ?? ""); + const [lastName, setLastName] = useState(initial?.lastName ?? ""); + + return ( +
+
+

Super Administrator

+ +
+

+ A platform-level account with every Owner permission, provisioned automatically on every business — no + password, it signs in the same one-time-code way as everyone else. Never appears in any business's Team + list, the customer-facing barber picker, or staff totals, and can never receive an appointment. +

+ +
+
+
+ + setEmail(e.target.value)} + placeholder="you@example.com" + className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm" + /> +
+
+
+ + setFirstName(e.target.value)} + className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm" + /> +
+
+ + setLastName(e.target.value)} + className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm" + /> +
+
+ + {saveState.error && ( +

+ {saveState.error} +

+ )} + {saveState.saved && !saveState.error && ( +
+ {saveState.rollout === null ? ( +

Saved as a draft — enter an email to apply it to every business.

+ ) : ( +

+ Saved and applied to {saveState.rollout.filter((r) => r.ok).length} of {saveState.rollout.length}{" "} + business{saveState.rollout.length === 1 ? "" : "es"}. + {saveState.rollout.some((r) => !r.ok) && ( + <> Failed: {saveState.rollout.filter((r) => !r.ok).map((r) => `${r.slug} (${r.error})`).join(", ")} + )} +

+ )} +
+ )} + +
+ + +
+ {reapplyState.error && ( +

+ {reapplyState.error} +

+ )} + {reapplyState.rollout && ( +

+ Reapplied to {reapplyState.rollout.filter((r) => r.ok).length} of {reapplyState.rollout.length} business + {reapplyState.rollout.length === 1 ? "" : "es"}. +

+ )} + +
+ ); +} diff --git a/manager/src/lib/__tests__/compose-template.test.ts b/manager/src/lib/__tests__/compose-template.test.ts index 54a45ed..30905d3 100644 --- a/manager/src/lib/__tests__/compose-template.test.ts +++ b/manager/src/lib/__tests__/compose-template.test.ts @@ -49,3 +49,30 @@ describe("generateBusinessEnv — AUTH_URL", () => { expect(env).toContain('NEXT_PUBLIC_APP_URL="https://other-business.example.com"'); }); }); + +describe("generateBusinessEnv — SUPER_ADMIN_*", () => { + it("leaves SUPER_ADMIN_EMAIL blank when no global default is configured", () => { + const env = generateBusinessEnv(baseValues); + expect(env).toContain('SUPER_ADMIN_EMAIL=""'); + expect(env).toContain('SUPER_ADMIN_FIRST_NAME=""'); + expect(env).toContain('SUPER_ADMIN_LAST_NAME=""'); + }); + + it("bakes in the global default Super Administrator when given one", () => { + const env = generateBusinessEnv({ + ...baseValues, + superAdmin: { email: "lazis002@gmail.com", firstName: "Stefan", lastName: "Lazic" }, + }); + expect(env).toContain('SUPER_ADMIN_EMAIL="lazis002@gmail.com"'); + expect(env).toContain('SUPER_ADMIN_FIRST_NAME="Stefan"'); + expect(env).toContain('SUPER_ADMIN_LAST_NAME="Lazic"'); + }); + + it("never writes a SUPER_ADMIN_PASSWORD line — sign-in is OTP-only", () => { + const env = generateBusinessEnv({ + ...baseValues, + superAdmin: { email: "lazis002@gmail.com", firstName: "Stefan", lastName: "Lazic" }, + }); + expect(env).not.toContain("SUPER_ADMIN_PASSWORD"); + }); +}); diff --git a/manager/src/lib/__tests__/global-settings-super-admin.test.ts b/manager/src/lib/__tests__/global-settings-super-admin.test.ts new file mode 100644 index 0000000..cb7040e --- /dev/null +++ b/manager/src/lib/__tests__/global-settings-super-admin.test.ts @@ -0,0 +1,63 @@ +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { isSuperAdminConfigComplete, readSuperAdminSettings, saveSuperAdminSettings } from "../global-settings"; + +describe("global-settings — Super Administrator (not a secret, no encryption involved)", () => { + let stacksDir: string; + const originalStacksDir = process.env.STACKS_DIR; + const originalEncryptionKey = process.env.MANAGER_SETTINGS_ENCRYPTION_KEY; + + beforeEach(async () => { + stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-super-admin-test-")); + process.env.STACKS_DIR = stacksDir; + process.env.MANAGER_SETTINGS_ENCRYPTION_KEY = "a".repeat(64); + }); + + afterEach(async () => { + if (originalStacksDir === undefined) delete process.env.STACKS_DIR; + else process.env.STACKS_DIR = originalStacksDir; + if (originalEncryptionKey === undefined) delete process.env.MANAGER_SETTINGS_ENCRYPTION_KEY; + else process.env.MANAGER_SETTINGS_ENCRYPTION_KEY = originalEncryptionKey; + await rm(stacksDir, { recursive: true, force: true }); + }); + + it("returns null before anything has been saved", async () => { + expect(await readSuperAdminSettings()).toBeNull(); + }); + + it("saves and reads back the email/first/last name, lowercasing and trimming the email", async () => { + await saveSuperAdminSettings({ email: " Lazis002@Gmail.com ", firstName: " Stefan ", lastName: " Lazic " }); + + const saved = await readSuperAdminSettings(); + expect(saved).toEqual({ email: "lazis002@gmail.com", firstName: "Stefan", lastName: "Lazic" }); + }); + + it("saving does not clobber email/push settings already stored in the same file", async () => { + const { saveEmailSettings } = await import("../global-settings"); + await saveEmailSettings({ + host: "smtp.example.com", + port: 587, + username: "user", + password: "pw", + encryption: "starttls", + fromAddress: "from@example.com", + fromName: "", + replyTo: "", + }); + + await saveSuperAdminSettings({ email: "lazis002@gmail.com", firstName: "Stefan", lastName: "Lazic" }); + + const { readEmailSettingsView } = await import("../global-settings"); + const email = await readEmailSettingsView(); + expect(email.host).toBe("smtp.example.com"); + expect(email.hasPassword).toBe(true); + }); + + it("isSuperAdminConfigComplete requires a non-blank email; name is optional", () => { + expect(isSuperAdminConfigComplete(null)).toBe(false); + expect(isSuperAdminConfigComplete({ email: "", firstName: "Stefan", lastName: "Lazic" })).toBe(false); + expect(isSuperAdminConfigComplete({ email: "lazis002@gmail.com", firstName: "", lastName: "" })).toBe(true); + }); +}); diff --git a/manager/src/lib/compose-template.ts b/manager/src/lib/compose-template.ts index 7c0b357..78060a4 100644 --- a/manager/src/lib/compose-template.ts +++ b/manager/src/lib/compose-template.ts @@ -64,6 +64,12 @@ export interface BusinessEnvPushValues { subject: string; } +export interface BusinessEnvSuperAdminValues { + email: string; + firstName: string; + lastName: string; +} + export interface BusinessEnvValues { name: string; domain: string; @@ -84,6 +90,8 @@ export interface BusinessEnvValues { email?: BusinessEnvEmailValues; /** Global platform VAPID/Web Push settings (see src/lib/global-settings.ts) — same pattern as `email` above. Omitted falls back to today's blank/"console" defaults. */ push?: BusinessEnvPushValues; + /** Global default Super Administrator (see src/lib/global-settings.ts) — same "resolved once at provisioning time" pattern as email/push, but not a secret (no password field exists at all — sign-in is OTP-only). Omitted leaves SUPER_ADMIN_EMAIL blank, same as before this existed. */ + superAdmin?: BusinessEnvSuperAdminValues; } /** Same shape as .env.business.example, filled in with generated/collected values instead of placeholders. */ @@ -120,6 +128,15 @@ VAPID_PUBLIC_KEY="" VAPID_PRIVATE_KEY="" VAPID_SUBJECT="mailto:owner@example.com"`; + const superAdmin = values.superAdmin; + const superAdminBlock = superAdmin + ? `SUPER_ADMIN_EMAIL="${superAdmin.email}" +SUPER_ADMIN_FIRST_NAME="${superAdmin.firstName}" +SUPER_ADMIN_LAST_NAME="${superAdmin.lastName}"` + : `SUPER_ADMIN_EMAIL="" +SUPER_ADMIN_FIRST_NAME="" +SUPER_ADMIN_LAST_NAME=""`; + return `# Generated by the management UI. See .env.business.example for what # each of these does. # BUSINESS_NAME is Manager-only bookkeeping (shown on the dashboard/edit @@ -146,10 +163,11 @@ NEXT_PUBLIC_APP_URL="https://${values.domain}" INSTALLATION_TOKEN="${values.installationToken}" CRON_SECRET="${values.cronSecret}" -SUPER_ADMIN_EMAIL="" -SUPER_ADMIN_PASSWORD="" -SUPER_ADMIN_FIRST_NAME="" -SUPER_ADMIN_LAST_NAME="" +# Managed centrally — see the Manager's Settings > Super Administrator +# page. Changing it there does NOT retroactively update existing +# businesses (unlike email/push above) — see set-super-admin.sh for +# applying a change across every business at once. +${superAdminBlock} AUTH_GOOGLE_ID="" AUTH_GOOGLE_SECRET="" diff --git a/manager/src/lib/env-file.ts b/manager/src/lib/env-file.ts index 9a1edff..538b969 100644 --- a/manager/src/lib/env-file.ts +++ b/manager/src/lib/env-file.ts @@ -33,8 +33,8 @@ export function readEnvValue(content: string, key: string): string | null { * file (preserving the surrounding file untouched), otherwise appends a * new line for it. Used by Edit Business: never regenerates a business's * whole .env from a template, which would blow away anything hand-edited - * into it since provisioning (Google/email/push credentials, a rotated - * SUPER_ADMIN_PASSWORD, etc.) — see provisioning.ts's updateBusiness. + * into it since provisioning (Google/email/push credentials, a hand-added + * GOOGLE_CALENDAR_CLIENT_SECRET, etc.) — see provisioning.ts's updateBusiness. */ export async function upsertEnvValues(filePath: string, updates: Record): Promise { const current = (await readEnvFile(filePath)) ?? ""; diff --git a/manager/src/lib/global-settings.ts b/manager/src/lib/global-settings.ts index 425dcc2..6955037 100644 --- a/manager/src/lib/global-settings.ts +++ b/manager/src/lib/global-settings.ts @@ -48,9 +48,17 @@ interface StoredPushSettings { generatedAt: string | null; } +/** Not a secret — no password (sign-in is OTP-only, see scripts/ensure-super-admin.ts) — so this is stored and returned in plain, unlike email/push above. */ +export interface SuperAdminSettings { + email: string; + firstName: string; + lastName: string; +} + interface StoredGlobalSettings { email: StoredEmailSettings | null; push: StoredPushSettings | null; + superAdmin: SuperAdminSettings | null; } export interface LastTestResult { @@ -119,10 +127,10 @@ async function readStoredSettings(): Promise { try { const raw = await readFile(settingsFilePath(), "utf8"); const parsed = JSON.parse(raw) as StoredGlobalSettings; - return { email: parsed.email ?? null, push: parsed.push ?? null }; + return { email: parsed.email ?? null, push: parsed.push ?? null, superAdmin: parsed.superAdmin ?? null }; } catch { // No file yet (first run) or unreadable — treat as "nothing configured" rather than throwing, so the Settings page can always render. - return { email: null, push: null }; + return { email: null, push: null, superAdmin: null }; } } @@ -364,3 +372,32 @@ export async function savePushSubject(subject: string): Promise { + const { superAdmin } = await readStoredSettings(); + return superAdmin; +} + +export function isSuperAdminConfigComplete(settings: SuperAdminSettings | null): settings is SuperAdminSettings { + return Boolean(settings && settings.email.trim()); +} + +export async function saveSuperAdminSettings(input: SuperAdminSettings): Promise { + const current = await readStoredSettings(); + const next: SuperAdminSettings = { + email: input.email.trim().toLowerCase(), + firstName: input.firstName.trim(), + lastName: input.lastName.trim(), + }; + await writeStoredSettings({ ...current, superAdmin: next }); + return next; +} diff --git a/manager/src/lib/provisioning.ts b/manager/src/lib/provisioning.ts index ee61526..cc1f71d 100644 --- a/manager/src/lib/provisioning.ts +++ b/manager/src/lib/provisioning.ts @@ -4,7 +4,14 @@ import mariadb, { type Connection } from "mariadb"; import { generateBusinessEnv, generateCaddySite, generateComposeYaml } from "./compose-template"; import { composeDown, composeUp, reloadCaddy, runMigrations } from "./docker"; import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file"; -import { isEmailConfigComplete, isPushConfigComplete, resolveEmailSettings, resolvePushSettings } from "./global-settings"; +import { + isEmailConfigComplete, + isPushConfigComplete, + isSuperAdminConfigComplete, + readSuperAdminSettings, + resolveEmailSettings, + resolvePushSettings, +} from "./global-settings"; import { randomHex } from "./random"; import { isValidSlug, listBusinessStacks, stackDir, stackExists, stacksRoot } from "./stacks"; @@ -168,6 +175,16 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise< ? { publicKey: globalPushSettings.publicKey, privateKey: globalPushSettings.privateKey, subject: globalPushSettings.subject } : undefined; + // Same pattern again — a global default Super Administrator (Manager's + // Settings > Super Administrator page), baked in at creation only when + // an email is actually set. No secret involved (sign-in is OTP-only), + // so unlike email/push there's nothing to fall back to "incomplete" + // for beyond a blank email. + const globalSuperAdminSettings = await readSuperAdminSettings(); + const superAdmin = isSuperAdminConfigComplete(globalSuperAdminSettings) + ? { email: globalSuperAdminSettings.email, firstName: globalSuperAdminSettings.firstName, lastName: globalSuperAdminSettings.lastName } + : undefined; + const dir = stackDir(slug); await mkdir(dir, { recursive: true }); await writeFile(path.join(dir, "compose.yaml"), generateComposeYaml(slug), "utf8"); @@ -184,6 +201,7 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise< cronSecret, email, push, + superAdmin, }), "utf8", ); @@ -212,8 +230,8 @@ export interface UpdateBusinessResult { * Edits an existing business's name/domain. Deliberately a surgical .env * update (see upsertEnvValues), never a full regenerate from the create * template — a regenerate would blow away anything hand-edited into that - * file since provisioning (Google/email/push credentials, a rotated - * SUPER_ADMIN_PASSWORD, etc.), which the caller has no business touching + * file since provisioning (Google/email/push credentials, a hand-added + * GOOGLE_CALENDAR_CLIENT_SECRET, etc.), which the caller has no business touching * just to change a name or domain. * * The slug itself is immutable here on purpose: it's baked into the diff --git a/manager/src/lib/super-admin-rollout.ts b/manager/src/lib/super-admin-rollout.ts new file mode 100644 index 0000000..143caf4 --- /dev/null +++ b/manager/src/lib/super-admin-rollout.ts @@ -0,0 +1,56 @@ +import path from "node:path"; +import { composeUp } from "./docker"; +import { upsertEnvValues } from "./env-file"; +import type { SuperAdminSettings } from "./global-settings"; +import { listBusinessStacks, stackDir } from "./stacks"; + +export interface SuperAdminRolloutResult { + slug: string; + ok: boolean; + error?: string; +} + +/** + * Pushes the global default Super Administrator into every business's + * own `.env` and force-recreates its container so + * scripts/ensure-super-admin.ts (run on every container start) picks it + * up — same mechanism as email-rollout.ts/push-rollout.ts. Unlike those, + * always overwrites all three keys unconditionally: there's no + * per-business "own value to preserve" concept here the way EMAIL_REPLY_TO + * has (a business doesn't have its own Super Administrator), and no + * secret involved (sign-in is OTP-only) worth being more careful with. + * + * This is the UI equivalent of running + * stacks/infra/scripts/set-super-admin.sh by hand — kept as a genuinely + * separate implementation (not a shared core) since one runs inside the + * Manager process against these same helpers and the other is a + * standalone shell script for someone without Manager access to a + * running instance; letting them drift independently is fine since both + * only ever do this one small thing. + * + * Runs sequentially, not in parallel — see email-rollout.ts's own + * comment for why. + */ +export async function applySuperAdminToAllBusinesses(settings: SuperAdminSettings): Promise { + const businesses = await listBusinessStacks(); + const results: SuperAdminRolloutResult[] = []; + + for (const business of businesses) { + try { + const dir = stackDir(business.slug); + const envPath = path.join(dir, ".env"); + + await upsertEnvValues(envPath, { + SUPER_ADMIN_EMAIL: settings.email, + SUPER_ADMIN_FIRST_NAME: settings.firstName, + SUPER_ADMIN_LAST_NAME: settings.lastName, + }); + await composeUp(dir, { forceRecreate: true }); + results.push({ slug: business.slug, ok: true }); + } catch (error) { + results.push({ slug: business.slug, ok: false, error: error instanceof Error ? error.message : String(error) }); + } + } + + return results; +} diff --git a/scripts/ensure-super-admin.ts b/scripts/ensure-super-admin.ts index ee00f23..c525c75 100644 --- a/scripts/ensure-super-admin.ts +++ b/scripts/ensure-super-admin.ts @@ -2,9 +2,18 @@ * Idempotent Super Administrator provisioning, run on every container * start (see docker/entrypoint.sh) — NOT part of the installation wizard, * per the brief's explicit requirement that this account come from - * environment variables. Safe to run repeatedly: re-hashes and syncs the - * password/name from the env vars every run, so rotating - * SUPER_ADMIN_PASSWORD and restarting the container is how you change it. + * environment variables. Safe to run repeatedly: re-syncs the name from + * the env vars every run. + * + * No password — sign-in across this entire app is OTP-only (see + * src/lib/auth/authorize-credentials.ts), and a Super Administrator signs + * in exactly the same way as anyone else, via a one-time code emailed to + * SUPER_ADMIN_EMAIL. `User.passwordHash` is never read anywhere in the + * actual sign-in path — this script used to require and set one anyway + * (a leftover from before the OTP migration), which only meant an unused + * value with its own, pointless minimum-length requirement. Removed + * rather than kept "just in case": a field nothing checks is worse than + * no field, since it invites someone to assume it's load-bearing. * * Deliberately does NOT create a StaffProfile — that's what keeps the * Super Administrator out of the Team list, the customer-facing barber @@ -18,13 +27,10 @@ * outside Next's own module resolution/bundling. */ import "dotenv/config"; -import bcrypt from "bcryptjs"; import { PrismaClient } from "../generated/prisma/client"; import { PrismaMariaDb } from "@prisma/adapter-mariadb"; import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config"; -const BCRYPT_COST = 12; // matches src/features/installation/installer.service.ts - async function main() { const email = process.env.SUPER_ADMIN_EMAIL?.trim().toLowerCase(); if (!email) { @@ -32,15 +38,6 @@ async function main() { return; } - const password = process.env.SUPER_ADMIN_PASSWORD; - if (!password || password.length < 8) { - console.error( - "[ensure-super-admin] SUPER_ADMIN_EMAIL is set but SUPER_ADMIN_PASSWORD is missing or shorter than 8 characters. Refusing to provision a Super Administrator with no usable password.", - ); - process.exitCode = 1; - return; - } - const firstName = process.env.SUPER_ADMIN_FIRST_NAME?.trim() ?? ""; const lastName = process.env.SUPER_ADMIN_LAST_NAME?.trim() ?? ""; const name = [firstName, lastName].filter(Boolean).join(" ") || null; @@ -49,7 +46,6 @@ async function main() { const prisma = new PrismaClient({ adapter }); try { - const passwordHash = await bcrypt.hash(password, BCRYPT_COST); const existing = await prisma.user.findUnique({ where: { email } }); if (existing && !existing.isSuperAdmin) { @@ -63,12 +59,12 @@ async function main() { if (existing) { await prisma.user.update({ where: { id: existing.id }, - data: { name, passwordHash, emailVerified: existing.emailVerified ?? new Date() }, + data: { name, emailVerified: existing.emailVerified ?? new Date() }, }); - console.log(`[ensure-super-admin] Synced credentials for existing Super Administrator (${email}).`); + console.log(`[ensure-super-admin] Synced name for existing Super Administrator (${email}).`); } else { await prisma.user.create({ - data: { email, name, passwordHash, isSuperAdmin: true, emailVerified: new Date() }, + data: { email, name, isSuperAdmin: true, emailVerified: new Date() }, }); console.log(`[ensure-super-admin] Created Super Administrator (${email}).`); } diff --git a/stacks/infra/scripts/process-cron.sh b/stacks/infra/scripts/process-cron.sh new file mode 100755 index 0000000..c63483c --- /dev/null +++ b/stacks/infra/scripts/process-cron.sh @@ -0,0 +1,70 @@ +#!/bin/sh +# Calls one of the app's CRON_SECRET-gated endpoints (see +# src/app/api/cron/process-notifications/route.ts and +# src/app/api/cron/sync-google-reviews/route.ts) for every business +# currently provisioned under STACKS_DIR — so a single crontab entry on +# the host covers every business automatically, including ones added +# later, without a manual crontab line per business. +# +# Without something calling process-notifications on a schedule, queued +# notifications (staff invitation emails, appointment reminders, loyalty +# reward emails, etc.) are created in the database as PENDING and never +# actually sent — see that route's own header for why this is a required +# piece of infrastructure, not optional. This script is the reference +# implementation for wiring that up; see the root README's "Notification +# delivery" section for the crontab lines that call it. +# +# Usage: +# process-cron.sh notifications # recommended every 1-2 minutes +# process-cron.sh reviews # recommended once every 24 hours +# +# STACKS_DIR defaults to /opt/nextchair/stacks — override via env var if +# this repo is cloned somewhere else (must match every other STACKS_DIR +# use across this deployment — see .env.infra.example's own note on why). + +set -eu + +if [ $# -lt 1 ]; then + echo "Usage: $0 notifications|reviews" >&2 + exit 1 +fi +ENDPOINT="$1" +STACKS_DIR="${STACKS_DIR:-/opt/nextchair/stacks}" + +case "$ENDPOINT" in + notifications) PATH_SUFFIX="process-notifications" ;; + reviews) PATH_SUFFIX="sync-google-reviews" ;; + *) + echo "Unknown endpoint '$ENDPOINT' — expected 'notifications' or 'reviews'." >&2 + exit 1 + ;; +esac + +if [ ! -d "$STACKS_DIR" ]; then + echo "STACKS_DIR '$STACKS_DIR' does not exist." >&2 + exit 1 +fi + +for env_file in "$STACKS_DIR"/*/.env; do + [ -f "$env_file" ] || continue + + domain=$(grep -E '^DOMAIN=' "$env_file" | head -n1 | cut -d= -f2- | tr -d '"') + cron_secret=$(grep -E '^CRON_SECRET=' "$env_file" | head -n1 | cut -d= -f2- | tr -d '"') + slug=$(basename "$(dirname "$env_file")") + + if [ -z "$domain" ] || [ -z "$cron_secret" ]; then + echo "[$slug] skipped — no DOMAIN/CRON_SECRET in .env (not fully provisioned yet)." >&2 + continue + fi + + status=$(curl -sS -o /tmp/process-cron-response.$$ -w '%{http_code}' -X POST \ + "https://${domain}/api/cron/${PATH_SUFFIX}" \ + -H "Authorization: Bearer ${cron_secret}" || echo "curl_failed") + + if [ "$status" = "200" ]; then + echo "[$slug] $PATH_SUFFIX ok" + else + echo "[$slug] $PATH_SUFFIX FAILED (status=$status): $(cat /tmp/process-cron-response.$$ 2>/dev/null)" >&2 + fi + rm -f /tmp/process-cron-response.$$ +done diff --git a/stacks/infra/scripts/set-super-admin.sh b/stacks/infra/scripts/set-super-admin.sh new file mode 100755 index 0000000..8baac18 --- /dev/null +++ b/stacks/infra/scripts/set-super-admin.sh @@ -0,0 +1,63 @@ +#!/bin/sh +# Sets SUPER_ADMIN_EMAIL/FIRST_NAME/LAST_NAME in every business's .env +# under STACKS_DIR and recreates each container so +# scripts/ensure-super-admin.ts (run on every container start via +# docker/entrypoint.sh) picks it up immediately. No password to set — +# sign-in is OTP-only; see ensure-super-admin.ts's own header for why. +# +# Only touches these three keys in each .env — every other secret/setting +# already in the file (Google/email/push credentials, etc.) is left +# byte-for-byte alone. Safe to re-run any time you want to change who the +# Super Administrator is, or after adding a new business. +# +# Usage: +# set-super-admin.sh +# +# STACKS_DIR defaults to /opt/nextchair/stacks — override via env var if +# this repo is cloned somewhere else. + +set -eu + +if [ $# -lt 3 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi +EMAIL="$1" +FIRST_NAME="$2" +LAST_NAME="$3" +STACKS_DIR="${STACKS_DIR:-/opt/nextchair/stacks}" + +if [ ! -d "$STACKS_DIR" ]; then + echo "STACKS_DIR '$STACKS_DIR' does not exist." >&2 + exit 1 +fi + +for env_file in "$STACKS_DIR"/*/.env; do + [ -f "$env_file" ] || continue + dir=$(dirname "$env_file") + slug=$(basename "$dir") + + tmp="${env_file}.tmp.$$" + awk -v email="$EMAIL" -v first="$FIRST_NAME" -v last="$LAST_NAME" ' + BEGIN { seen_email=0; seen_first=0; seen_last=0 } + /^SUPER_ADMIN_EMAIL=/ { print "SUPER_ADMIN_EMAIL=\"" email "\""; seen_email=1; next } + /^SUPER_ADMIN_FIRST_NAME=/ { print "SUPER_ADMIN_FIRST_NAME=\"" first "\""; seen_first=1; next } + /^SUPER_ADMIN_LAST_NAME=/ { print "SUPER_ADMIN_LAST_NAME=\"" last "\""; seen_last=1; next } + /^SUPER_ADMIN_PASSWORD=/ { next } + { print } + END { + if (!seen_email) print "SUPER_ADMIN_EMAIL=\"" email "\"" + if (!seen_first) print "SUPER_ADMIN_FIRST_NAME=\"" first "\"" + if (!seen_last) print "SUPER_ADMIN_LAST_NAME=\"" last "\"" + } + ' "$env_file" > "$tmp" && mv "$tmp" "$env_file" + + echo "[$slug] .env updated — recreating container..." + if (cd "$dir" && docker compose up -d --force-recreate) > /tmp/set-super-admin-$$.log 2>&1; then + echo "[$slug] ok" + else + echo "[$slug] FAILED to recreate container:" >&2 + cat /tmp/set-super-admin-$$.log >&2 + fi + rm -f /tmp/set-super-admin-$$.log +done