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