diff --git a/.claude/launch.json b/.claude/launch.json index 1e58ebd..4b122eb 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -6,6 +6,12 @@ "runtimeExecutable": "npm", "runtimeArgs": ["run", "dev"], "port": 3000 + }, + { + "name": "nextchair-manager-dev", + "runtimeExecutable": "npm", + "runtimeArgs": ["--prefix", "manager", "run", "dev", "--", "-p", "3100"], + "port": 3100 } ] } diff --git a/manager/src/app/dashboard/settings/actions.ts b/manager/src/app/dashboard/settings/actions.ts index a24067b..5f42878 100644 --- a/manager/src/app/dashboard/settings/actions.ts +++ b/manager/src/app/dashboard/settings/actions.ts @@ -1,22 +1,27 @@ "use server"; 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 { generateVapidKeys, isEmailConfigComplete, + isGoogleCalendarConfigComplete, isPushConfigComplete, isSuperAdminConfigComplete, readSuperAdminSettings, recordLastTestResult, resolveEmailSettings, + resolveGoogleCalendarSettings, resolvePushSettings, saveEmailSettings, + saveGoogleCalendarSettings, savePushSubject, saveSuperAdminSettings, VapidKeysAlreadyExistError, type EmailEncryptionMode, type EmailSettingsInput, + type GoogleCalendarSettingsInput, } from "@/lib/global-settings"; import { applySuperAdminToAllBusinesses, type SuperAdminRolloutResult } from "@/lib/super-admin-rollout"; import { sendTestSmtpEmail } from "@/lib/test-email"; @@ -298,3 +303,59 @@ export async function reapplySuperAdminAction( const rollout = await applySuperAdminToAllBusinesses(saved); 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 { + 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 { + 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 }; +} diff --git a/manager/src/app/dashboard/settings/google-calendar-settings-form.tsx b/manager/src/app/dashboard/settings/google-calendar-settings-form.tsx new file mode 100644 index 0000000..557dfae --- /dev/null +++ b/manager/src/app/dashboard/settings/google-calendar-settings-form.tsx @@ -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 ? ( + Configured + ) : ( + Configured (some businesses failed) + ); + } + if (initial.hasClientSecret && initial.clientId) { + return Configured; + } + return Not configured; +} + +/** + * 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 ( +
+
+

Google Calendar

+ +
+

+ 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's own callback URL —{" "} + + https://<business-domain>/api/staff/calendar/google/callback + {" "} + — as an authorized redirect URI on this client. +

+ +
+
+
+ + 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" + /> +
+
+ + 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" + /> +

+ {initial.hasClientSecret ? "A client secret is already saved — leave this blank to keep it." : "No client secret saved yet."} +

+
+
+ + {saveState.error && ( +

+ {saveState.error} +

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

Settings saved as a draft. Add a client secret to enable it and roll it out to businesses.

+ ) : ( +

+ 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(", ")} + + )} +

+ )} +
+ )} + +
+ + +
+ {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/app/dashboard/settings/page.tsx b/manager/src/app/dashboard/settings/page.tsx index 01a7e65..50e3d03 100644 --- a/manager/src/app/dashboard/settings/page.tsx +++ b/manager/src/app/dashboard/settings/page.tsx @@ -1,6 +1,7 @@ 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 { GoogleCalendarSettingsForm } from "./google-calendar-settings-form"; import { PushSettingsForm } from "./push-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 — * see src/lib/global-settings.ts's own header for the storage design. - * "Email", "Push Notifications", and "Super Administrator" exist today; a - * later section (SMS, storage, backups, branding, locale, monitoring) is - * a sibling here, not a rewrite. + * "Email", "Push Notifications", "Super Administrator", and "Google + * Calendar" 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, superAdmin] = await Promise.all([readEmailSettingsView(), readPushSettingsView(), readSuperAdminSettings()]); + const [email, push, superAdmin, googleCalendar] = await Promise.all([ + readEmailSettingsView(), + readPushSettingsView(), + readSuperAdminSettings(), + readGoogleCalendarSettingsView(), + ]); return (
@@ -25,6 +31,7 @@ export default async function SettingsPage() { +
); } diff --git a/manager/src/lib/__tests__/compose-template.test.ts b/manager/src/lib/__tests__/compose-template.test.ts index 30905d3..72b1a39 100644 --- a/manager/src/lib/__tests__/compose-template.test.ts +++ b/manager/src/lib/__tests__/compose-template.test.ts @@ -10,6 +10,7 @@ const baseValues: BusinessEnvValues = { authSecret: "auth-secret", installationToken: "the-installation-token-value", cronSecret: "cron-secret", + calendarTokenEncryptionKey: "calendar-token-encryption-key", }; describe("generateBusinessEnv — INSTALLATION_TOKEN", () => { @@ -76,3 +77,47 @@ describe("generateBusinessEnv — SUPER_ADMIN_*", () => { 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"); + }); +}); diff --git a/manager/src/lib/__tests__/global-settings-google-calendar.test.ts b/manager/src/lib/__tests__/global-settings-google-calendar.test.ts new file mode 100644 index 0000000..333f106 --- /dev/null +++ b/manager/src/lib/__tests__/global-settings-google-calendar.test.ts @@ -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); + }); +}); diff --git a/manager/src/lib/__tests__/google-calendar-rollout.test.ts b/manager/src/lib/__tests__/google-calendar-rollout.test.ts new file mode 100644 index 0000000..d5451f8 --- /dev/null +++ b/manager/src/lib/__tests__/google-calendar-rollout.test.ts @@ -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" }); + }); +}); diff --git a/manager/src/lib/compose-template.ts b/manager/src/lib/compose-template.ts index 78060a4..1efc5e9 100644 --- a/manager/src/lib/compose-template.ts +++ b/manager/src/lib/compose-template.ts @@ -70,6 +70,11 @@ export interface BusinessEnvSuperAdminValues { lastName: string; } +export interface BusinessEnvGoogleCalendarValues { + clientId: string; + clientSecret: string; +} + export interface BusinessEnvValues { name: string; domain: string; @@ -79,6 +84,16 @@ export interface BusinessEnvValues { authSecret: string; installationToken: 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), * resolved once at provisioning time and baked straight into this @@ -92,6 +107,8 @@ export interface BusinessEnvValues { 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; + /** 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. */ @@ -137,6 +154,13 @@ SUPER_ADMIN_LAST_NAME="${superAdmin.lastName}"` SUPER_ADMIN_FIRST_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 # each of these does. # BUSINESS_NAME is Manager-only bookkeeping (shown on the dashboard/edit @@ -172,10 +196,14 @@ ${superAdminBlock} AUTH_GOOGLE_ID="" AUTH_GOOGLE_SECRET="" -GOOGLE_CALENDAR_CLIENT_ID="" -GOOGLE_CALENDAR_CLIENT_SECRET="" +# Managed centrally — see the Manager's Settings > Google Calendar page. +# 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" -CALENDAR_TOKEN_ENCRYPTION_KEY="" +CALENDAR_TOKEN_ENCRYPTION_KEY="${values.calendarTokenEncryptionKey}" MEDIA_MAX_UPLOAD_BYTES="5000000" diff --git a/manager/src/lib/global-settings.ts b/manager/src/lib/global-settings.ts index 6955037..df6c3a7 100644 --- a/manager/src/lib/global-settings.ts +++ b/manager/src/lib/global-settings.ts @@ -55,10 +55,17 @@ export interface SuperAdminSettings { 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 { email: StoredEmailSettings | null; push: StoredPushSettings | null; superAdmin: SuperAdminSettings | null; + googleCalendar: StoredGoogleCalendarSettings | null; } export interface LastTestResult { @@ -119,6 +126,24 @@ export interface ResolvedPushSettings { 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 { return path.join(stacksRoot(), "manager", "global-settings.json"); } @@ -127,10 +152,15 @@ 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, superAdmin: parsed.superAdmin ?? null }; + return { + email: parsed.email ?? null, + push: parsed.push ?? null, + superAdmin: parsed.superAdmin ?? null, + googleCalendar: parsed.googleCalendar ?? 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, 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 }); 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 { + 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 { + const { googleCalendar } = await readStoredSettings(); + if (!googleCalendar) return null; + return { + clientId: googleCalendar.clientId, + clientSecret: googleCalendar.encryptedClientSecret ? decrypt(googleCalendar.encryptedClientSecret) : "", + }; +} + +export function isGoogleCalendarConfigComplete(settings: Pick): 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 { + 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 }; +} diff --git a/manager/src/lib/google-calendar-rollout.ts b/manager/src/lib/google-calendar-rollout.ts new file mode 100644 index 0000000..3dbfca8 --- /dev/null +++ b/manager/src/lib/google-calendar-rollout.ts @@ -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 { + 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; +} diff --git a/manager/src/lib/provisioning.ts b/manager/src/lib/provisioning.ts index cc1f71d..93d6963 100644 --- a/manager/src/lib/provisioning.ts +++ b/manager/src/lib/provisioning.ts @@ -6,10 +6,12 @@ import { composeDown, composeUp, reloadCaddy, runMigrations } from "./docker"; import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file"; import { isEmailConfigComplete, + isGoogleCalendarConfigComplete, isPushConfigComplete, isSuperAdminConfigComplete, readSuperAdminSettings, resolveEmailSettings, + resolveGoogleCalendarSettings, resolvePushSettings, } from "./global-settings"; import { randomHex } from "./random"; @@ -140,6 +142,7 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise< const authSecret = randomHex(32); const installationToken = randomHex(32); const cronSecret = randomHex(32); + const calendarTokenEncryptionKey = randomHex(32); 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 } : 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); await mkdir(dir, { recursive: true }); await writeFile(path.join(dir, "compose.yaml"), generateComposeYaml(slug), "utf8"); @@ -199,9 +213,11 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise< authSecret, installationToken, cronSecret, + calendarTokenEncryptionKey, email, push, superAdmin, + googleCalendar, }), "utf8", );