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:
11 files changed
+684
-10
No files matched your search
@@ -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<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's own callback URL —{" "}
|
||||
<code className="rounded bg-stone px-1 py-0.5 text-xs">
|
||||
https://<business-domain>/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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="space-y-8">
|
||||
@@ -25,6 +31,7 @@ export default async function SettingsPage() {
|
||||
<EmailSettingsForm initial={email} />
|
||||
<PushSettingsForm initial={push} />
|
||||
<SuperAdminSettingsForm initial={superAdmin} />
|
||||
<GoogleCalendarSettingsForm initial={googleCalendar} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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<StoredGlobalSettings> {
|
||||
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<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;
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
Reference in new issue
Block a user