diff --git a/apps/admin/app/onboarding/setup/page.tsx b/apps/admin/app/onboarding/setup/page.tsx index 78a40a75e..24d1bf0c6 100644 --- a/apps/admin/app/onboarding/setup/page.tsx +++ b/apps/admin/app/onboarding/setup/page.tsx @@ -11,6 +11,7 @@ function SetupWizardRoute() { isNew={params.get("new") === "1"} checkoutReturn={params.get("checkout") === "return"} resume={params.get("resume") === "1"} + choosePlan={params.get("plan") === "1"} /> ); } diff --git a/apps/admin/app/settings/page.tsx b/apps/admin/app/settings/page.tsx index 9d4f0325c..e51c5148d 100644 --- a/apps/admin/app/settings/page.tsx +++ b/apps/admin/app/settings/page.tsx @@ -15,6 +15,8 @@ import { Palette, RotateCcw, Save, + HardDrive, + ScrollText, Sparkles, Users, } from "lucide-react"; @@ -25,6 +27,8 @@ import { useEffect, useState } from "react"; import { AuthGate } from "@/components/auth-gate"; import { AiAssistantSettings } from "@/components/settings/ai-assistant-settings"; import { ApiKeySettings } from "@/components/settings/api-key-settings"; +import { AuditLogSettings } from "@/components/settings/audit-log-settings"; +import { StorageSettings } from "@/components/settings/storage-settings"; import { BrandingSettings } from "@/components/settings/branding-settings"; import { EmailDeliveryNotice } from "@/components/mails/email-delivery-notice"; import { LoginProviderSettings } from "@/components/settings/login-provider-settings"; @@ -60,7 +64,7 @@ import { import { Textarea } from "@/components/ui/codelit/textarea"; import currencies from "@/data/currencies.json"; -const GENERAL_SETTINGS_TABS = ["payment", "team", "notifications", "login-providers", "api-keys", "ai-assistants"] as const; +const GENERAL_SETTINGS_TABS = ["payment", "team", "notifications", "login-providers", "api-keys", "ai-assistants", "storage", "audit-log"] as const; const MAIL_SETTINGS_TABS = ["delivery"] as const; const WEBSITE_SETTINGS_TABS = ["branding", "code-injection", "domain"] as const; type SettingsMode = "general" | "website" | "mails"; @@ -111,6 +115,7 @@ type SchoolItem = { currency: string; isOwner?: boolean; selected?: boolean; + permissions?: string[]; }; @@ -192,6 +197,7 @@ export function SettingsPage({ mode = "general" }: { mode?: SettingsMode }) { const [schoolId, setSchoolId] = useState(null); const [isOwner, setIsOwner] = useState(false); + const [canManageSchool, setCanManageSchool] = useState(false); const providerSettings = isPaymentProvider(paymentMethod) ? paymentSettings?.[paymentMethod] @@ -208,6 +214,7 @@ export function SettingsPage({ mode = "general" }: { mode?: SettingsMode }) { if (!selected) return; setSchoolId(selected.id); setIsOwner(selected.isOwner === true); + setCanManageSchool(selected.permissions?.includes("school:write") === true); setCurrency(selected.currency || "USD"); void fetch("/api/v1/school/payment-settings", { credentials: "include", @@ -444,6 +451,21 @@ export function SettingsPage({ mode = "general" }: { mode?: SettingsMode }) { ), icon: , }, + // Storage and the audit log are for people who manage the school's settings. + ...(canManageSchool + ? [ + { + value: "storage", + label: "Storage", + icon: , + }, + { + value: "audit-log", + label: "Audit log", + icon: , + }, + ] + : []), ] } > @@ -765,6 +787,22 @@ export function SettingsPage({ mode = "general" }: { mode?: SettingsMode }) { ) : null} + + {mode === "general" && selectedTab === "storage" && canManageSchool ? ( +
+ +
+ ) : null} +
+ + + {mode === "general" && selectedTab === "audit-log" && canManageSchool ? ( +
+ +
+ ) : null} +
+ {mode === "general" && selectedTab === "notifications" ? (
diff --git a/apps/admin/components/communities/community-admin.tsx b/apps/admin/components/communities/community-admin.tsx index 8d5be0411..f6c6c1b81 100644 --- a/apps/admin/components/communities/community-admin.tsx +++ b/apps/admin/components/communities/community-admin.tsx @@ -49,6 +49,7 @@ import { StatusBadge, type StorefrontPlan, } from "./community-shared"; +import { inviteErrorMessage } from "@/lib/invite-errors"; export type CommunityManageSection = | "details" @@ -66,7 +67,7 @@ function communityApiErrorMessage(error: ApiError | null, fallback: string) { if (error?.details?.reason === "community_offline") { return "Make the community live before inviting people."; } - return error?.message ?? fallback; + return inviteErrorMessage(error, fallback); } /** "3", or "10+" when there are more pending items than one page. */ diff --git a/apps/admin/components/layout/admin-breadcrumb.tsx b/apps/admin/components/layout/admin-breadcrumb.tsx index e9541943d..23e036034 100644 --- a/apps/admin/components/layout/admin-breadcrumb.tsx +++ b/apps/admin/components/layout/admin-breadcrumb.tsx @@ -192,7 +192,9 @@ export function getFallbackBreadcrumbs(pathname: string): BreadcrumbItemType[] { clean === "/settings/team" || clean === "/settings/notifications" || clean === "/settings/api-keys" || - clean === "/settings/ai-assistants" + clean === "/settings/ai-assistants" || + clean === "/settings/audit-log" || + clean === "/settings/storage" ) { const tabLabels: Record = { payment: "Payments", @@ -200,6 +202,8 @@ export function getFallbackBreadcrumbs(pathname: string): BreadcrumbItemType[] { notifications: "Notifications", "api-keys": "API keys", "ai-assistants": "AI assistants", + "audit-log": "Audit log", + storage: "Storage", }; const tab = clean.split("/")[2]; return [{ label: "Settings", href: "/settings" }, { label: tabLabels[tab] }]; diff --git a/apps/admin/components/layout/admin-shell.tsx b/apps/admin/components/layout/admin-shell.tsx index 3b541c5f5..c12a8fda7 100644 --- a/apps/admin/components/layout/admin-shell.tsx +++ b/apps/admin/components/layout/admin-shell.tsx @@ -9,6 +9,7 @@ import { BreadcrumbProvider } from "@/components/layout/breadcrumb-context"; import type { CurrentAccount } from "@/components/layout/nav-user"; import type { School } from "@/components/layout/team-switcher"; import { NotificationsBell } from "@/components/notifications/notifications-bell"; +import { SchoolStateBanner } from "@/components/layout/school-state-banner"; import { PermissionDeniedFeedback } from "@/components/permission-denied-feedback"; import { CourseLitLoadingIcon } from "@/components/loading"; import { Separator } from "@/components/ui/separator"; @@ -320,6 +321,9 @@ export function AdminShell({ children }: { children: React.ReactNode }) { aria-hidden={showLoadingOverlay || undefined} className={`h-full overflow-auto p-6 ${showLoadingOverlay ? "invisible" : ""}`} > + school.selected)} + /> {children}
) : null} diff --git a/apps/admin/components/layout/school-state-banner.test.ts b/apps/admin/components/layout/school-state-banner.test.ts new file mode 100644 index 000000000..0d52c7e0e --- /dev/null +++ b/apps/admin/components/layout/school-state-banner.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "bun:test"; +import { schoolStateNotice } from "./school-state-banner"; + +describe("schoolStateNotice", () => { + it("says nothing about a running school", () => { + expect( + schoolStateNotice({ name: "A", status: "active", isOwner: true }), + ).toBeNull(); + expect(schoolStateNotice({ name: "A", status: undefined })).toBeNull(); + }); + + it("sends the owner of a read-only school to pick a plan", () => { + const notice = schoolStateNotice({ name: "A", status: "read_only", isOwner: true }); + expect(notice?.text).toContain("read-only"); + expect(notice?.actionLabel).toBe("Choose a plan"); + }); + + it("tells the rest of the team to ask the owner", () => { + const notice = schoolStateNotice({ + name: "A", + status: "read_only", + isOwner: false, + }); + expect(notice?.text).toContain("its owner"); + expect(notice?.actionLabel).not.toBe("Choose a plan"); + }); + + it("opens the plan step for a school waiting on checkout", () => { + for (const status of ["pending_checkout", "imported"]) { + const notice = schoolStateNotice({ name: "A", status, isOwner: true }); + expect(notice?.actionHref).toBe("/onboarding/setup?plan=1"); + } + }); + + it("points a suspended school at support", () => { + expect( + schoolStateNotice({ name: "A", status: "suspended", isOwner: true })?.text, + ).toContain("support@courselit.app"); + }); +}); diff --git a/apps/admin/components/layout/school-state-banner.tsx b/apps/admin/components/layout/school-state-banner.tsx new file mode 100644 index 000000000..dfa36fc91 --- /dev/null +++ b/apps/admin/components/layout/school-state-banner.tsx @@ -0,0 +1,81 @@ +"use client"; + +import type { School } from "@/components/layout/team-switcher"; +import { + type LimitUsage, + limitNearlyUsed, + usageShare, + useSchoolUsage, +} from "@/components/settings/usage-meters"; +import { StatusBanner } from "@/components/status-banner"; + +const PLAN_HREF = "/account/billing"; + +/** + * What the admin says about a school that isn't fully running, and where it + * sends the reader. Only the owner can pick a plan, so the rest of the team is + * told to ask them. Null for a school that needs no notice. + */ +export function schoolStateNotice( + school: Pick, +): { text: string; actionLabel: string; actionHref: string } | null { + const owner = school.isOwner === true; + switch (school.status) { + case "read_only": + return { + text: owner + ? `${school.name} is read-only. Learners keep their access, but nothing can change until you pick a plan.` + : `${school.name} is read-only until its owner picks a plan.`, + actionLabel: owner ? "Choose a plan" : "See the plan", + actionHref: PLAN_HREF, + }; + case "pending_checkout": + case "imported": + return { + text: owner + ? `${school.name} isn't live yet. Pick a plan to open it to learners.` + : `${school.name} isn't live until its owner picks a plan.`, + actionLabel: owner ? "Choose a plan" : "See the plan", + actionHref: owner ? "/onboarding/setup?plan=1" : PLAN_HREF, + }; + case "suspended": + return { + text: `${school.name} is suspended. Write to support@courselit.app to find out why.`, + actionLabel: "See the plan", + actionHref: PLAN_HREF, + }; + default: + return null; + } +} + +/** "M4 Academy has used 85% of its storage." for the owner's banner. */ +export function usageWarning(schoolName: string, usage: LimitUsage): string { + const percent = Math.round((usageShare(usage) ?? 0) * 100); + return usage.key === "storage_bytes" + ? `${schoolName} has used ${percent}% of its storage.` + : `${schoolName} has sent ${percent}% of this month's marketing email.`; +} + +export function SchoolStateBanner({ school }: { school: School | undefined }) { + const notice = school ? schoolStateNotice(school) : null; + // Usage is billing information, so only the owner sees this warning (S9). + const usage = useSchoolUsage( + school?.id ?? null, + Boolean(school?.isOwner) && !notice && school?.status === "active", + ); + const nearlyUsed = usage ? limitNearlyUsed(usage) : null; + if (!notice && nearlyUsed && school) { + return ( + + {usageWarning(school.name, nearlyUsed)} + + ); + } + if (!notice) return null; + return ( + + {notice.text} + + ); +} diff --git a/apps/admin/components/onboarding/wizard/api.ts b/apps/admin/components/onboarding/wizard/api.ts index 921f99620..318ddd283 100644 --- a/apps/admin/components/onboarding/wizard/api.ts +++ b/apps/admin/components/onboarding/wizard/api.ts @@ -19,6 +19,7 @@ import { uploadedToMediaRef, type WizardData, } from "./flow"; +import type { CourseLitPlanLimits } from "@courselit/api-contract/courselit-plans"; export class ApiError extends Error { status: number; @@ -80,6 +81,8 @@ export type BillingCatalog = { amountMinor: number; trialDays: number; }>; + /** What each plan allows here; `null` values are unlimited. */ + limits?: Record | null; }; /** OSS = no catalog and no checkout. Everything else is the hosted service. */ @@ -95,7 +98,12 @@ export type SchoolOnboarding = { checklistHidden: boolean; resumePromptHidden: boolean; checklist: Array<{ key: string; done: boolean }>; - billing: { required: boolean; entitled: boolean }; + billing: { + required: boolean; + entitled: boolean; + paid: boolean; + trialEndsAt: string | null; + }; updatedAt: string; }; diff --git a/apps/admin/components/onboarding/wizard/flow.test.ts b/apps/admin/components/onboarding/wizard/flow.test.ts index 3036951a3..51f7e4e39 100644 --- a/apps/admin/components/onboarding/wizard/flow.test.ts +++ b/apps/admin/components/onboarding/wizard/flow.test.ts @@ -7,6 +7,7 @@ import { canSubmitSubdomain, deriveSiteDomain, detectLocaleDefaults, + ERROR_COPY, emptyWizardData, formatMailingAddress, hasNewConnection, @@ -17,6 +18,7 @@ import { parseDraft, previousStep, resolveResumeStep, + resumeRows, sanitizeSubdomainInput, serializeDraft, slugify, @@ -163,6 +165,13 @@ describe("validation", () => { validateSchool({ school: "A", subdomain: "abc" }, "taken").subdomain, ).toBeDefined(); expect(validateSchool({ school: "A", subdomain: "abc" }, "ok")).toEqual({}); + expect( + validateSchool({ school: "paypal.com support", subdomain: "abc" }, "ok").school, + ).toBe(ERROR_COPY.schoolLink); + expect(validateSchool({ school: "x".repeat(81), subdomain: "abc" }, "ok").school).toBe( + ERROR_COPY.schoolLong, + ); + expect(validateSchool({ school: "St. Mary's Guitar School", subdomain: "abc" }, "ok")).toEqual({}); }); it("validates each offer form", () => { @@ -348,3 +357,19 @@ describe("site domain", () => { ).toBe("courselit.app"); }); }); + +describe("resume summary", () => { + const env = { firstRun: false, hosted: true }; + const plan = (trialEndsAt: string | null) => + resumeRows("offer", env, emptyWizardData(), "Owner", trialEndsAt).find( + (row) => row.key === "plan", + )?.desc; + + it("asks for a plan when the school has none", () => { + expect(plan(null)).toBe("Choose a CourseLit plan to go live"); + }); + + it("names the end of a running trial instead", () => { + expect(plan("2026-11-28T12:00:00.000Z")).toStartWith("Free trial until"); + }); +}); diff --git a/apps/admin/components/onboarding/wizard/flow.ts b/apps/admin/components/onboarding/wizard/flow.ts index 0795c12fd..4e51819cb 100644 --- a/apps/admin/components/onboarding/wizard/flow.ts +++ b/apps/admin/components/onboarding/wizard/flow.ts @@ -4,6 +4,7 @@ * unit tested in isolation. */ import type { MediaRef } from "@courselit/api-contract"; +import { schoolNameSchema } from "@courselit/api-contract/school-name"; export type WizardStep = | "about" @@ -412,6 +413,9 @@ export type FieldErrors = Partial< export const ERROR_COPY = { name: "Add your name so learners know who's teaching.", school: "Give your school a name.", + schoolLink: + "School names can't include a web or email address. It appears in every email your learners get.", + schoolLong: "Keep your school's name to 80 characters or fewer.", courseTitle: "Give your course a title.", communityName: "Give your community a name.", question: "Add the question applicants answer.", @@ -435,6 +439,14 @@ export function validateSchool( ): FieldErrors { const errors: FieldErrors = {}; if (blank(data.school)) errors.school = ERROR_COPY.school; + else { + // The same rule the API applies (S14): the name goes into email subjects. + const checked = schoolNameSchema.safeParse(data.school); + if (!checked.success) { + errors.school = + data.school.trim().length > 80 ? ERROR_COPY.schoolLong : ERROR_COPY.schoolLink; + } + } if (!isValidSubdomain(data.subdomain)) errors.subdomain = ERROR_COPY.subdomain; else if (availability === "taken") errors.subdomain = ERROR_COPY.subdomainTaken; return errors; @@ -906,6 +918,7 @@ export function resumeRows( env: Pick, data: WizardData, accountName: string, + trialEndsAt: string | null = null, ): Array<{ key: string; name: string; desc: string; done: boolean; next: boolean }> { const groups = stepperGroups(env).filter((group) => group.key !== "live"); const current = stepperIndex(stepperGroups(env), step); @@ -916,7 +929,9 @@ export function resumeRows( offer: offerTitle(data) || "Not chosen yet", site: "Logo, colour and tagline", email: "Mailing address", - plan: "Choose a CourseLit plan to go live", + plan: trialEndsAt + ? `Free trial until ${new Date(trialEndsAt).toLocaleDateString(undefined, { month: "long", day: "numeric" })}` + : "Choose a CourseLit plan to go live", live: "", }; return groups.map((group, index) => ({ diff --git a/apps/admin/components/onboarding/wizard/steps.tsx b/apps/admin/components/onboarding/wizard/steps.tsx index 2d74b5a9a..1d98ec471 100644 --- a/apps/admin/components/onboarding/wizard/steps.tsx +++ b/apps/admin/components/onboarding/wizard/steps.tsx @@ -71,6 +71,7 @@ import { Tile, useCopy, } from "./ui"; +import { courseLitPlanHighlights } from "@courselit/api-contract/courselit-plans"; const COVER_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"]; const LOGO_TYPES = ["image/png", "image/jpeg", "image/svg+xml", "image/webp"]; @@ -1272,6 +1273,14 @@ export function PlanStep({ ) : null} ) : null} +
    + {courseLitPlanHighlights(plan, catalog?.limits?.[plan]).map((line) => ( +
  • + + {line} +
  • + ))} +
); })} diff --git a/apps/admin/components/onboarding/wizard/wizard.tsx b/apps/admin/components/onboarding/wizard/wizard.tsx index 2df8c1c70..4951a0294 100644 --- a/apps/admin/components/onboarding/wizard/wizard.tsx +++ b/apps/admin/components/onboarding/wizard/wizard.tsx @@ -22,13 +22,13 @@ import { adoptProduct, applyBranding, applyEmail, + assertAcceptedFile, type BillingCatalog, checkSubdomain, createSchool, fetchBillingCatalog, getOnboarding, isHostedCatalog, - assertAcceptedFile, newUploadingFile, patchOnboarding, publishOffer, @@ -101,6 +101,8 @@ export type WizardProps = { checkoutReturn: boolean; /** `?resume=1`: offer the Resume dialog. */ resume: boolean; + /** `?plan=1`: open on the plan step, e.g. to buy a plan during a trial. */ + choosePlan?: boolean; }; type SchoolInfo = { @@ -131,7 +133,12 @@ function realAccountName( return name && name.toLowerCase() !== prefix ? name : ""; } -export function SetupWizard({ isNew, checkoutReturn, resume }: WizardProps) { +export function SetupWizard({ + isNew, + checkoutReturn, + resume, + choosePlan, +}: WizardProps) { const router = useRouter(); const shell = useAdminShellContext(); const account = shell?.account ?? null; @@ -158,6 +165,7 @@ export function SetupWizard({ isNew, checkoutReturn, resume }: WizardProps) { const [bootError, setBootError] = useState(null); const [catalog, setCatalog] = useState(null); const [hosted, setHosted] = useState(true); + const [trialEndsAt, setTrialEndsAt] = useState(null); const [firstRun, setFirstRun] = useState(false); const [step, setStep] = useState("about"); const [data, setData] = useState(() => emptyWizardData(localeDefaults)); @@ -329,13 +337,16 @@ export function SetupWizard({ isNew, checkoutReturn, resume }: WizardProps) { setData(restored); dataRef.current = restored; setFirstRun(false); - const entitled = onboarding.billing.entitled || !onboarding.billing.required; + // Back from checkout: only a paid plan confirms the payment. A running + // trial is entitled too, but it says nothing about this checkout. + const paid = onboarding.billing.paid || !onboarding.billing.required; + setTrialEndsAt(onboarding.billing.paid ? null : onboarding.billing.trialEndsAt); const target = resolveResumeStep(onboarding.step, { hosted: isHosted, hasOffer: restored.offer !== null, }); if (checkoutReturn) { - if (entitled) { + if (paid) { setStep("plan"); setPaidConfirmed(true); setGoingLive(true); @@ -345,6 +356,9 @@ export function SetupWizard({ isNew, checkoutReturn, resume }: WizardProps) { } setCancelled(true); setStep("plan"); + } else if (choosePlan) { + setPlanMode("form"); + setStep("plan"); } else { setStep(target); setResumeStep(target); @@ -1409,35 +1423,36 @@ export function SetupWizard({ isNew, checkoutReturn, resume }: WizardProps) { aria-label="Setup" className="flex min-h-0 flex-col border-r border-border" > -
- {failed ? ( -
- -
- We couldn't save this step.{" "} - Check your connection and try again. Nothing you entered is lost. -
- -
- ) : null} - {form} + +
+ We couldn't save this step.{" "} + Check your connection and try again. Nothing you entered is lost. +
+ +
+ ) : null} + {form} + {showFooter ? (