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
@@ -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