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
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user