Google Calendar
CI / ci (push) Canceled after 0s

Made google calendar auth not business specific.

global-settings.ts — new GoogleCalendarSettings section, encrypted at rest the same way as the SMTP password/VAPID private key.

google-calendar-rollout.ts — pushes the shared client ID/secret into every business's .env and recreates its container. GOOGLE_CALENDAR_REDIRECT_URI is left alone (it must keep tracking each business's own domain).

provisioning.ts / compose-template.ts — new businesses now pick up the global client automatically at creation, same as Email/Push already do.

Settings UI — new "Google Calendar" card at the bottom of Manager's Settings page (google-calendar-settings-form.tsx), with Save + "Reapply to all businesses."
This commit is contained in:
Stefan Lazic committed 2026-09-12 15:09:48 +01:00
1 parent 65e141d527
commit de32175916
11 files changed
+684 -10

No files matched your search

+6
View File
@@ -6,6 +6,12 @@
"runtimeExecutable": "npm", "runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"], "runtimeArgs": ["run", "dev"],
"port": 3000 "port": 3000
},
{
"name": "nextchair-manager-dev",
"runtimeExecutable": "npm",
"runtimeArgs": ["--prefix", "manager", "run", "dev", "--", "-p", "3100"],
"port": 3100
} }
] ]
} }
@@ -1,22 +1,27 @@
"use server"; "use server";
import { applyEmailSettingsToAllBusinesses, type EmailRolloutResult } from "@/lib/email-rollout"; import { applyEmailSettingsToAllBusinesses, type EmailRolloutResult } from "@/lib/email-rollout";
import { applyGoogleCalendarSettingsToAllBusinesses, type GoogleCalendarRolloutResult } from "@/lib/google-calendar-rollout";
import { applyPushSettingsToAllBusinesses, type PushRolloutResult } from "@/lib/push-rollout"; import { applyPushSettingsToAllBusinesses, type PushRolloutResult } from "@/lib/push-rollout";
import { import {
generateVapidKeys, generateVapidKeys,
isEmailConfigComplete, isEmailConfigComplete,
isGoogleCalendarConfigComplete,
isPushConfigComplete, isPushConfigComplete,
isSuperAdminConfigComplete, isSuperAdminConfigComplete,
readSuperAdminSettings, readSuperAdminSettings,
recordLastTestResult, recordLastTestResult,
resolveEmailSettings, resolveEmailSettings,
resolveGoogleCalendarSettings,
resolvePushSettings, resolvePushSettings,
saveEmailSettings, saveEmailSettings,
saveGoogleCalendarSettings,
savePushSubject, savePushSubject,
saveSuperAdminSettings, saveSuperAdminSettings,
VapidKeysAlreadyExistError, VapidKeysAlreadyExistError,
type EmailEncryptionMode, type EmailEncryptionMode,
type EmailSettingsInput, type EmailSettingsInput,
type GoogleCalendarSettingsInput,
} from "@/lib/global-settings"; } from "@/lib/global-settings";
import { applySuperAdminToAllBusinesses, type SuperAdminRolloutResult } from "@/lib/super-admin-rollout"; import { applySuperAdminToAllBusinesses, type SuperAdminRolloutResult } from "@/lib/super-admin-rollout";
import { sendTestSmtpEmail } from "@/lib/test-email"; import { sendTestSmtpEmail } from "@/lib/test-email";
@@ -298,3 +303,59 @@ export async function reapplySuperAdminAction(
const rollout = await applySuperAdminToAllBusinesses(saved); const rollout = await applySuperAdminToAllBusinesses(saved);
return { error: null, rollout }; return { error: null, rollout };
} }
// ── Google Calendar ──────────────────────────────────────────────────────
function parseGoogleCalendarSettingsInput(formData: FormData): GoogleCalendarSettingsInput {
return {
clientId: String(formData.get("clientId") ?? "").trim(),
clientSecret: String(formData.get("clientSecret") ?? ""),
};
}
export interface SaveGoogleCalendarSettingsState {
error: string | null;
saved: boolean;
/** null = settings were saved but not rolled out (incomplete config — see isGoogleCalendarConfigComplete); an array (possibly empty) means a rollout was attempted. */
rollout: GoogleCalendarRolloutResult[] | null;
}
export async function saveGoogleCalendarSettingsAction(
_prevState: SaveGoogleCalendarSettingsState,
formData: FormData,
): Promise<SaveGoogleCalendarSettingsState> {
const input = parseGoogleCalendarSettingsInput(formData);
if (!input.clientId) {
return { error: "Client ID is required.", saved: false, rollout: null };
}
const resolved = await saveGoogleCalendarSettings(input);
if (!isGoogleCalendarConfigComplete(resolved)) {
// Saved as a draft — no client secret configured yet, so nothing is
// rolled out (mirrors saveEmailSettingsAction's incomplete-config
// behavior: a business left with a blank secret would hard-fail the
// moment a staff member tries to connect their calendar).
return { error: null, saved: true, rollout: null };
}
const rollout = await applyGoogleCalendarSettingsToAllBusinesses(resolved);
return { error: null, saved: true, rollout };
}
export interface ReapplyGoogleCalendarSettingsState {
error: string | null;
rollout: GoogleCalendarRolloutResult[] | null;
}
/** Retries the rollout without requiring a field edit first — e.g. after a business's container was down during the last save, or to backfill a newly-added business's CALENDAR_TOKEN_ENCRYPTION_KEY. */
export async function reapplyGoogleCalendarSettingsAction(
_prevState: ReapplyGoogleCalendarSettingsState,
_formData: FormData,
): Promise<ReapplyGoogleCalendarSettingsState> {
const resolved = await resolveGoogleCalendarSettings();
if (!resolved || !isGoogleCalendarConfigComplete(resolved)) {
return { error: "Google Calendar settings aren't fully configured yet — nothing to reapply.", rollout: null };
}
const rollout = await applyGoogleCalendarSettingsToAllBusinesses(resolved);
return { error: null, rollout };
}
@@ -0,0 +1,160 @@
"use client";
import { useRouter } from "next/navigation";
import { useActionState, useEffect, useState } from "react";
import type { GoogleCalendarSettingsView } from "@/lib/global-settings";
import {
reapplyGoogleCalendarSettingsAction,
saveGoogleCalendarSettingsAction,
type ReapplyGoogleCalendarSettingsState,
type SaveGoogleCalendarSettingsState,
} from "./actions";
const saveInitialState: SaveGoogleCalendarSettingsState = { error: null, saved: false, rollout: null };
const reapplyInitialState: ReapplyGoogleCalendarSettingsState = { error: null, rollout: null };
function StatusBadge({ initial, saveState }: { initial: GoogleCalendarSettingsView; saveState: SaveGoogleCalendarSettingsState }) {
if (saveState.saved && saveState.rollout) {
const allOk = saveState.rollout.every((r) => r.ok);
return allOk ? (
<span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Configured</span>
) : (
<span className="rounded-full bg-amber-100 px-3 py-1 text-xs font-semibold text-amber-800">Configured (some businesses failed)</span>
);
}
if (initial.hasClientSecret && initial.clientId) {
return <span className="rounded-full bg-green-100 px-3 py-1 text-xs font-semibold text-green-800">Configured</span>;
}
return <span className="rounded-full bg-stone px-3 py-1 text-xs font-semibold text-muted">Not configured</span>;
}
/**
* One OAuth client shared by every business — see email-settings-form.tsx
* for why every field here is controlled state rather than
* `defaultValue`: this form also has two actions (Save / Reapply) sharing
* one set of fields, and re-render after either would otherwise reset an
* uncommitted edit.
*/
export function GoogleCalendarSettingsForm({ initial }: { initial: GoogleCalendarSettingsView }) {
const router = useRouter();
const [saveState, saveAction, isSaving] = useActionState(saveGoogleCalendarSettingsAction, saveInitialState);
const [reapplyState, reapplyAction, isReapplying] = useActionState(reapplyGoogleCalendarSettingsAction, reapplyInitialState);
useEffect(() => {
if (saveState.saved) router.refresh();
}, [saveState, router]);
const [clientId, setClientId] = useState(initial.clientId);
const [clientSecret, setClientSecret] = useState("");
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold">Google Calendar</h2>
<StatusBadge initial={initial} saveState={saveState} />
</div>
<p className="text-sm text-muted">
One OAuth client shared by every business on this installation, so staff can sync their confirmed
appointments to their own Google Calendar without each business needing its own Google Cloud project. In
Google Cloud Console, add every business&apos;s own callback URL —{" "}
<code className="rounded bg-stone px-1 py-0.5 text-xs">
https://&lt;business-domain&gt;/api/staff/calendar/google/callback
</code>{" "}
— as an authorized redirect URI on this client.
</p>
<form className="space-y-4 rounded-md border border-line bg-surface p-5">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label htmlFor="clientId" className="block text-sm font-medium text-muted">
Client ID
</label>
<input
id="clientId"
name="clientId"
type="text"
required
value={clientId}
onChange={(e) => setClientId(e.target.value)}
placeholder="xxxxxxxxxxxx.apps.googleusercontent.com"
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
</div>
<div>
<label htmlFor="clientSecret" className="block text-sm font-medium text-muted">
Client secret
</label>
<input
id="clientSecret"
name="clientSecret"
type="password"
autoComplete="new-password"
value={clientSecret}
onChange={(e) => setClientSecret(e.target.value)}
placeholder={initial.hasClientSecret ? "•••••••••••• (leave blank to keep)" : "Not set"}
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
/>
<p className="mt-1 text-xs text-muted">
{initial.hasClientSecret ? "A client secret is already saved — leave this blank to keep it." : "No client secret saved yet."}
</p>
</div>
</div>
{saveState.error && (
<p role="alert" className="text-sm font-medium text-danger">
{saveState.error}
</p>
)}
{saveState.saved && !saveState.error && (
<div className="text-sm text-green-700">
{saveState.rollout === null ? (
<p>Settings saved as a draft. Add a client secret to enable it and roll it out to businesses.</p>
) : (
<p>
Settings 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(", ")}
</>
)}
</p>
)}
</div>
)}
<div className="flex flex-wrap gap-3 pt-2">
<button
type="submit"
formAction={saveAction}
disabled={isSaving}
className="rounded-md bg-ink px-4 py-2 text-sm font-semibold text-white disabled:opacity-60"
>
{isSaving ? "Saving…" : "Save Changes"}
</button>
<button
type="submit"
formAction={reapplyAction}
disabled={isReapplying || !initial.hasClientSecret}
title={!initial.hasClientSecret ? "Save complete settings first" : undefined}
className="ml-auto text-sm font-medium text-muted hover:text-ink disabled:opacity-40"
>
{isReapplying ? "Reapplying…" : "Reapply to all businesses"}
</button>
</div>
{reapplyState.error && (
<p role="alert" className="text-sm font-medium text-danger">
{reapplyState.error}
</p>
)}
{reapplyState.rollout && (
<p className="text-sm text-green-700">
Reapplied to {reapplyState.rollout.filter((r) => r.ok).length} of {reapplyState.rollout.length} business
{reapplyState.rollout.length === 1 ? "" : "es"}.
</p>
)}
</form>
</div>
);
}
+12 -5
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { readEmailSettingsView, readPushSettingsView, readSuperAdminSettings } from "@/lib/global-settings"; import { readEmailSettingsView, readGoogleCalendarSettingsView, readPushSettingsView, readSuperAdminSettings } from "@/lib/global-settings";
import { EmailSettingsForm } from "./email-settings-form"; import { EmailSettingsForm } from "./email-settings-form";
import { GoogleCalendarSettingsForm } from "./google-calendar-settings-form";
import { PushSettingsForm } from "./push-settings-form"; import { PushSettingsForm } from "./push-settings-form";
import { SuperAdminSettingsForm } from "./super-admin-settings-form"; import { SuperAdminSettingsForm } from "./super-admin-settings-form";
@@ -9,12 +10,17 @@ export const metadata: Metadata = { title: "Settings" };
/** /**
* Platform-wide settings shared by every business on this installation — * Platform-wide settings shared by every business on this installation —
* see src/lib/global-settings.ts's own header for the storage design. * see src/lib/global-settings.ts's own header for the storage design.
* "Email", "Push Notifications", and "Super Administrator" exist today; a * "Email", "Push Notifications", "Super Administrator", and "Google
* later section (SMS, storage, backups, branding, locale, monitoring) is * Calendar" exist today; a later section (SMS, storage, backups,
* a sibling here, not a rewrite. * branding, locale, monitoring) is a sibling here, not a rewrite.
*/ */
export default async function SettingsPage() { export default async function SettingsPage() {
const [email, push, superAdmin] = await Promise.all([readEmailSettingsView(), readPushSettingsView(), readSuperAdminSettings()]); const [email, push, superAdmin, googleCalendar] = await Promise.all([
readEmailSettingsView(),
readPushSettingsView(),
readSuperAdminSettings(),
readGoogleCalendarSettingsView(),
]);
return ( return (
<div className="space-y-8"> <div className="space-y-8">
@@ -25,6 +31,7 @@ export default async function SettingsPage() {
<EmailSettingsForm initial={email} /> <EmailSettingsForm initial={email} />
<PushSettingsForm initial={push} /> <PushSettingsForm initial={push} />
<SuperAdminSettingsForm initial={superAdmin} /> <SuperAdminSettingsForm initial={superAdmin} />
<GoogleCalendarSettingsForm initial={googleCalendar} />
</div> </div>
); );
} }
@@ -10,6 +10,7 @@ const baseValues: BusinessEnvValues = {
authSecret: "auth-secret", authSecret: "auth-secret",
installationToken: "the-installation-token-value", installationToken: "the-installation-token-value",
cronSecret: "cron-secret", cronSecret: "cron-secret",
calendarTokenEncryptionKey: "calendar-token-encryption-key",
}; };
describe("generateBusinessEnv — INSTALLATION_TOKEN", () => { describe("generateBusinessEnv — INSTALLATION_TOKEN", () => {
@@ -76,3 +77,47 @@ describe("generateBusinessEnv — SUPER_ADMIN_*", () => {
expect(env).not.toContain("SUPER_ADMIN_PASSWORD"); expect(env).not.toContain("SUPER_ADMIN_PASSWORD");
}); });
}); });
describe("generateBusinessEnv — GOOGLE_CALENDAR_* / CALENDAR_TOKEN_ENCRYPTION_KEY", () => {
it("leaves the OAuth client blank when no global default is configured", () => {
const env = generateBusinessEnv(baseValues);
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_ID=""');
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_SECRET=""');
});
it("bakes in the global default OAuth client when given one", () => {
const env = generateBusinessEnv({
...baseValues,
googleCalendar: { clientId: "abc.apps.googleusercontent.com", clientSecret: "shh" },
});
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_ID="abc.apps.googleusercontent.com"');
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_SECRET="shh"');
});
it("always derives the redirect URI from this business's own domain, regardless of the shared OAuth client", () => {
const env = generateBusinessEnv({
...baseValues,
domain: "acme.example.com",
googleCalendar: { clientId: "abc.apps.googleusercontent.com", clientSecret: "shh" },
});
expect(env).toContain('GOOGLE_CALENDAR_REDIRECT_URI="https://acme.example.com/api/staff/calendar/google/callback"');
});
it("always writes the given calendarTokenEncryptionKey, distinct per business, even when the OAuth client is shared", () => {
const envA = generateBusinessEnv({
...baseValues,
calendarTokenEncryptionKey: "key-a",
googleCalendar: { clientId: "abc.apps.googleusercontent.com", clientSecret: "shh" },
});
const envB = generateBusinessEnv({
...baseValues,
domain: "other.example.com",
calendarTokenEncryptionKey: "key-b",
googleCalendar: { clientId: "abc.apps.googleusercontent.com", clientSecret: "shh" },
});
expect(envA).toContain('CALENDAR_TOKEN_ENCRYPTION_KEY="key-a"');
expect(envB).toContain('CALENDAR_TOKEN_ENCRYPTION_KEY="key-b"');
expect(envA).not.toContain("key-b");
expect(envB).not.toContain("key-a");
});
});
@@ -0,0 +1,89 @@
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 {
isGoogleCalendarConfigComplete,
readGoogleCalendarSettingsView,
resolveGoogleCalendarSettings,
saveGoogleCalendarSettings,
saveEmailSettings,
readEmailSettingsView,
} from "../global-settings";
describe("global-settings — Google Calendar (encrypted client secret)", () => {
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-google-calendar-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 the empty default before anything has been saved", async () => {
expect(await readGoogleCalendarSettingsView()).toEqual({ clientId: "", hasClientSecret: false });
expect(await resolveGoogleCalendarSettings()).toBeNull();
});
it("saves and reads back the client ID/secret, never exposing the secret in the view", async () => {
await saveGoogleCalendarSettings({ clientId: "abc.apps.googleusercontent.com", clientSecret: "top-secret" });
const view = await readGoogleCalendarSettingsView();
expect(view).toEqual({ clientId: "abc.apps.googleusercontent.com", hasClientSecret: true });
const resolved = await resolveGoogleCalendarSettings();
expect(resolved).toEqual({ clientId: "abc.apps.googleusercontent.com", clientSecret: "top-secret" });
});
it("never stores the client secret in plaintext on disk", async () => {
await saveGoogleCalendarSettings({ clientId: "abc.apps.googleusercontent.com", clientSecret: "top-secret" });
const { readFile } = await import("node:fs/promises");
const raw = await readFile(path.join(stacksDir, "manager", "global-settings.json"), "utf8");
expect(raw).not.toContain("top-secret");
});
it("a blank clientSecret on save keeps whatever secret is already stored", async () => {
await saveGoogleCalendarSettings({ clientId: "abc.apps.googleusercontent.com", clientSecret: "top-secret" });
await saveGoogleCalendarSettings({ clientId: "abc.apps.googleusercontent.com", clientSecret: "" });
const resolved = await resolveGoogleCalendarSettings();
expect(resolved?.clientSecret).toBe("top-secret");
});
it("saving does not clobber email settings already stored in the same file", async () => {
await saveEmailSettings({
host: "smtp.example.com",
port: 587,
username: "user",
password: "pw",
encryption: "starttls",
fromAddress: "from@example.com",
fromName: "",
replyTo: "",
});
await saveGoogleCalendarSettings({ clientId: "abc.apps.googleusercontent.com", clientSecret: "top-secret" });
const email = await readEmailSettingsView();
expect(email.host).toBe("smtp.example.com");
expect(email.hasPassword).toBe(true);
});
it("isGoogleCalendarConfigComplete requires both a non-blank client ID and secret", () => {
expect(isGoogleCalendarConfigComplete({ clientId: "", clientSecret: "" })).toBe(false);
expect(isGoogleCalendarConfigComplete({ clientId: "abc", clientSecret: "" })).toBe(false);
expect(isGoogleCalendarConfigComplete({ clientId: "", clientSecret: "shh" })).toBe(false);
expect(isGoogleCalendarConfigComplete({ clientId: "abc", clientSecret: "shh" })).toBe(true);
});
});
@@ -0,0 +1,109 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
const composeUpMock = vi.fn().mockResolvedValue(undefined);
vi.mock("@/lib/docker", () => ({
composeUp: (...args: unknown[]) => composeUpMock(...args),
composeDown: vi.fn(),
composeStatus: vi.fn().mockResolvedValue("stopped"),
reloadCaddy: vi.fn(),
runMigrations: vi.fn(),
}));
describe("applyGoogleCalendarSettingsToAllBusinesses", () => {
let stacksDir: string;
const originalStacksDir = process.env.STACKS_DIR;
beforeEach(async () => {
stacksDir = await mkdtemp(path.join(tmpdir(), "nc-manager-gcal-rollout-test-"));
process.env.STACKS_DIR = stacksDir;
composeUpMock.mockClear();
});
afterEach(async () => {
if (originalStacksDir === undefined) delete process.env.STACKS_DIR;
else process.env.STACKS_DIR = originalStacksDir;
await rm(stacksDir, { recursive: true, force: true });
});
async function createStack(slug: string, envContent: string) {
const dir = path.join(stacksDir, slug);
await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "compose.yaml"), "name: test\n", "utf8");
await writeFile(path.join(dir, ".env"), envContent, "utf8");
return dir;
}
it("writes the shared client ID/secret into every business and force-recreates each container", async () => {
const { applyGoogleCalendarSettingsToAllBusinesses } = await import("../google-calendar-rollout");
await createStack("acme", ['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"', 'CALENDAR_TOKEN_ENCRYPTION_KEY="existing-key"'].join("\n") + "\n");
const results = await applyGoogleCalendarSettingsToAllBusinesses({
clientId: "shared-client-id",
clientSecret: "shared-client-secret",
});
expect(results).toEqual([{ slug: "acme", ok: true }]);
const env = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_ID="shared-client-id"');
expect(env).toContain('GOOGLE_CALENDAR_CLIENT_SECRET="shared-client-secret"');
expect(composeUpMock).toHaveBeenCalledWith(path.join(stacksDir, "acme"), { forceRecreate: true });
});
it("never touches GOOGLE_CALENDAR_REDIRECT_URI — it stays derived from each business's own domain", async () => {
const { applyGoogleCalendarSettingsToAllBusinesses } = await import("../google-calendar-rollout");
await createStack(
"acme",
['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"', 'GOOGLE_CALENDAR_REDIRECT_URI="https://acme.example.com/api/staff/calendar/google/callback"'].join(
"\n",
) + "\n",
);
await applyGoogleCalendarSettingsToAllBusinesses({ clientId: "shared-client-id", clientSecret: "shared-client-secret" });
const env = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
expect(env).toContain('GOOGLE_CALENDAR_REDIRECT_URI="https://acme.example.com/api/staff/calendar/google/callback"');
});
it("leaves an existing CALENDAR_TOKEN_ENCRYPTION_KEY untouched — it must never be shared across businesses", async () => {
const { applyGoogleCalendarSettingsToAllBusinesses } = await import("../google-calendar-rollout");
await createStack("acme", ['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"', 'CALENDAR_TOKEN_ENCRYPTION_KEY="already-set-key"'].join("\n") + "\n");
await applyGoogleCalendarSettingsToAllBusinesses({ clientId: "shared-client-id", clientSecret: "shared-client-secret" });
const env = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
expect(env).toContain('CALENDAR_TOKEN_ENCRYPTION_KEY="already-set-key"');
});
it("backfills a fresh, distinct CALENDAR_TOKEN_ENCRYPTION_KEY for a business that never had one (pre-fix)", async () => {
const { applyGoogleCalendarSettingsToAllBusinesses } = await import("../google-calendar-rollout");
await createStack("acme", ['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"', 'CALENDAR_TOKEN_ENCRYPTION_KEY=""'].join("\n") + "\n");
await createStack("other", ['BUSINESS_NAME="Other"', 'DOMAIN="other.example.com"', 'CALENDAR_TOKEN_ENCRYPTION_KEY=""'].join("\n") + "\n");
await applyGoogleCalendarSettingsToAllBusinesses({ clientId: "shared-client-id", clientSecret: "shared-client-secret" });
const envA = await readFile(path.join(stacksDir, "acme", ".env"), "utf8");
const envB = await readFile(path.join(stacksDir, "other", ".env"), "utf8");
const keyA = /CALENDAR_TOKEN_ENCRYPTION_KEY="([^"]*)"/.exec(envA)?.[1];
const keyB = /CALENDAR_TOKEN_ENCRYPTION_KEY="([^"]*)"/.exec(envB)?.[1];
expect(keyA).toBeTruthy();
expect(keyB).toBeTruthy();
expect(keyA).not.toBe(keyB);
});
it("reports per-business failures without aborting the rest", async () => {
const { applyGoogleCalendarSettingsToAllBusinesses } = await import("../google-calendar-rollout");
await createStack("acme", ['BUSINESS_NAME="Acme"', 'DOMAIN="acme.example.com"'].join("\n") + "\n");
await createStack("broken", ['BUSINESS_NAME="Broken"', 'DOMAIN="broken.example.com"'].join("\n") + "\n");
composeUpMock.mockImplementation(async (dir: string) => {
if (dir.includes("broken")) throw new Error("container recreate failed");
});
const results = await applyGoogleCalendarSettingsToAllBusinesses({ clientId: "shared-client-id", clientSecret: "shared-client-secret" });
expect(results).toContainEqual({ slug: "acme", ok: true });
expect(results).toContainEqual({ slug: "broken", ok: false, error: "container recreate failed" });
});
});
+31 -3
View File
@@ -70,6 +70,11 @@ export interface BusinessEnvSuperAdminValues {
lastName: string; lastName: string;
} }
export interface BusinessEnvGoogleCalendarValues {
clientId: string;
clientSecret: string;
}
export interface BusinessEnvValues { export interface BusinessEnvValues {
name: string; name: string;
domain: string; domain: string;
@@ -79,6 +84,16 @@ export interface BusinessEnvValues {
authSecret: string; authSecret: string;
installationToken: string; installationToken: string;
cronSecret: string; cronSecret: string;
/**
* Encrypts this business's own stored Google Calendar refresh tokens
* (see src/lib/security/token-encryption.ts) — always generated fresh
* per business, the same as authSecret/installationToken/cronSecret
* above, and deliberately NOT part of the global Google Calendar
* settings below: unlike the OAuth client itself, this key must never
* be shared across businesses, since it's what protects each
* business's own tokens in its own database.
*/
calendarTokenEncryptionKey: string;
/** /**
* Global platform email settings (see src/lib/global-settings.ts), * Global platform email settings (see src/lib/global-settings.ts),
* resolved once at provisioning time and baked straight into this * resolved once at provisioning time and baked straight into this
@@ -92,6 +107,8 @@ export interface BusinessEnvValues {
push?: BusinessEnvPushValues; 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. */ /** 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; superAdmin?: BusinessEnvSuperAdminValues;
/** Global platform Google Calendar OAuth client (see src/lib/global-settings.ts) — same pattern as `email`/`push` above. Omitted falls back to today's blank defaults. */
googleCalendar?: BusinessEnvGoogleCalendarValues;
} }
/** Same shape as .env.business.example, filled in with generated/collected values instead of placeholders. */ /** Same shape as .env.business.example, filled in with generated/collected values instead of placeholders. */
@@ -137,6 +154,13 @@ SUPER_ADMIN_LAST_NAME="${superAdmin.lastName}"`
SUPER_ADMIN_FIRST_NAME="" SUPER_ADMIN_FIRST_NAME=""
SUPER_ADMIN_LAST_NAME=""`; SUPER_ADMIN_LAST_NAME=""`;
const googleCalendar = values.googleCalendar;
const googleCalendarBlock = googleCalendar
? `GOOGLE_CALENDAR_CLIENT_ID="${googleCalendar.clientId}"
GOOGLE_CALENDAR_CLIENT_SECRET="${googleCalendar.clientSecret}"`
: `GOOGLE_CALENDAR_CLIENT_ID=""
GOOGLE_CALENDAR_CLIENT_SECRET=""`;
return `# Generated by the management UI. See .env.business.example for what return `# Generated by the management UI. See .env.business.example for what
# each of these does. # each of these does.
# BUSINESS_NAME is Manager-only bookkeeping (shown on the dashboard/edit # BUSINESS_NAME is Manager-only bookkeeping (shown on the dashboard/edit
@@ -172,10 +196,14 @@ ${superAdminBlock}
AUTH_GOOGLE_ID="" AUTH_GOOGLE_ID=""
AUTH_GOOGLE_SECRET="" AUTH_GOOGLE_SECRET=""
GOOGLE_CALENDAR_CLIENT_ID="" # Managed centrally — see the Manager's Settings > Google Calendar page.
GOOGLE_CALENDAR_CLIENT_SECRET="" # Changing it there updates every business automatically. The redirect
# URI and token encryption key below are per-business, not part of that
# shared setting — see this function's own values.calendarTokenEncryptionKey
# doc comment for why.
${googleCalendarBlock}
GOOGLE_CALENDAR_REDIRECT_URI="https://${values.domain}/api/staff/calendar/google/callback" GOOGLE_CALENDAR_REDIRECT_URI="https://${values.domain}/api/staff/calendar/google/callback"
CALENDAR_TOKEN_ENCRYPTION_KEY="" CALENDAR_TOKEN_ENCRYPTION_KEY="${values.calendarTokenEncryptionKey}"
MEDIA_MAX_UPLOAD_BYTES="5000000" MEDIA_MAX_UPLOAD_BYTES="5000000"
+91 -2
View File
@@ -55,10 +55,17 @@ export interface SuperAdminSettings {
lastName: string; lastName: string;
} }
/** Persisted shape — the client secret field only ever holds the encrypted envelope, never plaintext. */
interface StoredGoogleCalendarSettings {
clientId: string;
encryptedClientSecret: EncryptedPayload | null;
}
interface StoredGlobalSettings { interface StoredGlobalSettings {
email: StoredEmailSettings | null; email: StoredEmailSettings | null;
push: StoredPushSettings | null; push: StoredPushSettings | null;
superAdmin: SuperAdminSettings | null; superAdmin: SuperAdminSettings | null;
googleCalendar: StoredGoogleCalendarSettings | null;
} }
export interface LastTestResult { export interface LastTestResult {
@@ -119,6 +126,24 @@ export interface ResolvedPushSettings {
subject: string; subject: string;
} }
/** Safe-to-render shape — never carries the real client secret, only whether one is set. The client ID is not a secret and is shown in full. */
export interface GoogleCalendarSettingsView {
clientId: string;
hasClientSecret: boolean;
}
/** Write-side input — `clientSecret` omitted/blank means "keep whatever is already stored" (same rule as EmailSettingsInput's password). */
export interface GoogleCalendarSettingsInput {
clientId: string;
clientSecret?: string;
}
/** The fully-resolved shape (real plaintext client secret included) — used only server-side, for the actual .env push, never rendered or logged. */
export interface ResolvedGoogleCalendarSettings {
clientId: string;
clientSecret: string;
}
function settingsFilePath(): string { function settingsFilePath(): string {
return path.join(stacksRoot(), "manager", "global-settings.json"); return path.join(stacksRoot(), "manager", "global-settings.json");
} }
@@ -127,10 +152,15 @@ async function readStoredSettings(): Promise<StoredGlobalSettings> {
try { try {
const raw = await readFile(settingsFilePath(), "utf8"); const raw = await readFile(settingsFilePath(), "utf8");
const parsed = JSON.parse(raw) as StoredGlobalSettings; const parsed = JSON.parse(raw) as StoredGlobalSettings;
return { email: parsed.email ?? null, push: parsed.push ?? null, superAdmin: parsed.superAdmin ?? null }; return {
email: parsed.email ?? null,
push: parsed.push ?? null,
superAdmin: parsed.superAdmin ?? null,
googleCalendar: parsed.googleCalendar ?? null,
};
} catch { } catch {
// No file yet (first run) or unreadable — treat as "nothing configured" rather than throwing, so the Settings page can always render. // 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, superAdmin: null }; return { email: null, push: null, superAdmin: null, googleCalendar: null };
} }
} }
@@ -401,3 +431,62 @@ export async function saveSuperAdminSettings(input: SuperAdminSettings): Promise
await writeStoredSettings({ ...current, superAdmin: next }); await writeStoredSettings({ ...current, superAdmin: next });
return next; return next;
} }
// ── Google Calendar ──────────────────────────────────────────────────────
const DEFAULT_GOOGLE_CALENDAR_VIEW: GoogleCalendarSettingsView = {
clientId: "",
hasClientSecret: false,
};
/** Safe for the Settings page to render directly — the client secret is never included, only whether one is set. */
export async function readGoogleCalendarSettingsView(): Promise<GoogleCalendarSettingsView> {
const { googleCalendar } = await readStoredSettings();
if (!googleCalendar) return DEFAULT_GOOGLE_CALENDAR_VIEW;
return { clientId: googleCalendar.clientId, hasClientSecret: googleCalendar.encryptedClientSecret !== null };
}
/** Server-side only — decrypts the real client secret. Used for the .env push. Returns null if nothing is configured yet. */
export async function resolveGoogleCalendarSettings(): Promise<ResolvedGoogleCalendarSettings | null> {
const { googleCalendar } = await readStoredSettings();
if (!googleCalendar) return null;
return {
clientId: googleCalendar.clientId,
clientSecret: googleCalendar.encryptedClientSecret ? decrypt(googleCalendar.encryptedClientSecret) : "",
};
}
export function isGoogleCalendarConfigComplete(settings: Pick<ResolvedGoogleCalendarSettings, "clientId" | "clientSecret">): boolean {
return Boolean(settings.clientId.trim() && settings.clientSecret.trim());
}
/**
* Persists the shared Google Calendar OAuth client — a blank/omitted
* `clientSecret` keeps whatever is already stored (see this file's own
* header, same rule as saveEmailSettings). One client is meant to cover
* every business on this installation: the client ID/secret identify the
* OAuth application itself, not any one tenant, so Google Cloud Console's
* authorized-redirect-URI list is where each business's own callback URL
* gets added, not here.
*/
export async function saveGoogleCalendarSettings(input: GoogleCalendarSettingsInput): Promise<ResolvedGoogleCalendarSettings> {
const current = await readStoredSettings();
const keepExistingSecret = !input.clientSecret || !input.clientSecret.trim();
let encryptedClientSecret = current.googleCalendar?.encryptedClientSecret ?? null;
let resolvedSecret = "";
if (!keepExistingSecret) {
encryptedClientSecret = encrypt(input.clientSecret!.trim());
resolvedSecret = input.clientSecret!.trim();
} else if (encryptedClientSecret) {
resolvedSecret = decrypt(encryptedClientSecret);
}
const next: StoredGoogleCalendarSettings = {
clientId: input.clientId.trim(),
encryptedClientSecret,
};
await writeStoredSettings({ ...current, googleCalendar: next });
return { clientId: next.clientId, clientSecret: resolvedSecret };
}
@@ -0,0 +1,64 @@
import path from "node:path";
import { composeUp } from "./docker";
import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file";
import type { ResolvedGoogleCalendarSettings } from "./global-settings";
import { randomHex } from "./random";
import { listBusinessStacks, stackDir } from "./stacks";
export interface GoogleCalendarRolloutResult {
slug: string;
ok: boolean;
error?: string;
}
/**
* Pushes the global Google Calendar OAuth client into every business's
* own `.env` and force-recreates its container — same mechanism as
* email-rollout.ts/push-rollout.ts/super-admin-rollout.ts.
*
* GOOGLE_CALENDAR_REDIRECT_URI is deliberately never touched here: it's
* derived from each business's own domain (see provisioning.ts's
* updateBusiness), not from this global setting, and must keep pointing
* at that business's own /api/staff/calendar/google/callback regardless
* of which shared OAuth client is doing the authorizing.
*
* CALENDAR_TOKEN_ENCRYPTION_KEY is backfilled here too, but only when a
* business doesn't already have one — every business generates its own
* at provisioning time now (see compose-template.ts), but a business
* provisioned before that existed would otherwise have a blank key and
* hard-fail the moment a staff member tries to connect a calendar (see
* src/lib/security/token-encryption.ts). Unlike the OAuth client itself,
* this key must never be shared across businesses — it encrypts each
* business's own stored refresh tokens in its own database — so a
* business that already has one keeps it untouched forever, the same way
* EMAIL_REPLY_TO is only ever filled in as a fallback, never overwritten.
*/
export async function applyGoogleCalendarSettingsToAllBusinesses(
settings: ResolvedGoogleCalendarSettings,
): Promise<GoogleCalendarRolloutResult[]> {
const businesses = await listBusinessStacks();
const results: GoogleCalendarRolloutResult[] = [];
for (const business of businesses) {
try {
const dir = stackDir(business.slug);
const envPath = path.join(dir, ".env");
const currentEnv = await readEnvFile(envPath);
const currentEncryptionKey = currentEnv ? readEnvValue(currentEnv, "CALENDAR_TOKEN_ENCRYPTION_KEY") : null;
const businessHasOwnEncryptionKey = Boolean(currentEncryptionKey && currentEncryptionKey.trim());
await upsertEnvValues(envPath, {
GOOGLE_CALENDAR_CLIENT_ID: settings.clientId,
GOOGLE_CALENDAR_CLIENT_SECRET: settings.clientSecret,
...(businessHasOwnEncryptionKey ? {} : { CALENDAR_TOKEN_ENCRYPTION_KEY: randomHex(32) }),
});
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;
}
+16
View File
@@ -6,10 +6,12 @@ import { composeDown, composeUp, reloadCaddy, runMigrations } from "./docker";
import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file"; import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file";
import { import {
isEmailConfigComplete, isEmailConfigComplete,
isGoogleCalendarConfigComplete,
isPushConfigComplete, isPushConfigComplete,
isSuperAdminConfigComplete, isSuperAdminConfigComplete,
readSuperAdminSettings, readSuperAdminSettings,
resolveEmailSettings, resolveEmailSettings,
resolveGoogleCalendarSettings,
resolvePushSettings, resolvePushSettings,
} from "./global-settings"; } from "./global-settings";
import { randomHex } from "./random"; import { randomHex } from "./random";
@@ -140,6 +142,7 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise<
const authSecret = randomHex(32); const authSecret = randomHex(32);
const installationToken = randomHex(32); const installationToken = randomHex(32);
const cronSecret = randomHex(32); const cronSecret = randomHex(32);
const calendarTokenEncryptionKey = randomHex(32);
await createDatabaseAndUser(databaseIdentifier, databaseIdentifier, databasePassword); await createDatabaseAndUser(databaseIdentifier, databaseIdentifier, databasePassword);
@@ -185,6 +188,17 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise<
? { email: globalSuperAdminSettings.email, firstName: globalSuperAdminSettings.firstName, lastName: globalSuperAdminSettings.lastName } ? { email: globalSuperAdminSettings.email, firstName: globalSuperAdminSettings.firstName, lastName: globalSuperAdminSettings.lastName }
: undefined; : undefined;
// Same pattern again — a shared Google Calendar OAuth client (Manager's
// Settings > Google Calendar page), baked in at creation only when
// fully configured. calendarTokenEncryptionKey above is NOT part of
// this — see compose-template.ts's own doc comment for why it's always
// freshly generated per business instead.
const globalGoogleCalendarSettings = await resolveGoogleCalendarSettings();
const googleCalendar =
globalGoogleCalendarSettings && isGoogleCalendarConfigComplete(globalGoogleCalendarSettings)
? { clientId: globalGoogleCalendarSettings.clientId, clientSecret: globalGoogleCalendarSettings.clientSecret }
: undefined;
const dir = stackDir(slug); const dir = stackDir(slug);
await mkdir(dir, { recursive: true }); await mkdir(dir, { recursive: true });
await writeFile(path.join(dir, "compose.yaml"), generateComposeYaml(slug), "utf8"); await writeFile(path.join(dir, "compose.yaml"), generateComposeYaml(slug), "utf8");
@@ -199,9 +213,11 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise<
authSecret, authSecret,
installationToken, installationToken,
cronSecret, cronSecret,
calendarTokenEncryptionKey,
email, email,
push, push,
superAdmin, superAdmin,
googleCalendar,
}), }),
"utf8", "utf8",
); );