Notifications
CI / ci (push) Canceled after 0s

Added a cron job processor so emails and notifications can get processed.

Added super admin settings with a gui to edit.
This commit is contained in:
Stefan Lazic committed 2026-09-11 17:46:28 +01:00
1 parent cbeccc6c1f
commit 1a76d1cacb
17 files changed
+645 -48

No files matched your search

@@ -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");
});
});
@@ -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);
});
});
+22 -4
View File
@@ -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=""
+2 -2
View File
@@ -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<string, string>): Promise<void> {
const current = (await readEnvFile(filePath)) ?? "";
+39 -2
View File
@@ -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<StoredGlobalSettings> {
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<ResolvedPushSett
if (!next.encryptedPrivateKey) return null;
return { publicKey: next.publicKey, privateKey: decrypt(next.encryptedPrivateKey), subject: next.subject };
}
// ── Super Administrator ─────────────────────────────────────────────────
/**
* Baked into every business's .env at creation, the same as email/push
* above — see provisioning.ts's provisionBusiness. Unlike those, nothing
* here is a secret (no password, sign-in is OTP-only), so there's no
* View/Resolved split and no encryption: this is read and returned as
* plain data throughout.
*/
export async function readSuperAdminSettings(): Promise<SuperAdminSettings | null> {
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<SuperAdminSettings> {
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;
}
+21 -3
View File
@@ -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
+56
View File
@@ -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<SuperAdminRolloutResult[]> {
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;
}