diff --git a/apps/status-page/src/components/nav/header.tsx b/apps/status-page/src/components/nav/header.tsx index 381463ee3..1d24d8a6b 100644 --- a/apps/status-page/src/components/nav/header.tsx +++ b/apps/status-page/src/components/nav/header.tsx @@ -1,6 +1,5 @@ "use client"; -import type { RouterOutputs } from "@openstatus/api"; import { Menu, Chat } from "@openstatus/icons"; import { StatusPageGetInTouchIcon } from "@openstatus/ui/components/blocks/status-page-get-in-touch"; import { @@ -33,12 +32,7 @@ import { toast } from "sonner"; import { usePathnamePrefix } from "../../hooks/use-pathname-prefix"; import { useTRPC } from "../../lib/trpc/client"; import { Link } from "../common/link"; -import { - type StatusUpdateType, - StatusUpdates, -} from "../status-page/status-updates"; - -type Page = RouterOutputs["statusPage"]["get"]; +import { StatusUpdates } from "../status-page/status-updates"; function useNav() { const t = useExtracted(); @@ -67,21 +61,6 @@ function useNav() { ]; } -function getStatusUpdateTypes(page: Page): StatusUpdateType[] { - if (!page) return []; - - // NOTE: rss or json are not supported because of authentication - if (page?.accessType === "email-domain") { - return ["email"] as const; - } - - if (page?.workspacePlan === "free") { - return ["slack", "rss", "json"] as const; - } - - return ["email", "slack", "rss", "json"] as const; -} - export function Header({ className, ...props @@ -158,7 +137,6 @@ export function Header({ ) : null} { await subscribeMutation.mutateAsync({ slug: domain, ...values }); }} diff --git a/apps/status-page/src/components/status-page/status-updates.tsx b/apps/status-page/src/components/status-page/status-updates.tsx index f2e98a2ca..6e772ecf8 100644 --- a/apps/status-page/src/components/status-page/status-updates.tsx +++ b/apps/status-page/src/components/status-page/status-updates.tsx @@ -9,7 +9,6 @@ import { StatusUpdatesRss, StatusUpdatesSection, StatusUpdatesSlack, - StatusUpdatesSsh, StatusUpdatesTrigger, } from "@openstatus/ui/components/blocks/status-updates"; import { Button } from "@openstatus/ui/components/ui/button"; @@ -33,8 +32,6 @@ import { type FormValues, } from "../forms/form-subscribe-email"; -export type StatusUpdateType = "email" | "rss" | "ssh" | "json" | "slack"; - type Page = NonNullable; function getUpdateLink( @@ -55,14 +52,12 @@ function getUpdateLink( } interface StatusUpdatesProps extends React.ComponentProps { - types?: StatusUpdateType[]; page?: Page | null; onSubscribe?: (values: FormValues) => Promise | void; } export function StatusUpdates({ className, - types = ["rss", "ssh", "json", "slack"], page, onSubscribe, ...props @@ -75,12 +70,12 @@ export function StatusUpdates({ // page payload, which intentionally omits it. const [password] = useCookieState(createProtectedCookieKey(domain)); + const types = page?.statusUpdateTypes ?? []; if (types.length === 0) return null; const rssUrl = getUpdateLink("rss", page, password); const atomUrl = getUpdateLink("atom", page, password); const jsonUrl = getUpdateLink("json", page, password); - const sshCommand = `ssh ${page?.slug}@ssh.openstatus.dev`; return ( @@ -100,9 +95,6 @@ export function StatusUpdates({ {types.includes("json") ? ( {t("JSON")} ) : null} - {types.includes("ssh") ? ( - {t("SSH")} - ) : null} {success ? ( @@ -139,9 +131,6 @@ export function StatusUpdates({ - - - diff --git a/packages/api/src/router/statusPage.access.test.ts b/packages/api/src/router/statusPage.access.test.ts index 4376987fc..86b0c923e 100644 --- a/packages/api/src/router/statusPage.access.test.ts +++ b/packages/api/src/router/statusPage.access.test.ts @@ -147,6 +147,30 @@ describe("statusPage over HTTP", () => { }); describe("statusPage access gate", () => { + test("public page exposes subscription options from effective limits", async () => { + const enabled = await createTestWorkspace({ + plan: "free", + limits: JSON.stringify({ "status-subscribers": true }), + }); + const enabledPage = await createPage(enabled.workspace.id); + const enabledResult = await caller().statusPage.get({ + slug: enabledPage.slug, + }); + expect(enabledResult?.statusUpdateTypes).toEqual([ + "email", + "slack", + "rss", + "json", + ]); + + const disabled = await createTestWorkspace({ plan: "free" }); + const disabledPage = await createPage(disabled.workspace.id); + const disabledResult = await caller().statusPage.get({ + slug: disabledPage.slug, + }); + expect(disabledResult?.statusUpdateTypes).toEqual(["slack", "rss", "json"]); + }); + for (const accessType of Object.keys(authorized) as (keyof typeof slugs)[]) { test(`${accessType}: authorized caller gets the full page`, async () => { const c = authorized[accessType](); diff --git a/packages/api/src/router/statusPage.ts b/packages/api/src/router/statusPage.ts index 70fde1fd5..dba65b14c 100644 --- a/packages/api/src/router/statusPage.ts +++ b/packages/api/src/router/statusPage.ts @@ -16,6 +16,7 @@ import { selectWorkspaceSchema, statusReport, } from "@openstatus/db/src/schema"; +import { getStatusUpdateTypes } from "@openstatus/services/page"; import { constantTimeEqual } from "@openstatus/services/page-access"; import { getSubscriberByToken, @@ -522,6 +523,11 @@ export const statusPageRouter = createTRPCRouter({ statusReports, maintenances, workspacePlan: _page.workspace.plan, + statusUpdateTypes: getStatusUpdateTypes({ + accessType: _page.accessType ?? "public", + emailSubscriptionsEnabled: + ws.data?.limits["status-subscribers"] ?? false, + }), status, lastEvents, openEvents, diff --git a/packages/db/src/schema/shared.ts b/packages/db/src/schema/shared.ts index 394ec71b7..8cb9240b5 100644 --- a/packages/db/src/schema/shared.ts +++ b/packages/db/src/schema/shared.ts @@ -233,6 +233,7 @@ export const selectPublicPageSchemaWithRelation = selectPageSchema .nullable() .prefault("free") .transform((val) => val ?? "free"), + statusUpdateTypes: z.array(z.enum(["email", "slack", "rss", "json"])), whiteLabel: z.boolean().prefault(false), }) .omit({ password: true }); diff --git a/packages/services/src/page/__tests__/status-update-types.test.ts b/packages/services/src/page/__tests__/status-update-types.test.ts new file mode 100644 index 000000000..ad75658c7 --- /dev/null +++ b/packages/services/src/page/__tests__/status-update-types.test.ts @@ -0,0 +1,40 @@ +import { expect } from "@std/expect"; +import { test } from "@std/testing/bdd"; + +import { getStatusUpdateTypes } from "../status-update-types"; + +test("public pages include Email when subscriptions are enabled", () => { + expect( + getStatusUpdateTypes({ + accessType: "public", + emailSubscriptionsEnabled: true, + }), + ).toEqual(["email", "slack", "rss", "json"]); +}); + +test("public pages omit Email when subscriptions are disabled", () => { + expect( + getStatusUpdateTypes({ + accessType: "public", + emailSubscriptionsEnabled: false, + }), + ).toEqual(["slack", "rss", "json"]); +}); + +test("email-domain pages only include Email when subscriptions are enabled", () => { + expect( + getStatusUpdateTypes({ + accessType: "email-domain", + emailSubscriptionsEnabled: true, + }), + ).toEqual(["email"]); +}); + +test("email-domain pages have no subscription options when disabled", () => { + expect( + getStatusUpdateTypes({ + accessType: "email-domain", + emailSubscriptionsEnabled: false, + }), + ).toEqual([]); +}); diff --git a/packages/services/src/page/index.ts b/packages/services/src/page/index.ts index 8bc4fabf3..0d65d39d9 100644 --- a/packages/services/src/page/index.ts +++ b/packages/services/src/page/index.ts @@ -1,4 +1,8 @@ export { createPage, newPage } from "./create"; +export { + getStatusUpdateTypes, + type StatusUpdateType, +} from "./status-update-types"; export { deletePage } from "./delete"; export { type MaintenanceContent, diff --git a/packages/services/src/page/status-update-types.ts b/packages/services/src/page/status-update-types.ts new file mode 100644 index 000000000..c99971654 --- /dev/null +++ b/packages/services/src/page/status-update-types.ts @@ -0,0 +1,19 @@ +import type { pageAccessTypes } from "@openstatus/db/src/schema/pages/constants"; + +type PageAccessType = (typeof pageAccessTypes)[number]; + +export type StatusUpdateType = "email" | "slack" | "rss" | "json"; + +export function getStatusUpdateTypes(args: { + accessType: PageAccessType; + emailSubscriptionsEnabled: boolean; +}): StatusUpdateType[] { + // Feeds cannot authenticate visitors to email-domain pages. + if (args.accessType === "email-domain") { + return args.emailSubscriptionsEnabled ? ["email"] : []; + } + + return args.emailSubscriptionsEnabled + ? ["email", "slack", "rss", "json"] + : ["slack", "rss", "json"]; +}