Added a cron job processor so emails and notifications can get processed. Added super admin settings with a gui to edit.
This commit is contained in:
17 files changed
+645
-48
No files matched your search
+12
-7
@@ -49,18 +49,23 @@ NEXT_PUBLIC_APP_URL="https://example-business.example.com"
|
|||||||
# openssl rand -hex 32
|
# openssl rand -hex 32
|
||||||
INSTALLATION_TOKEN="replace-me"
|
INSTALLATION_TOKEN="replace-me"
|
||||||
# Shared secret an external scheduler presents to
|
# Shared secret an external scheduler presents to
|
||||||
# /api/cron/process-notifications — generate with: openssl rand -hex 32
|
# /api/cron/process-notifications and /api/cron/sync-google-reviews —
|
||||||
|
# generate with: openssl rand -hex 32. Without something actually calling
|
||||||
|
# those on a schedule, queued notifications (staff invites, reminders,
|
||||||
|
# etc.) never get sent — see the root README's "Notification delivery"
|
||||||
|
# section and stacks/infra/scripts/process-cron.sh for the one-time
|
||||||
|
# crontab setup this requires.
|
||||||
CRON_SECRET="replace-me"
|
CRON_SECRET="replace-me"
|
||||||
|
|
||||||
# ── Super Administrator (optional — see scripts/ensure-super-admin.ts) ──
|
# ── Super Administrator (optional — see scripts/ensure-super-admin.ts) ──
|
||||||
# Platform-level account with every Owner permission for THIS business.
|
# Platform-level account with every Owner permission for THIS business.
|
||||||
# Not run automatically by this compose file (that's a Docker-entrypoint-
|
# Runs automatically on every container start (see docker/entrypoint.sh)
|
||||||
# only behaviour, see docker/entrypoint.sh) — run it manually, once:
|
# — filling these in and recreating the container is enough, no manual
|
||||||
# docker exec -it next-chair-example-business npx tsx scripts/ensure-super-admin.ts
|
# `docker exec` needed. No password — signs in the same OTP way as
|
||||||
# Leave SUPER_ADMIN_EMAIL blank to skip entirely and use the /install
|
# everyone else, via a one-time code emailed to SUPER_ADMIN_EMAIL. Leave
|
||||||
# wizard's own administrator account instead.
|
# SUPER_ADMIN_EMAIL blank to skip entirely and use the /install wizard's
|
||||||
|
# own administrator account instead.
|
||||||
SUPER_ADMIN_EMAIL=""
|
SUPER_ADMIN_EMAIL=""
|
||||||
SUPER_ADMIN_PASSWORD=""
|
|
||||||
SUPER_ADMIN_FIRST_NAME=""
|
SUPER_ADMIN_FIRST_NAME=""
|
||||||
SUPER_ADMIN_LAST_NAME=""
|
SUPER_ADMIN_LAST_NAME=""
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -82,12 +82,12 @@ INSTALLATION_TOKEN=""
|
|||||||
# Super Administrator (Phase 9) — platform-level account with every Owner
|
# Super Administrator (Phase 9) — platform-level account with every Owner
|
||||||
# permission, provisioned from these env vars rather than the installation
|
# permission, provisioned from these env vars rather than the installation
|
||||||
# wizard (see scripts/ensure-super-admin.ts, run on every container start).
|
# wizard (see scripts/ensure-super-admin.ts, run on every container start).
|
||||||
# Has no StaffProfile by design: never appears in Team, the customer-facing
|
# No password — signs in the same OTP way as everyone else, via a
|
||||||
# barber picker, or staff totals, and can never receive an appointment. All
|
# one-time code emailed to SUPER_ADMIN_EMAIL. Has no StaffProfile by
|
||||||
# four optional — leave SUPER_ADMIN_EMAIL unset to skip provisioning one
|
# design: never appears in Team, the customer-facing barber picker, or
|
||||||
# entirely. Re-run to rotate the password (re-hashed every run).
|
# staff totals, and can never receive an appointment. All three optional
|
||||||
|
# — leave SUPER_ADMIN_EMAIL unset to skip provisioning one entirely.
|
||||||
SUPER_ADMIN_EMAIL=""
|
SUPER_ADMIN_EMAIL=""
|
||||||
SUPER_ADMIN_PASSWORD=""
|
|
||||||
SUPER_ADMIN_FIRST_NAME=""
|
SUPER_ADMIN_FIRST_NAME=""
|
||||||
SUPER_ADMIN_LAST_NAME=""
|
SUPER_ADMIN_LAST_NAME=""
|
||||||
|
|
||||||
|
|||||||
@@ -138,6 +138,38 @@ this for you), then finish its setup by visiting
|
|||||||
|
|
||||||
Repeat this step for each additional business, whenever you want one.
|
Repeat this step for each additional business, whenever you want one.
|
||||||
|
|
||||||
|
### 6. Notification delivery (required once, covers every business)
|
||||||
|
|
||||||
|
Staff invitation emails, appointment reminders, loyalty reward emails,
|
||||||
|
and Google Reviews sync are **not sent automatically** — the app only
|
||||||
|
queues them; something has to actually trigger delivery on a schedule by
|
||||||
|
calling two `CRON_SECRET`-gated endpoints
|
||||||
|
(`/api/cron/process-notifications`, `/api/cron/sync-google-reviews` — see
|
||||||
|
their own route files for the full reasoning). Skip this step and every
|
||||||
|
business's queued notifications just sit as `PENDING` forever — the UI
|
||||||
|
still says "an invitation email has been sent" the moment it's queued,
|
||||||
|
regardless of whether anything has actually delivered it yet.
|
||||||
|
|
||||||
|
[stacks/infra/scripts/process-cron.sh](stacks/infra/scripts/process-cron.sh)
|
||||||
|
calls both endpoints for **every** business under `STACKS_DIR` in one
|
||||||
|
run, so a single crontab entry covers businesses added later too — no
|
||||||
|
per-business cron line to maintain. Add this to the VPS's crontab
|
||||||
|
(`crontab -e`) once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Deliver queued notifications every 2 minutes.
|
||||||
|
*/2 * * * * STACKS_DIR=/opt/nextchair/stacks /opt/nextchair/stacks/infra/scripts/process-cron.sh notifications >> /var/log/nextchair-cron.log 2>&1
|
||||||
|
|
||||||
|
# Refresh Google Reviews once a day.
|
||||||
|
0 3 * * * STACKS_DIR=/opt/nextchair/stacks /opt/nextchair/stacks/infra/scripts/process-cron.sh reviews >> /var/log/nextchair-cron.log 2>&1
|
||||||
|
```
|
||||||
|
|
||||||
|
(Adjust `STACKS_DIR` if you cloned this repo somewhere other than
|
||||||
|
`/opt/nextchair`.) To confirm it's working, or to flush a notification
|
||||||
|
that's stuck pending right now, run either line's command by hand and
|
||||||
|
check `/var/log/nextchair-cron.log` — a healthy run logs `<slug>
|
||||||
|
process-notifications ok` for every business.
|
||||||
|
|
||||||
## Updating after a code change
|
## Updating after a code change
|
||||||
|
|
||||||
For an existing NextChair server, **do not re-run the fresh VPS setup above** and do not overwrite your production `.env` files.
|
For an existing NextChair server, **do not re-run the fresh VPS setup above** and do not overwrite your production `.env` files.
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ export default function globalSetup() {
|
|||||||
// when the caller hasn't already set their own (same fallback pattern
|
// when the caller hasn't already set their own (same fallback pattern
|
||||||
// as AUTH_SECRET in playwright.config.ts).
|
// as AUTH_SECRET in playwright.config.ts).
|
||||||
SUPER_ADMIN_EMAIL: process.env.SUPER_ADMIN_EMAIL || "superadmin@blakeheritage.co.uk",
|
SUPER_ADMIN_EMAIL: process.env.SUPER_ADMIN_EMAIL || "superadmin@blakeheritage.co.uk",
|
||||||
SUPER_ADMIN_PASSWORD: process.env.SUPER_ADMIN_PASSWORD || "password123",
|
|
||||||
SUPER_ADMIN_FIRST_NAME: process.env.SUPER_ADMIN_FIRST_NAME || "Super",
|
SUPER_ADMIN_FIRST_NAME: process.env.SUPER_ADMIN_FIRST_NAME || "Super",
|
||||||
SUPER_ADMIN_LAST_NAME: process.env.SUPER_ADMIN_LAST_NAME || "Admin",
|
SUPER_ADMIN_LAST_NAME: process.env.SUPER_ADMIN_LAST_NAME || "Admin",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,15 +6,19 @@ import {
|
|||||||
generateVapidKeys,
|
generateVapidKeys,
|
||||||
isEmailConfigComplete,
|
isEmailConfigComplete,
|
||||||
isPushConfigComplete,
|
isPushConfigComplete,
|
||||||
|
isSuperAdminConfigComplete,
|
||||||
|
readSuperAdminSettings,
|
||||||
recordLastTestResult,
|
recordLastTestResult,
|
||||||
resolveEmailSettings,
|
resolveEmailSettings,
|
||||||
resolvePushSettings,
|
resolvePushSettings,
|
||||||
saveEmailSettings,
|
saveEmailSettings,
|
||||||
savePushSubject,
|
savePushSubject,
|
||||||
|
saveSuperAdminSettings,
|
||||||
VapidKeysAlreadyExistError,
|
VapidKeysAlreadyExistError,
|
||||||
type EmailEncryptionMode,
|
type EmailEncryptionMode,
|
||||||
type EmailSettingsInput,
|
type EmailSettingsInput,
|
||||||
} from "@/lib/global-settings";
|
} from "@/lib/global-settings";
|
||||||
|
import { applySuperAdminToAllBusinesses, type SuperAdminRolloutResult } from "@/lib/super-admin-rollout";
|
||||||
import { sendTestSmtpEmail } from "@/lib/test-email";
|
import { sendTestSmtpEmail } from "@/lib/test-email";
|
||||||
|
|
||||||
function parseEncryption(value: FormDataEntryValue | null): EmailEncryptionMode {
|
function parseEncryption(value: FormDataEntryValue | null): EmailEncryptionMode {
|
||||||
@@ -238,3 +242,59 @@ export async function reapplyPushSettingsAction(
|
|||||||
const rollout = await applyPushSettingsToAllBusinesses(resolved);
|
const rollout = await applyPushSettingsToAllBusinesses(resolved);
|
||||||
return { error: null, rollout };
|
return { error: null, rollout };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Super Administrator ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface SaveSuperAdminState {
|
||||||
|
error: string | null;
|
||||||
|
saved: boolean;
|
||||||
|
rollout: SuperAdminRolloutResult[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the global default Super Administrator and immediately rolls it
|
||||||
|
* out to every existing business — unlike email/push, there's no
|
||||||
|
* "incomplete config, save as a draft" case worth distinguishing (an
|
||||||
|
* email is either present or it isn't, no secondary field like a
|
||||||
|
* password gates completeness), so this always rolls out once the email
|
||||||
|
* itself validates. New businesses pick this up automatically at
|
||||||
|
* provisioning time (see provisioning.ts) — this action is what applies
|
||||||
|
* a change to businesses that already exist.
|
||||||
|
*/
|
||||||
|
export async function saveSuperAdminSettingsAction(
|
||||||
|
_prevState: SaveSuperAdminState,
|
||||||
|
formData: FormData,
|
||||||
|
): Promise<SaveSuperAdminState> {
|
||||||
|
const email = String(formData.get("email") ?? "").trim();
|
||||||
|
const firstName = String(formData.get("firstName") ?? "").trim();
|
||||||
|
const lastName = String(formData.get("lastName") ?? "").trim();
|
||||||
|
|
||||||
|
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
||||||
|
return { error: "Enter a valid email address.", saved: false, rollout: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const saved = await saveSuperAdminSettings({ email, firstName, lastName });
|
||||||
|
if (!isSuperAdminConfigComplete(saved)) {
|
||||||
|
return { error: null, saved: true, rollout: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rollout = await applySuperAdminToAllBusinesses(saved);
|
||||||
|
return { error: null, saved: true, rollout };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReapplySuperAdminState {
|
||||||
|
error: string | null;
|
||||||
|
rollout: SuperAdminRolloutResult[] | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function reapplySuperAdminAction(
|
||||||
|
_prevState: ReapplySuperAdminState,
|
||||||
|
_formData: FormData,
|
||||||
|
): Promise<ReapplySuperAdminState> {
|
||||||
|
const saved = await readSuperAdminSettings();
|
||||||
|
if (!isSuperAdminConfigComplete(saved)) {
|
||||||
|
return { error: "No Super Administrator email is saved yet — nothing to reapply.", rollout: null };
|
||||||
|
}
|
||||||
|
const rollout = await applySuperAdminToAllBusinesses(saved);
|
||||||
|
return { error: null, rollout };
|
||||||
|
}
|
||||||
@@ -1,19 +1,20 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { readEmailSettingsView, readPushSettingsView } from "@/lib/global-settings";
|
import { readEmailSettingsView, readPushSettingsView, readSuperAdminSettings } from "@/lib/global-settings";
|
||||||
import { EmailSettingsForm } from "./email-settings-form";
|
import { EmailSettingsForm } from "./email-settings-form";
|
||||||
import { PushSettingsForm } from "./push-settings-form";
|
import { PushSettingsForm } from "./push-settings-form";
|
||||||
|
import { SuperAdminSettingsForm } from "./super-admin-settings-form";
|
||||||
|
|
||||||
export const metadata: Metadata = { title: "Settings" };
|
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" and "Push Notifications" exist today; a later section (SMS,
|
* "Email", "Push Notifications", and "Super Administrator" exist today; a
|
||||||
* storage, backups, branding, locale, monitoring) is a sibling here, not
|
* later section (SMS, storage, backups, branding, locale, monitoring) is
|
||||||
* a rewrite.
|
* a sibling here, not a rewrite.
|
||||||
*/
|
*/
|
||||||
export default async function SettingsPage() {
|
export default async function SettingsPage() {
|
||||||
const [email, push] = await Promise.all([readEmailSettingsView(), readPushSettingsView()]);
|
const [email, push, superAdmin] = await Promise.all([readEmailSettingsView(), readPushSettingsView(), readSuperAdminSettings()]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
@@ -23,6 +24,7 @@ export default async function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
<EmailSettingsForm initial={email} />
|
<EmailSettingsForm initial={email} />
|
||||||
<PushSettingsForm initial={push} />
|
<PushSettingsForm initial={push} />
|
||||||
|
<SuperAdminSettingsForm initial={superAdmin} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useActionState, useEffect, useState } from "react";
|
||||||
|
import type { SuperAdminSettings } from "@/lib/global-settings";
|
||||||
|
import {
|
||||||
|
reapplySuperAdminAction,
|
||||||
|
saveSuperAdminSettingsAction,
|
||||||
|
type ReapplySuperAdminState,
|
||||||
|
type SaveSuperAdminState,
|
||||||
|
} from "./actions";
|
||||||
|
|
||||||
|
const saveInitialState: SaveSuperAdminState = { error: null, saved: false, rollout: null };
|
||||||
|
const reapplyInitialState: ReapplySuperAdminState = { error: null, rollout: null };
|
||||||
|
|
||||||
|
function StatusBadge({ initial, saveState }: { initial: SuperAdminSettings | null; saveState: SaveSuperAdminState }) {
|
||||||
|
const configured = (saveState.saved && !!saveState.rollout) || !!initial?.email;
|
||||||
|
return configured ? (
|
||||||
|
<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-stone px-3 py-1 text-xs font-semibold text-muted">Not configured</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Controlled inputs — same reasoning as email-settings-form.tsx: two actions (Save / Reapply) share one field set, and an uncontrolled defaultValue input would reset after either resolves. */
|
||||||
|
export function SuperAdminSettingsForm({ initial }: { initial: SuperAdminSettings | null }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [saveState, saveAction, isSaving] = useActionState(saveSuperAdminSettingsAction, saveInitialState);
|
||||||
|
const [reapplyState, reapplyAction, isReapplying] = useActionState(reapplySuperAdminAction, reapplyInitialState);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (saveState.saved) router.refresh();
|
||||||
|
}, [saveState, router]);
|
||||||
|
|
||||||
|
const [email, setEmail] = useState(initial?.email ?? "");
|
||||||
|
const [firstName, setFirstName] = useState(initial?.firstName ?? "");
|
||||||
|
const [lastName, setLastName] = useState(initial?.lastName ?? "");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="text-lg font-semibold">Super Administrator</h2>
|
||||||
|
<StatusBadge initial={initial} saveState={saveState} />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
A platform-level account with every Owner permission, provisioned automatically on every business — no
|
||||||
|
password, it signs in the same one-time-code way as everyone else. Never appears in any business's Team
|
||||||
|
list, the customer-facing barber picker, or staff totals, and can never receive an appointment.
|
||||||
|
</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="email" className="block text-sm font-medium text-muted">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="email"
|
||||||
|
name="email"
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
placeholder="you@example.com"
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div />
|
||||||
|
<div>
|
||||||
|
<label htmlFor="firstName" className="block text-sm font-medium text-muted">
|
||||||
|
First name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="firstName"
|
||||||
|
name="firstName"
|
||||||
|
type="text"
|
||||||
|
value={firstName}
|
||||||
|
onChange={(e) => setFirstName(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="lastName" className="block text-sm font-medium text-muted">
|
||||||
|
Last name
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="lastName"
|
||||||
|
name="lastName"
|
||||||
|
type="text"
|
||||||
|
value={lastName}
|
||||||
|
onChange={(e) => setLastName(e.target.value)}
|
||||||
|
className="mt-1 w-full rounded-md border border-line px-3 py-2 text-sm"
|
||||||
|
/>
|
||||||
|
</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>Saved as a draft — enter an email to apply it to every business.</p>
|
||||||
|
) : (
|
||||||
|
<p>
|
||||||
|
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?.email}
|
||||||
|
title={!initial?.email ? "Save an email 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -49,3 +49,30 @@ describe("generateBusinessEnv — AUTH_URL", () => {
|
|||||||
expect(env).toContain('NEXT_PUBLIC_APP_URL="https://other-business.example.com"');
|
expect(env).toContain('NEXT_PUBLIC_APP_URL="https://other-business.example.com"');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("generateBusinessEnv — SUPER_ADMIN_*", () => {
|
||||||
|
it("leaves SUPER_ADMIN_EMAIL blank when no global default is configured", () => {
|
||||||
|
const env = generateBusinessEnv(baseValues);
|
||||||
|
expect(env).toContain('SUPER_ADMIN_EMAIL=""');
|
||||||
|
expect(env).toContain('SUPER_ADMIN_FIRST_NAME=""');
|
||||||
|
expect(env).toContain('SUPER_ADMIN_LAST_NAME=""');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bakes in the global default Super Administrator when given one", () => {
|
||||||
|
const env = generateBusinessEnv({
|
||||||
|
...baseValues,
|
||||||
|
superAdmin: { email: "lazis002@gmail.com", firstName: "Stefan", lastName: "Lazic" },
|
||||||
|
});
|
||||||
|
expect(env).toContain('SUPER_ADMIN_EMAIL="lazis002@gmail.com"');
|
||||||
|
expect(env).toContain('SUPER_ADMIN_FIRST_NAME="Stefan"');
|
||||||
|
expect(env).toContain('SUPER_ADMIN_LAST_NAME="Lazic"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never writes a SUPER_ADMIN_PASSWORD line — sign-in is OTP-only", () => {
|
||||||
|
const env = generateBusinessEnv({
|
||||||
|
...baseValues,
|
||||||
|
superAdmin: { email: "lazis002@gmail.com", firstName: "Stefan", lastName: "Lazic" },
|
||||||
|
});
|
||||||
|
expect(env).not.toContain("SUPER_ADMIN_PASSWORD");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
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 { isSuperAdminConfigComplete, readSuperAdminSettings, saveSuperAdminSettings } from "../global-settings";
|
||||||
|
|
||||||
|
describe("global-settings — Super Administrator (not a secret, no encryption involved)", () => {
|
||||||
|
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-super-admin-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 null before anything has been saved", async () => {
|
||||||
|
expect(await readSuperAdminSettings()).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saves and reads back the email/first/last name, lowercasing and trimming the email", async () => {
|
||||||
|
await saveSuperAdminSettings({ email: " Lazis002@Gmail.com ", firstName: " Stefan ", lastName: " Lazic " });
|
||||||
|
|
||||||
|
const saved = await readSuperAdminSettings();
|
||||||
|
expect(saved).toEqual({ email: "lazis002@gmail.com", firstName: "Stefan", lastName: "Lazic" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saving does not clobber email/push settings already stored in the same file", async () => {
|
||||||
|
const { saveEmailSettings } = await import("../global-settings");
|
||||||
|
await saveEmailSettings({
|
||||||
|
host: "smtp.example.com",
|
||||||
|
port: 587,
|
||||||
|
username: "user",
|
||||||
|
password: "pw",
|
||||||
|
encryption: "starttls",
|
||||||
|
fromAddress: "from@example.com",
|
||||||
|
fromName: "",
|
||||||
|
replyTo: "",
|
||||||
|
});
|
||||||
|
|
||||||
|
await saveSuperAdminSettings({ email: "lazis002@gmail.com", firstName: "Stefan", lastName: "Lazic" });
|
||||||
|
|
||||||
|
const { readEmailSettingsView } = await import("../global-settings");
|
||||||
|
const email = await readEmailSettingsView();
|
||||||
|
expect(email.host).toBe("smtp.example.com");
|
||||||
|
expect(email.hasPassword).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isSuperAdminConfigComplete requires a non-blank email; name is optional", () => {
|
||||||
|
expect(isSuperAdminConfigComplete(null)).toBe(false);
|
||||||
|
expect(isSuperAdminConfigComplete({ email: "", firstName: "Stefan", lastName: "Lazic" })).toBe(false);
|
||||||
|
expect(isSuperAdminConfigComplete({ email: "lazis002@gmail.com", firstName: "", lastName: "" })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -64,6 +64,12 @@ export interface BusinessEnvPushValues {
|
|||||||
subject: string;
|
subject: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BusinessEnvSuperAdminValues {
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface BusinessEnvValues {
|
export interface BusinessEnvValues {
|
||||||
name: string;
|
name: string;
|
||||||
domain: string;
|
domain: string;
|
||||||
@@ -84,6 +90,8 @@ export interface BusinessEnvValues {
|
|||||||
email?: BusinessEnvEmailValues;
|
email?: BusinessEnvEmailValues;
|
||||||
/** Global platform VAPID/Web Push settings (see src/lib/global-settings.ts) — same pattern as `email` above. Omitted falls back to today's blank/"console" defaults. */
|
/** Global platform VAPID/Web Push settings (see src/lib/global-settings.ts) — same pattern as `email` above. Omitted falls back to today's blank/"console" defaults. */
|
||||||
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. */
|
||||||
|
superAdmin?: BusinessEnvSuperAdminValues;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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. */
|
||||||
@@ -120,6 +128,15 @@ VAPID_PUBLIC_KEY=""
|
|||||||
VAPID_PRIVATE_KEY=""
|
VAPID_PRIVATE_KEY=""
|
||||||
VAPID_SUBJECT="mailto:owner@example.com"`;
|
VAPID_SUBJECT="mailto:owner@example.com"`;
|
||||||
|
|
||||||
|
const superAdmin = values.superAdmin;
|
||||||
|
const superAdminBlock = superAdmin
|
||||||
|
? `SUPER_ADMIN_EMAIL="${superAdmin.email}"
|
||||||
|
SUPER_ADMIN_FIRST_NAME="${superAdmin.firstName}"
|
||||||
|
SUPER_ADMIN_LAST_NAME="${superAdmin.lastName}"`
|
||||||
|
: `SUPER_ADMIN_EMAIL=""
|
||||||
|
SUPER_ADMIN_FIRST_NAME=""
|
||||||
|
SUPER_ADMIN_LAST_NAME=""`;
|
||||||
|
|
||||||
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
|
||||||
@@ -146,10 +163,11 @@ NEXT_PUBLIC_APP_URL="https://${values.domain}"
|
|||||||
INSTALLATION_TOKEN="${values.installationToken}"
|
INSTALLATION_TOKEN="${values.installationToken}"
|
||||||
CRON_SECRET="${values.cronSecret}"
|
CRON_SECRET="${values.cronSecret}"
|
||||||
|
|
||||||
SUPER_ADMIN_EMAIL=""
|
# Managed centrally — see the Manager's Settings > Super Administrator
|
||||||
SUPER_ADMIN_PASSWORD=""
|
# page. Changing it there does NOT retroactively update existing
|
||||||
SUPER_ADMIN_FIRST_NAME=""
|
# businesses (unlike email/push above) — see set-super-admin.sh for
|
||||||
SUPER_ADMIN_LAST_NAME=""
|
# applying a change across every business at once.
|
||||||
|
${superAdminBlock}
|
||||||
|
|
||||||
AUTH_GOOGLE_ID=""
|
AUTH_GOOGLE_ID=""
|
||||||
AUTH_GOOGLE_SECRET=""
|
AUTH_GOOGLE_SECRET=""
|
||||||
|
|||||||
@@ -33,8 +33,8 @@ export function readEnvValue(content: string, key: string): string | null {
|
|||||||
* file (preserving the surrounding file untouched), otherwise appends a
|
* file (preserving the surrounding file untouched), otherwise appends a
|
||||||
* new line for it. Used by Edit Business: never regenerates a business's
|
* new line for it. Used by Edit Business: never regenerates a business's
|
||||||
* whole .env from a template, which would blow away anything hand-edited
|
* whole .env from a template, which would blow away anything hand-edited
|
||||||
* into it since provisioning (Google/email/push credentials, a rotated
|
* into it since provisioning (Google/email/push credentials, a hand-added
|
||||||
* SUPER_ADMIN_PASSWORD, etc.) — see provisioning.ts's updateBusiness.
|
* GOOGLE_CALENDAR_CLIENT_SECRET, etc.) — see provisioning.ts's updateBusiness.
|
||||||
*/
|
*/
|
||||||
export async function upsertEnvValues(filePath: string, updates: Record<string, string>): Promise<void> {
|
export async function upsertEnvValues(filePath: string, updates: Record<string, string>): Promise<void> {
|
||||||
const current = (await readEnvFile(filePath)) ?? "";
|
const current = (await readEnvFile(filePath)) ?? "";
|
||||||
|
|||||||
@@ -48,9 +48,17 @@ interface StoredPushSettings {
|
|||||||
generatedAt: string | null;
|
generatedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Not a secret — no password (sign-in is OTP-only, see scripts/ensure-super-admin.ts) — so this is stored and returned in plain, unlike email/push above. */
|
||||||
|
export interface SuperAdminSettings {
|
||||||
|
email: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface StoredGlobalSettings {
|
interface StoredGlobalSettings {
|
||||||
email: StoredEmailSettings | null;
|
email: StoredEmailSettings | null;
|
||||||
push: StoredPushSettings | null;
|
push: StoredPushSettings | null;
|
||||||
|
superAdmin: SuperAdminSettings | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LastTestResult {
|
export interface LastTestResult {
|
||||||
@@ -119,10 +127,10 @@ 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 };
|
return { email: parsed.email ?? null, push: parsed.push ?? null, superAdmin: parsed.superAdmin ?? 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 };
|
return { email: null, push: null, superAdmin: null };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,3 +372,32 @@ export async function savePushSubject(subject: string): Promise<ResolvedPushSett
|
|||||||
if (!next.encryptedPrivateKey) return null;
|
if (!next.encryptedPrivateKey) return null;
|
||||||
return { publicKey: next.publicKey, privateKey: decrypt(next.encryptedPrivateKey), subject: next.subject };
|
return { publicKey: next.publicKey, privateKey: decrypt(next.encryptedPrivateKey), subject: next.subject };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Super Administrator ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baked into every business's .env at creation, the same as email/push
|
||||||
|
* above — see provisioning.ts's provisionBusiness. Unlike those, nothing
|
||||||
|
* here is a secret (no password, sign-in is OTP-only), so there's no
|
||||||
|
* View/Resolved split and no encryption: this is read and returned as
|
||||||
|
* plain data throughout.
|
||||||
|
*/
|
||||||
|
export async function readSuperAdminSettings(): Promise<SuperAdminSettings | null> {
|
||||||
|
const { superAdmin } = await readStoredSettings();
|
||||||
|
return superAdmin;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSuperAdminConfigComplete(settings: SuperAdminSettings | null): settings is SuperAdminSettings {
|
||||||
|
return Boolean(settings && settings.email.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveSuperAdminSettings(input: SuperAdminSettings): Promise<SuperAdminSettings> {
|
||||||
|
const current = await readStoredSettings();
|
||||||
|
const next: SuperAdminSettings = {
|
||||||
|
email: input.email.trim().toLowerCase(),
|
||||||
|
firstName: input.firstName.trim(),
|
||||||
|
lastName: input.lastName.trim(),
|
||||||
|
};
|
||||||
|
await writeStoredSettings({ ...current, superAdmin: next });
|
||||||
|
return next;
|
||||||
|
}
|
||||||
@@ -4,7 +4,14 @@ import mariadb, { type Connection } from "mariadb";
|
|||||||
import { generateBusinessEnv, generateCaddySite, generateComposeYaml } from "./compose-template";
|
import { generateBusinessEnv, generateCaddySite, generateComposeYaml } from "./compose-template";
|
||||||
import { composeDown, composeUp, reloadCaddy, runMigrations } from "./docker";
|
import { composeDown, composeUp, reloadCaddy, runMigrations } from "./docker";
|
||||||
import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file";
|
import { readEnvFile, readEnvValue, upsertEnvValues } from "./env-file";
|
||||||
import { isEmailConfigComplete, isPushConfigComplete, resolveEmailSettings, resolvePushSettings } from "./global-settings";
|
import {
|
||||||
|
isEmailConfigComplete,
|
||||||
|
isPushConfigComplete,
|
||||||
|
isSuperAdminConfigComplete,
|
||||||
|
readSuperAdminSettings,
|
||||||
|
resolveEmailSettings,
|
||||||
|
resolvePushSettings,
|
||||||
|
} from "./global-settings";
|
||||||
import { randomHex } from "./random";
|
import { randomHex } from "./random";
|
||||||
import { isValidSlug, listBusinessStacks, stackDir, stackExists, stacksRoot } from "./stacks";
|
import { isValidSlug, listBusinessStacks, stackDir, stackExists, stacksRoot } from "./stacks";
|
||||||
|
|
||||||
@@ -168,6 +175,16 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise<
|
|||||||
? { publicKey: globalPushSettings.publicKey, privateKey: globalPushSettings.privateKey, subject: globalPushSettings.subject }
|
? { publicKey: globalPushSettings.publicKey, privateKey: globalPushSettings.privateKey, subject: globalPushSettings.subject }
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
// Same pattern again — a global default Super Administrator (Manager's
|
||||||
|
// Settings > Super Administrator page), baked in at creation only when
|
||||||
|
// an email is actually set. No secret involved (sign-in is OTP-only),
|
||||||
|
// so unlike email/push there's nothing to fall back to "incomplete"
|
||||||
|
// for beyond a blank email.
|
||||||
|
const globalSuperAdminSettings = await readSuperAdminSettings();
|
||||||
|
const superAdmin = isSuperAdminConfigComplete(globalSuperAdminSettings)
|
||||||
|
? { email: globalSuperAdminSettings.email, firstName: globalSuperAdminSettings.firstName, lastName: globalSuperAdminSettings.lastName }
|
||||||
|
: 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");
|
||||||
@@ -184,6 +201,7 @@ export async function provisionBusiness(input: ProvisionBusinessInput): Promise<
|
|||||||
cronSecret,
|
cronSecret,
|
||||||
email,
|
email,
|
||||||
push,
|
push,
|
||||||
|
superAdmin,
|
||||||
}),
|
}),
|
||||||
"utf8",
|
"utf8",
|
||||||
);
|
);
|
||||||
@@ -212,8 +230,8 @@ export interface UpdateBusinessResult {
|
|||||||
* Edits an existing business's name/domain. Deliberately a surgical .env
|
* Edits an existing business's name/domain. Deliberately a surgical .env
|
||||||
* update (see upsertEnvValues), never a full regenerate from the create
|
* update (see upsertEnvValues), never a full regenerate from the create
|
||||||
* template — a regenerate would blow away anything hand-edited into that
|
* template — a regenerate would blow away anything hand-edited into that
|
||||||
* file since provisioning (Google/email/push credentials, a rotated
|
* file since provisioning (Google/email/push credentials, a hand-added
|
||||||
* SUPER_ADMIN_PASSWORD, etc.), which the caller has no business touching
|
* GOOGLE_CALENDAR_CLIENT_SECRET, etc.), which the caller has no business touching
|
||||||
* just to change a name or domain.
|
* just to change a name or domain.
|
||||||
*
|
*
|
||||||
* The slug itself is immutable here on purpose: it's baked into the
|
* The slug itself is immutable here on purpose: it's baked into the
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
import { composeUp } from "./docker";
|
||||||
|
import { upsertEnvValues } from "./env-file";
|
||||||
|
import type { SuperAdminSettings } from "./global-settings";
|
||||||
|
import { listBusinessStacks, stackDir } from "./stacks";
|
||||||
|
|
||||||
|
export interface SuperAdminRolloutResult {
|
||||||
|
slug: string;
|
||||||
|
ok: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pushes the global default Super Administrator into every business's
|
||||||
|
* own `.env` and force-recreates its container so
|
||||||
|
* scripts/ensure-super-admin.ts (run on every container start) picks it
|
||||||
|
* up — same mechanism as email-rollout.ts/push-rollout.ts. Unlike those,
|
||||||
|
* always overwrites all three keys unconditionally: there's no
|
||||||
|
* per-business "own value to preserve" concept here the way EMAIL_REPLY_TO
|
||||||
|
* has (a business doesn't have its own Super Administrator), and no
|
||||||
|
* secret involved (sign-in is OTP-only) worth being more careful with.
|
||||||
|
*
|
||||||
|
* This is the UI equivalent of running
|
||||||
|
* stacks/infra/scripts/set-super-admin.sh by hand — kept as a genuinely
|
||||||
|
* separate implementation (not a shared core) since one runs inside the
|
||||||
|
* Manager process against these same helpers and the other is a
|
||||||
|
* standalone shell script for someone without Manager access to a
|
||||||
|
* running instance; letting them drift independently is fine since both
|
||||||
|
* only ever do this one small thing.
|
||||||
|
*
|
||||||
|
* Runs sequentially, not in parallel — see email-rollout.ts's own
|
||||||
|
* comment for why.
|
||||||
|
*/
|
||||||
|
export async function applySuperAdminToAllBusinesses(settings: SuperAdminSettings): Promise<SuperAdminRolloutResult[]> {
|
||||||
|
const businesses = await listBusinessStacks();
|
||||||
|
const results: SuperAdminRolloutResult[] = [];
|
||||||
|
|
||||||
|
for (const business of businesses) {
|
||||||
|
try {
|
||||||
|
const dir = stackDir(business.slug);
|
||||||
|
const envPath = path.join(dir, ".env");
|
||||||
|
|
||||||
|
await upsertEnvValues(envPath, {
|
||||||
|
SUPER_ADMIN_EMAIL: settings.email,
|
||||||
|
SUPER_ADMIN_FIRST_NAME: settings.firstName,
|
||||||
|
SUPER_ADMIN_LAST_NAME: settings.lastName,
|
||||||
|
});
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -2,9 +2,18 @@
|
|||||||
* Idempotent Super Administrator provisioning, run on every container
|
* Idempotent Super Administrator provisioning, run on every container
|
||||||
* start (see docker/entrypoint.sh) — NOT part of the installation wizard,
|
* start (see docker/entrypoint.sh) — NOT part of the installation wizard,
|
||||||
* per the brief's explicit requirement that this account come from
|
* per the brief's explicit requirement that this account come from
|
||||||
* environment variables. Safe to run repeatedly: re-hashes and syncs the
|
* environment variables. Safe to run repeatedly: re-syncs the name from
|
||||||
* password/name from the env vars every run, so rotating
|
* the env vars every run.
|
||||||
* SUPER_ADMIN_PASSWORD and restarting the container is how you change it.
|
*
|
||||||
|
* No password — sign-in across this entire app is OTP-only (see
|
||||||
|
* src/lib/auth/authorize-credentials.ts), and a Super Administrator signs
|
||||||
|
* in exactly the same way as anyone else, via a one-time code emailed to
|
||||||
|
* SUPER_ADMIN_EMAIL. `User.passwordHash` is never read anywhere in the
|
||||||
|
* actual sign-in path — this script used to require and set one anyway
|
||||||
|
* (a leftover from before the OTP migration), which only meant an unused
|
||||||
|
* value with its own, pointless minimum-length requirement. Removed
|
||||||
|
* rather than kept "just in case": a field nothing checks is worse than
|
||||||
|
* no field, since it invites someone to assume it's load-bearing.
|
||||||
*
|
*
|
||||||
* Deliberately does NOT create a StaffProfile — that's what keeps the
|
* Deliberately does NOT create a StaffProfile — that's what keeps the
|
||||||
* Super Administrator out of the Team list, the customer-facing barber
|
* Super Administrator out of the Team list, the customer-facing barber
|
||||||
@@ -18,13 +27,10 @@
|
|||||||
* outside Next's own module resolution/bundling.
|
* outside Next's own module resolution/bundling.
|
||||||
*/
|
*/
|
||||||
import "dotenv/config";
|
import "dotenv/config";
|
||||||
import bcrypt from "bcryptjs";
|
|
||||||
import { PrismaClient } from "../generated/prisma/client";
|
import { PrismaClient } from "../generated/prisma/client";
|
||||||
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
import { PrismaMariaDb } from "@prisma/adapter-mariadb";
|
||||||
import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config";
|
import { buildMysqlPoolConfig } from "../src/lib/db/mysql-pool-config";
|
||||||
|
|
||||||
const BCRYPT_COST = 12; // matches src/features/installation/installer.service.ts
|
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const email = process.env.SUPER_ADMIN_EMAIL?.trim().toLowerCase();
|
const email = process.env.SUPER_ADMIN_EMAIL?.trim().toLowerCase();
|
||||||
if (!email) {
|
if (!email) {
|
||||||
@@ -32,15 +38,6 @@ async function main() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const password = process.env.SUPER_ADMIN_PASSWORD;
|
|
||||||
if (!password || password.length < 8) {
|
|
||||||
console.error(
|
|
||||||
"[ensure-super-admin] SUPER_ADMIN_EMAIL is set but SUPER_ADMIN_PASSWORD is missing or shorter than 8 characters. Refusing to provision a Super Administrator with no usable password.",
|
|
||||||
);
|
|
||||||
process.exitCode = 1;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstName = process.env.SUPER_ADMIN_FIRST_NAME?.trim() ?? "";
|
const firstName = process.env.SUPER_ADMIN_FIRST_NAME?.trim() ?? "";
|
||||||
const lastName = process.env.SUPER_ADMIN_LAST_NAME?.trim() ?? "";
|
const lastName = process.env.SUPER_ADMIN_LAST_NAME?.trim() ?? "";
|
||||||
const name = [firstName, lastName].filter(Boolean).join(" ") || null;
|
const name = [firstName, lastName].filter(Boolean).join(" ") || null;
|
||||||
@@ -49,7 +46,6 @@ async function main() {
|
|||||||
const prisma = new PrismaClient({ adapter });
|
const prisma = new PrismaClient({ adapter });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const passwordHash = await bcrypt.hash(password, BCRYPT_COST);
|
|
||||||
const existing = await prisma.user.findUnique({ where: { email } });
|
const existing = await prisma.user.findUnique({ where: { email } });
|
||||||
|
|
||||||
if (existing && !existing.isSuperAdmin) {
|
if (existing && !existing.isSuperAdmin) {
|
||||||
@@ -63,12 +59,12 @@ async function main() {
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: { name, passwordHash, emailVerified: existing.emailVerified ?? new Date() },
|
data: { name, emailVerified: existing.emailVerified ?? new Date() },
|
||||||
});
|
});
|
||||||
console.log(`[ensure-super-admin] Synced credentials for existing Super Administrator (${email}).`);
|
console.log(`[ensure-super-admin] Synced name for existing Super Administrator (${email}).`);
|
||||||
} else {
|
} else {
|
||||||
await prisma.user.create({
|
await prisma.user.create({
|
||||||
data: { email, name, passwordHash, isSuperAdmin: true, emailVerified: new Date() },
|
data: { email, name, isSuperAdmin: true, emailVerified: new Date() },
|
||||||
});
|
});
|
||||||
console.log(`[ensure-super-admin] Created Super Administrator (${email}).`);
|
console.log(`[ensure-super-admin] Created Super Administrator (${email}).`);
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+70
@@ -0,0 +1,70 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Calls one of the app's CRON_SECRET-gated endpoints (see
|
||||||
|
# src/app/api/cron/process-notifications/route.ts and
|
||||||
|
# src/app/api/cron/sync-google-reviews/route.ts) for every business
|
||||||
|
# currently provisioned under STACKS_DIR — so a single crontab entry on
|
||||||
|
# the host covers every business automatically, including ones added
|
||||||
|
# later, without a manual crontab line per business.
|
||||||
|
#
|
||||||
|
# Without something calling process-notifications on a schedule, queued
|
||||||
|
# notifications (staff invitation emails, appointment reminders, loyalty
|
||||||
|
# reward emails, etc.) are created in the database as PENDING and never
|
||||||
|
# actually sent — see that route's own header for why this is a required
|
||||||
|
# piece of infrastructure, not optional. This script is the reference
|
||||||
|
# implementation for wiring that up; see the root README's "Notification
|
||||||
|
# delivery" section for the crontab lines that call it.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# process-cron.sh notifications # recommended every 1-2 minutes
|
||||||
|
# process-cron.sh reviews # recommended once every 24 hours
|
||||||
|
#
|
||||||
|
# STACKS_DIR defaults to /opt/nextchair/stacks — override via env var if
|
||||||
|
# this repo is cloned somewhere else (must match every other STACKS_DIR
|
||||||
|
# use across this deployment — see .env.infra.example's own note on why).
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ $# -lt 1 ]; then
|
||||||
|
echo "Usage: $0 notifications|reviews" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
ENDPOINT="$1"
|
||||||
|
STACKS_DIR="${STACKS_DIR:-/opt/nextchair/stacks}"
|
||||||
|
|
||||||
|
case "$ENDPOINT" in
|
||||||
|
notifications) PATH_SUFFIX="process-notifications" ;;
|
||||||
|
reviews) PATH_SUFFIX="sync-google-reviews" ;;
|
||||||
|
*)
|
||||||
|
echo "Unknown endpoint '$ENDPOINT' — expected 'notifications' or 'reviews'." >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
if [ ! -d "$STACKS_DIR" ]; then
|
||||||
|
echo "STACKS_DIR '$STACKS_DIR' does not exist." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for env_file in "$STACKS_DIR"/*/.env; do
|
||||||
|
[ -f "$env_file" ] || continue
|
||||||
|
|
||||||
|
domain=$(grep -E '^DOMAIN=' "$env_file" | head -n1 | cut -d= -f2- | tr -d '"')
|
||||||
|
cron_secret=$(grep -E '^CRON_SECRET=' "$env_file" | head -n1 | cut -d= -f2- | tr -d '"')
|
||||||
|
slug=$(basename "$(dirname "$env_file")")
|
||||||
|
|
||||||
|
if [ -z "$domain" ] || [ -z "$cron_secret" ]; then
|
||||||
|
echo "[$slug] skipped — no DOMAIN/CRON_SECRET in .env (not fully provisioned yet)." >&2
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
status=$(curl -sS -o /tmp/process-cron-response.$$ -w '%{http_code}' -X POST \
|
||||||
|
"https://${domain}/api/cron/${PATH_SUFFIX}" \
|
||||||
|
-H "Authorization: Bearer ${cron_secret}" || echo "curl_failed")
|
||||||
|
|
||||||
|
if [ "$status" = "200" ]; then
|
||||||
|
echo "[$slug] $PATH_SUFFIX ok"
|
||||||
|
else
|
||||||
|
echo "[$slug] $PATH_SUFFIX FAILED (status=$status): $(cat /tmp/process-cron-response.$$ 2>/dev/null)" >&2
|
||||||
|
fi
|
||||||
|
rm -f /tmp/process-cron-response.$$
|
||||||
|
done
|
||||||
Executable
+63
@@ -0,0 +1,63 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Sets SUPER_ADMIN_EMAIL/FIRST_NAME/LAST_NAME in every business's .env
|
||||||
|
# under STACKS_DIR and recreates each container so
|
||||||
|
# scripts/ensure-super-admin.ts (run on every container start via
|
||||||
|
# docker/entrypoint.sh) picks it up immediately. No password to set —
|
||||||
|
# sign-in is OTP-only; see ensure-super-admin.ts's own header for why.
|
||||||
|
#
|
||||||
|
# Only touches these three keys in each .env — every other secret/setting
|
||||||
|
# already in the file (Google/email/push credentials, etc.) is left
|
||||||
|
# byte-for-byte alone. Safe to re-run any time you want to change who the
|
||||||
|
# Super Administrator is, or after adding a new business.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# set-super-admin.sh <email> <first-name> <last-name>
|
||||||
|
#
|
||||||
|
# STACKS_DIR defaults to /opt/nextchair/stacks — override via env var if
|
||||||
|
# this repo is cloned somewhere else.
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
if [ $# -lt 3 ]; then
|
||||||
|
echo "Usage: $0 <email> <first-name> <last-name>" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
EMAIL="$1"
|
||||||
|
FIRST_NAME="$2"
|
||||||
|
LAST_NAME="$3"
|
||||||
|
STACKS_DIR="${STACKS_DIR:-/opt/nextchair/stacks}"
|
||||||
|
|
||||||
|
if [ ! -d "$STACKS_DIR" ]; then
|
||||||
|
echo "STACKS_DIR '$STACKS_DIR' does not exist." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
for env_file in "$STACKS_DIR"/*/.env; do
|
||||||
|
[ -f "$env_file" ] || continue
|
||||||
|
dir=$(dirname "$env_file")
|
||||||
|
slug=$(basename "$dir")
|
||||||
|
|
||||||
|
tmp="${env_file}.tmp.$$"
|
||||||
|
awk -v email="$EMAIL" -v first="$FIRST_NAME" -v last="$LAST_NAME" '
|
||||||
|
BEGIN { seen_email=0; seen_first=0; seen_last=0 }
|
||||||
|
/^SUPER_ADMIN_EMAIL=/ { print "SUPER_ADMIN_EMAIL=\"" email "\""; seen_email=1; next }
|
||||||
|
/^SUPER_ADMIN_FIRST_NAME=/ { print "SUPER_ADMIN_FIRST_NAME=\"" first "\""; seen_first=1; next }
|
||||||
|
/^SUPER_ADMIN_LAST_NAME=/ { print "SUPER_ADMIN_LAST_NAME=\"" last "\""; seen_last=1; next }
|
||||||
|
/^SUPER_ADMIN_PASSWORD=/ { next }
|
||||||
|
{ print }
|
||||||
|
END {
|
||||||
|
if (!seen_email) print "SUPER_ADMIN_EMAIL=\"" email "\""
|
||||||
|
if (!seen_first) print "SUPER_ADMIN_FIRST_NAME=\"" first "\""
|
||||||
|
if (!seen_last) print "SUPER_ADMIN_LAST_NAME=\"" last "\""
|
||||||
|
}
|
||||||
|
' "$env_file" > "$tmp" && mv "$tmp" "$env_file"
|
||||||
|
|
||||||
|
echo "[$slug] .env updated — recreating container..."
|
||||||
|
if (cd "$dir" && docker compose up -d --force-recreate) > /tmp/set-super-admin-$$.log 2>&1; then
|
||||||
|
echo "[$slug] ok"
|
||||||
|
else
|
||||||
|
echo "[$slug] FAILED to recreate container:" >&2
|
||||||
|
cat /tmp/set-super-admin-$$.log >&2
|
||||||
|
fi
|
||||||
|
rm -f /tmp/set-super-admin-$$.log
|
||||||
|
done
|
||||||
Reference in new issue
Block a user