Openstatus www.openstatus.dev

feat: password protection (#799)

* feat: password protection

* fix: small stuff

* feat: include public filter on monitors

* fix: schema validation

* fix: undefined value

* feat: add shareability

* feat: add changelog

* chore: add password protection to plan

* 🧪 fix test

* 🧪 fix test

* 🧪 fix test

* feat: disable feature for free plan

* fix: changelog

---------

Co-authored-by: Thibault Le Ouay <thibaultleouay@gmail.Com>

authored by

Maximilian Kaske
Thibault Le Ouay
and committed by
GitHub
0a659cbb 780f3fb8

+2304 -35
+72
apps/server/src/v1/page.ts
··· 66 66 .or(z.literal("")) 67 67 .transform((val) => (val ? val : undefined)) 68 68 .nullish(), 69 + 70 + passwordProtected: z 71 + .boolean() 72 + .openapi({ 73 + description: 74 + "Make the page password protected. Used with the 'passwordProtected' property.", 75 + example: true, 76 + }) 77 + .default(false) 78 + .optional(), 79 + 80 + password: z 81 + .string() 82 + .openapi({ 83 + description: "Your password to protect the page from the publi", 84 + example: "hidden-password", 85 + }) 86 + .optional() 87 + .nullish(), 88 + 69 89 monitors: z 70 90 .array(z.number()) 71 91 .openapi({ ··· 111 131 example: [1, 2], 112 132 }) 113 133 .nullish(), 134 + 135 + passwordProtected: z 136 + .boolean() 137 + .openapi({ 138 + description: "Make the page password protected", 139 + example: true, 140 + }) 141 + .default(false) 142 + .optional(), 143 + 144 + password: z 145 + .string() 146 + .openapi({ 147 + description: "Your password to protect the page from the publi", 148 + example: "hidden-password", 149 + }) 150 + .optional() 151 + .nullish(), 114 152 }); 115 153 116 154 const UpdatePageSchema = z.object({ ··· 154 192 description: "The monitors of the page", 155 193 example: [1, 2], 156 194 }) 195 + .nullish(), 196 + 197 + passwordProtected: z 198 + .boolean() 199 + .openapi({ 200 + description: "Make the page password protected", 201 + example: true, 202 + }) 203 + .default(false) 204 + .optional(), 205 + 206 + password: z 207 + .string() 208 + .openapi({ 209 + description: "Your password to protect the page from the publi", 210 + example: "hidden-password", 211 + }) 212 + .optional() 157 213 .nullish(), 158 214 }); 159 215 ··· 400 456 401 457 const input = c.req.valid("json"); 402 458 459 + if ( 460 + workspacePlan.limits["password-protection"] === false && 461 + input?.passwordProtected === true 462 + ) { 463 + return c.json({ code: 403, message: "Forbidden" }, 403); 464 + } 465 + 403 466 const countSlug = ( 404 467 await db 405 468 .select({ count: sql<number>`count(*)` }) ··· 487 550 const { id } = c.req.valid("param"); 488 551 489 552 if (!id) return c.json({ code: 400, message: "Bad Request" }, 400); 553 + 554 + const workspacePlan = c.get("workspacePlan"); 555 + 556 + if ( 557 + workspacePlan.limits["password-protection"] === false && 558 + input?.passwordProtected === true 559 + ) { 560 + return c.json({ code: 403, message: "Forbidden" }, 403); 561 + } 490 562 491 563 const _page = await db 492 564 .select()
apps/web/public/assets/changelog/password-protected-status-page.png

This is a binary file and will not be displayed.

+6 -4
apps/web/src/app/api/og/page/route.tsx
··· 18 18 const { searchParams } = new URL(req.url); 19 19 20 20 const slug = searchParams.has("slug") ? searchParams.get("slug") : undefined; 21 + const passwordProtected = searchParams.has("passwordProtected") 22 + ? searchParams.get("passwordProtected") === "true" // FIXME: can we use Boolean("true") or Boolean("false")? 23 + : undefined; 21 24 22 25 const page = await api.page.getPageBySlug.query({ slug: slug || "" }); 23 26 const title = page ? page.title : TITLE; 24 27 const description = page ? "" : DESCRIPTION; 25 28 29 + // REMINDER: if password protected, we keep the status 'operational' by default, hiding the actual status 26 30 const tracker = new Tracker({ 27 - incidents: page?.incidents, 28 - statusReports: page?.statusReports, 31 + incidents: passwordProtected ? undefined : page?.incidents, 32 + statusReports: passwordProtected ? undefined : page?.statusReports, 29 33 }); 30 - 31 - // const status = tracker.currentStatus; 32 34 33 35 return new ImageResponse( 34 36 (
+14 -12
apps/web/src/app/app/[workspaceSlug]/(dashboard)/monitors/(overview)/page.tsx
··· 13 13 import { env } from "@/env"; 14 14 import { api } from "@/trpc/server"; 15 15 16 - // import { RefreshWidget } from "../_components/refresh-widget"; 17 - 18 16 const tb = new OSTinybird({ token: env.TINY_BIRD_API_KEY }); 19 17 20 18 export const dynamic = "force-dynamic"; ··· 26 24 tags: z 27 25 .string() 28 26 .transform((v) => v?.split(",")) 27 + .optional(), 28 + public: z 29 + .string() 30 + .transform((v) => 31 + v?.split(",").map((v) => { 32 + if (v === "true") return true; 33 + if (v === "false") return false; 34 + return undefined; 35 + }), 36 + ) 29 37 .optional(), 30 38 }); 31 39 ··· 91 99 }), 92 100 ); 93 101 94 - // const lastCronTimestamp = monitorsWithData?.reduce((prev, acc) => { 95 - // const lastTimestamp = acc.metrics?.lastTimestamp || 0; 96 - // if (lastTimestamp > prev) return lastTimestamp; 97 - // return prev; 98 - // }, 0); 99 - 100 102 return ( 101 103 <> 102 104 <DataTable 103 - defaultColumnFilters={[{ id: "tags", value: search.data.tags }].filter( 104 - (v) => v.value !== undefined, 105 - )} 105 + defaultColumnFilters={[ 106 + { id: "tags", value: search.data.tags }, 107 + { id: "public", value: search.data.public }, 108 + ].filter((v) => v.value !== undefined)} 106 109 columns={columns} 107 110 data={monitorsWithData} 108 111 tags={tags} 109 112 /> 110 113 {isLimitReached ? <Limit /> : null} 111 - {/* <RefreshWidget defaultValue={lastCronTimestamp} /> */} 112 114 </> 113 115 ); 114 116 }
-2
apps/web/src/app/app/[workspaceSlug]/(dashboard)/settings/team/_components/invite-button.tsx
··· 27 27 28 28 import { LoadingAnimation } from "@/components/loading-animation"; 29 29 import { toastAction } from "@/lib/toast"; 30 - import { wait } from "@/lib/utils"; 31 30 import { api } from "@/trpc/client"; 32 31 33 32 const schema = insertInvitationSchema.pick({ email: true }); ··· 51 50 async function onSubmit(data: Schema) { 52 51 startTransition(async () => { 53 52 try { 54 - wait(2000); 55 53 api.invitation.create.mutate(data); 56 54 toastAction("saved"); 57 55 router.refresh();
+3
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/[id]/edit/page.tsx
··· 17 17 }) { 18 18 const id = Number(params.id); 19 19 const page = await api.page.getPageById.query({ id }); 20 + const workspace = await api.workspace.getWorkspace.query(); 20 21 const allMonitors = await api.monitor.getMonitorsByWorkspace.query(); 21 22 22 23 if (!page) { ··· 34 35 monitors: page.monitorsToPages.map(({ monitor }) => monitor.id), 35 36 }} 36 37 defaultSection={search.success ? search.data.section : undefined} 38 + plan={workspace?.plan} 39 + workspaceSlug={params.workspaceSlug} 37 40 /> 38 41 ); 39 42 }
+9 -1
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/[id]/layout.tsx
··· 31 31 description={page.description} 32 32 actions={ 33 33 <Button variant="outline" asChild> 34 - <Link target="_blank" href={`https://${page.slug}.openstatus.dev`}> 34 + <Link 35 + target="_blank" 36 + href={ 37 + process.env.NODE_ENV === "development" 38 + ? `http://localhost:3000/status-page/${page.slug}` 39 + : // TODO: add custom domain support 40 + `https://${page.slug}.openstatus.dev` 41 + } 42 + > 35 43 Visit 36 44 </Link> 37 45 </Button>
+6 -1
apps/web/src/app/app/[workspaceSlug]/(dashboard)/status-pages/new/page.tsx
··· 3 3 import { StatusPageForm } from "@/components/forms/status-page/form"; 4 4 import { api } from "@/trpc/server"; 5 5 6 - export default async function Page() { 6 + export default async function Page({ 7 + params, 8 + }: { 9 + params: { workspaceSlug: string }; 10 + }) { 7 11 const allMonitors = await api.monitor.getMonitorsByWorkspace.query(); 8 12 const isLimitReached = await api.page.isPageLimitReached.query(); 9 13 ··· 14 18 allMonitors={allMonitors} // FIXME: rename to just 'monitors' 15 19 nextUrl="./" // back to the overview page 16 20 defaultSection="monitors" 21 + workspaceSlug={params.workspaceSlug} 17 22 /> 18 23 ); 19 24 }
+43 -2
apps/web/src/app/status-page/[domain]/_components/actions.ts
··· 8 8 import { page, pageSubscriber } from "@openstatus/db/src/schema"; 9 9 import { sendEmail, SubscribeEmail } from "@openstatus/emails"; 10 10 11 - const schema = z.object({ 11 + const subscribeSchema = z.object({ 12 12 email: z 13 13 .string({ 14 14 invalid_type_error: "Invalid Email", ··· 18 18 }); 19 19 20 20 export async function handleSubscribe(formData: FormData) { 21 - const validatedFields = schema.safeParse({ 21 + const validatedFields = subscribeSchema.safeParse({ 22 22 email: formData.get("email"), 23 23 slug: formData.get("slug"), 24 24 }); ··· 92 92 slug: pageData.slug, 93 93 }); 94 94 } 95 + 96 + const passwordSchema = z.object({ 97 + password: z.string(), 98 + slug: z.string(), 99 + }); 100 + 101 + export async function handleValidatePassword(formData: FormData) { 102 + const validatedFields = passwordSchema.safeParse({ 103 + password: formData.get("password"), 104 + slug: formData.get("slug"), 105 + }); 106 + 107 + console.log({ validatedFields }); 108 + 109 + if (!validatedFields.success) { 110 + const fieldErrors = validatedFields.error.flatten().fieldErrors; 111 + return { 112 + error: fieldErrors?.password?.[0] || "Invalid form data", 113 + }; 114 + } 115 + 116 + const _page = await db 117 + .select() 118 + .from(page) 119 + .where(eq(page.slug, validatedFields.data.slug)) 120 + .get(); 121 + 122 + if (!_page) { 123 + return { 124 + error: "Page not found", 125 + }; 126 + } 127 + 128 + if (_page.password !== validatedFields.data.password) { 129 + return { 130 + error: "Invalid password", 131 + }; 132 + } 133 + 134 + return { data: _page.password }; 135 + }
+117
apps/web/src/app/status-page/[domain]/_components/password-form.tsx
··· 1 + "use client"; 2 + 3 + import { useEffect, useState, useTransition } from "react"; 4 + import { usePathname, useRouter, useSearchParams } from "next/navigation"; 5 + import { zodResolver } from "@hookform/resolvers/zod"; 6 + import { useForm } from "react-hook-form"; 7 + import { z } from "zod"; 8 + 9 + import { 10 + Button, 11 + Form, 12 + FormControl, 13 + FormDescription, 14 + FormField, 15 + FormItem, 16 + FormLabel, 17 + FormMessage, 18 + Input, 19 + } from "@openstatus/ui"; 20 + 21 + import { LoadingAnimation } from "@/components/loading-animation"; 22 + import { useCookieState } from "@/hooks/use-cookie-state"; 23 + import { toast, toastAction } from "@/lib/toast"; 24 + import { createProtectedCookieKey } from "../utils"; 25 + import { handleValidatePassword } from "./actions"; 26 + 27 + // TODO: add 'hide/show' button to show the password 28 + // FIXME: we could do the `?authorize` thing in the server side (e.g. middleware) - but not 29 + // in the `layout.tsx` because we cannot access the search params there 30 + 31 + const schema = z.object({ 32 + password: z.string(), 33 + }); 34 + 35 + type Schema = z.infer<typeof schema>; 36 + 37 + export function PasswordForm({ slug }: { slug: string }) { 38 + const form = useForm<Schema>({ 39 + resolver: zodResolver(schema), 40 + defaultValues: { password: "" }, 41 + }); 42 + const [loading, setLoading] = useState(true); 43 + const router = useRouter(); 44 + const pathname = usePathname(); 45 + const searchParams = useSearchParams(); 46 + const [isPending, startTransition] = useTransition(); 47 + const [_, handleChange] = useCookieState(createProtectedCookieKey(slug)); // what if we do not define the expires date? 48 + 49 + useEffect(() => { 50 + if (searchParams.has("authorize")) { 51 + const authorize = searchParams.get("authorize"); 52 + if (!authorize) return; 53 + form.setValue("password", authorize); 54 + } 55 + setLoading(false); 56 + // eslint-disable-next-line react-hooks/exhaustive-deps 57 + }, []); 58 + 59 + async function onSubmit(data: Schema) { 60 + startTransition(async () => { 61 + try { 62 + const formData = new FormData(); 63 + formData.append("password", data.password); 64 + formData.append("slug", slug); 65 + 66 + const res = await handleValidatePassword(formData); 67 + 68 + if (res?.error || res.data === undefined) { 69 + toast.error(res.error || "An error occurred. Please retry."); 70 + return; 71 + } 72 + 73 + handleChange(res.data); 74 + toastAction("saved"); 75 + 76 + router.replace(pathname); 77 + router.refresh(); 78 + } catch { 79 + toastAction("error"); 80 + } 81 + }); 82 + } 83 + 84 + return ( 85 + <Form {...form}> 86 + <form 87 + onSubmit={form.handleSubmit(onSubmit)} 88 + className="grid w-full gap-4" 89 + > 90 + <FormField 91 + control={form.control} 92 + name="password" 93 + render={({ field }) => ( 94 + <FormItem> 95 + <FormLabel>Password</FormLabel> 96 + <FormControl> 97 + <Input 98 + placeholder="top-secret" 99 + type="password" 100 + disabled={loading} 101 + {...field} 102 + /> 103 + </FormControl> 104 + <FormDescription> 105 + Enter the password to access the status page. 106 + </FormDescription> 107 + <FormMessage /> 108 + </FormItem> 109 + )} 110 + /> 111 + <Button size="lg" disabled={isPending || loading}> 112 + {isPending || loading ? <LoadingAnimation /> : "Confirm"} 113 + </Button> 114 + </form> 115 + </Form> 116 + ); 117 + }
+25
apps/web/src/app/status-page/[domain]/_components/password-protected.tsx
··· 1 + import type { WorkspacePlan } from "@openstatus/plans"; 2 + 3 + import { Shell } from "@/components/dashboard/shell"; 4 + import { Footer } from "../_components/footer"; 5 + import { PasswordForm } from "../_components/password-form"; 6 + 7 + export default function PasswordProtected({ 8 + slug, 9 + plan, 10 + }: { 11 + slug: string; 12 + plan: WorkspacePlan; 13 + }) { 14 + return ( 15 + <div className="mx-auto flex min-h-screen w-full max-w-3xl flex-col space-y-6 p-4 md:p-8"> 16 + <main className="flex h-full w-full flex-1 flex-col justify-center"> 17 + <Shell className="mx-auto grid gap-6"> 18 + <h1 className="text-2xl font-semibold">Protected Page</h1> 19 + <PasswordForm slug={slug} /> 20 + </Shell> 21 + </main> 22 + <Footer plan={plan} /> 23 + </div> 24 + ); 25 + }
+21 -3
apps/web/src/app/status-page/[domain]/layout.tsx
··· 1 1 import type { Metadata } from "next"; 2 + import { cookies } from "next/headers"; 2 3 import { notFound } from "next/navigation"; 3 4 4 5 import { ··· 10 11 import { api } from "@/trpc/server"; 11 12 import { Footer } from "./_components/footer"; 12 13 import { Header } from "./_components/header"; 13 - import { setPrefixUrl } from "./utils"; 14 + import PasswordProtected from "./_components/password-protected"; 15 + import { createProtectedCookieKey, setPrefixUrl } from "./utils"; 14 16 15 17 type Props = { 16 18 params: { domain: string }; ··· 19 21 20 22 export default async function StatusPageLayout({ children, params }: Props) { 21 23 const page = await api.page.getPageBySlug.query({ slug: params.domain }); 24 + 22 25 if (!page) return notFound(); 23 26 24 27 const plan = page.workspacePlan; ··· 43 46 }, 44 47 ]; 45 48 49 + // TODO: move to middleware using NextResponse.rewrite keeping the path without using redirect 50 + // and move the PasswordProtected into a page.tsx 51 + if (page.passwordProtected) { 52 + const cookie = cookies(); 53 + const protectedCookie = cookie.get(createProtectedCookieKey(params.domain)); 54 + const password = protectedCookie ? protectedCookie.value : undefined; 55 + if (password !== page.password) { 56 + return <PasswordProtected plan={plan} slug={params.domain} />; 57 + } 58 + } 59 + 46 60 return ( 47 61 <div className="mx-auto flex min-h-screen w-full max-w-3xl flex-col space-y-6 p-4 md:p-8"> 48 62 <Header navigation={navigation} plan={plan} page={page} /> ··· 64 78 icons: page?.icon, 65 79 twitter: { 66 80 ...twitterMetadata, 67 - images: [`/api/og/page?slug=${page?.slug}`], 81 + images: [ 82 + `/api/og/page?slug=${page?.slug}&passwordProtected=${page?.passwordProtected}`, 83 + ], 68 84 title: page?.title, 69 85 description: page?.description, 70 86 }, 71 87 openGraph: { 72 88 ...ogMetadata, 73 - images: [`/api/og/page?slug=${page?.slug}`], 89 + images: [ 90 + `/api/og/page?slug=${page?.slug}&passwordProtected=${page?.passwordProtected}`, 91 + ], 74 92 title: page?.title, 75 93 description: page?.description, 76 94 },
+4
apps/web/src/app/status-page/[domain]/utils.tsx
··· 8 8 } 9 9 return suffix; 10 10 } 11 + 12 + export function createProtectedCookieKey(value: string) { 13 + return `secured-${value}`; 14 + }
+60
apps/web/src/components/billing/pro-feature-hover-card.tsx
··· 1 + "use client"; 2 + 3 + import { useState } from "react"; 4 + 5 + import { workspacePlanHierarchy } from "@openstatus/db/src/schema"; 6 + import type { WorkspacePlan } from "@openstatus/plans"; 7 + import { HoverCard, HoverCardContent, HoverCardTrigger } from "@openstatus/ui"; 8 + 9 + function upgradePlan(current: WorkspacePlan, required: WorkspacePlan) { 10 + return workspacePlanHierarchy[current] < workspacePlanHierarchy[required]; 11 + } 12 + 13 + // TBD: we could useParams() to access workspaceSlug 14 + 15 + export function ProFeatureHoverCard({ 16 + children, 17 + plan, 18 + minRequiredPlan, 19 + workspaceSlug, 20 + }: { 21 + children: React.ReactNode; 22 + plan?: WorkspacePlan; 23 + minRequiredPlan: WorkspacePlan; 24 + workspaceSlug: string; 25 + }) { 26 + console.log({ workspaceSlug, plan, minRequiredPlan }); 27 + const [open, setOpen] = useState(false); 28 + const shouldUpgrade = upgradePlan(plan || "free", minRequiredPlan); 29 + 30 + if (!shouldUpgrade) return children; 31 + 32 + return ( 33 + <HoverCard openDelay={0} open={open} onOpenChange={setOpen}> 34 + <HoverCardTrigger 35 + onClick={() => setOpen(true)} 36 + className="opacity-70" 37 + asChild 38 + > 39 + {children} 40 + </HoverCardTrigger> 41 + <HoverCardContent side="top" className="grid gap-2"> 42 + <p className="text-muted-foreground text-sm"> 43 + This feature is only available starting from the{" "} 44 + <span className="font-semibold capitalize">{minRequiredPlan}</span>{" "} 45 + plan. 46 + </p> 47 + <p className="text-sm"> 48 + <a 49 + href={`/app/${workspaceSlug}/settings/billing`} 50 + target="_blank" 51 + className="text-foreground inline-flex items-center font-medium underline underline-offset-4 hover:no-underline" 52 + > 53 + Upgrade now 54 + </a> 55 + . 56 + </p> 57 + </HoverCardContent> 58 + </HoverCard> 59 + ); 60 + }
+5 -2
apps/web/src/components/dashboard/copy-to-clipboard-button.tsx
··· 11 11 } from "@openstatus/ui"; 12 12 13 13 import { Icons } from "@/components/icons"; 14 - import { copyToClipboard } from "@/lib/utils"; 14 + import { cn, copyToClipboard } from "@/lib/utils"; 15 15 16 16 export function CopyToClipboardButton({ 17 17 text, 18 18 tooltipText, 19 + className, 19 20 }: { 20 21 text: string; 21 22 tooltipText: string; 23 + className?: string; 22 24 }) { 23 25 const [hasCopied, setHasCopied] = React.useState(false); 24 26 ··· 35 37 <Tooltip> 36 38 <TooltipTrigger asChild> 37 39 <Button 40 + type="button" 38 41 variant="ghost" 39 42 size="icon" 40 - className="h-7 w-7" 43 + className={cn("h-7 w-7", className)} 41 44 onClick={() => { 42 45 copyToClipboard(text); 43 46 setHasCopied(true);
+18 -1
apps/web/src/components/forms/status-page/form.tsx
··· 7 7 import { useForm } from "react-hook-form"; 8 8 9 9 import { insertPageSchema } from "@openstatus/db/src/schema"; 10 - import type { InsertPage, Monitor } from "@openstatus/db/src/schema"; 10 + import type { 11 + InsertPage, 12 + Monitor, 13 + WorkspacePlan, 14 + } from "@openstatus/db/src/schema"; 11 15 import { Badge, Form } from "@openstatus/ui"; 12 16 13 17 import { ··· 25 29 import { General } from "./general"; 26 30 import { SectionAdvanced } from "./section-advanced"; 27 31 import { SectionMonitor } from "./section-monitor"; 32 + import { SectionVisibility } from "./section-visibility"; 28 33 29 34 interface Props { 30 35 defaultSection?: string; ··· 38 43 * on submit, allows to push a url 39 44 */ 40 45 nextUrl?: string; 46 + plan?: WorkspacePlan; 47 + workspaceSlug: string; 41 48 } 42 49 43 50 export function StatusPageForm({ ··· 46 53 allMonitors, 47 54 checkAllMonitors, 48 55 nextUrl, 56 + plan, 57 + workspaceSlug, 49 58 }: Props) { 50 59 const form = useForm<InsertPage>({ 51 60 resolver: zodResolver(insertPageSchema), ··· 61 70 : defaultValues?.monitors ?? [], 62 71 customDomain: defaultValues?.customDomain || "", 63 72 icon: defaultValues?.icon || "", 73 + password: defaultValues?.password || "", 74 + passwordProtected: defaultValues?.passwordProtected || false, 64 75 }, 65 76 }); 66 77 const pathname = usePathname(); ··· 123 134 ?.location, 124 135 }, 125 136 }); 137 + // otherwise, the form will stay dirty - keepValues is used to keep the current values in the form 138 + form.reset({}, { keepValues: true }); 126 139 if (nextUrl) { 127 140 router.push(nextUrl); 128 141 } ··· 174 187 ) : null} 175 188 </TabsTrigger> 176 189 <TabsTrigger value="advanced">Advanced</TabsTrigger> 190 + <TabsTrigger value="visibility">Visibility</TabsTrigger> 177 191 </TabsList> 178 192 <TabsContent value="monitors"> 179 193 <SectionMonitor form={form} monitors={allMonitors} /> 180 194 </TabsContent> 181 195 <TabsContent value="advanced"> 182 196 <SectionAdvanced form={form} /> 197 + </TabsContent> 198 + <TabsContent value="visibility"> 199 + <SectionVisibility {...{ form, plan, workspaceSlug }} /> 183 200 </TabsContent> 184 201 </Tabs> 185 202 <SaveButton
+1 -1
apps/web/src/components/forms/status-page/section-monitor.tsx
··· 25 25 <div className="grid w-full gap-4"> 26 26 <SectionHeader 27 27 title="Connected Monitors" 28 - description="Select the monitors you want to display on your status page. Inactve monitors will not be shown." 28 + description="Select the monitors you want to display on your status page. Inactive monitors will not be shown." 29 29 /> 30 30 <FormField 31 31 control={form.control}
+136
apps/web/src/components/forms/status-page/section-visibility.tsx
··· 1 + "use client"; 2 + 3 + import * as React from "react"; 4 + import { X } from "lucide-react"; 5 + import type { UseFormReturn } from "react-hook-form"; 6 + 7 + import type { InsertPage, WorkspacePlan } from "@openstatus/db/src/schema"; 8 + import { 9 + Button, 10 + Checkbox, 11 + FormControl, 12 + FormDescription, 13 + FormField, 14 + FormItem, 15 + FormLabel, 16 + FormMessage, 17 + Input, 18 + } from "@openstatus/ui"; 19 + 20 + import { ProFeatureHoverCard } from "@/components/billing/pro-feature-hover-card"; 21 + import { CopyToClipboardButton } from "@/components/dashboard/copy-to-clipboard-button"; 22 + import { SectionHeader } from "../shared/section-header"; 23 + 24 + interface Props { 25 + form: UseFormReturn<InsertPage>; 26 + plan?: WorkspacePlan; 27 + workspaceSlug: string; 28 + } 29 + 30 + export function SectionVisibility({ form, plan, workspaceSlug }: Props) { 31 + const watchPasswordProtected = form.watch("passwordProtected"); 32 + const watchPassword = form.watch("password"); 33 + 34 + const getBaseUrl = () => { 35 + if (process.env.NODE_ENV === "development") { 36 + return `http://localhost:3000/status-page/${form.getValues("slug")}`; 37 + } 38 + if (form.getValues("customDomain") !== "") { 39 + return `https://${form.getValues("customDomain")}`; 40 + } 41 + return `https://${form.getValues("slug")}.openstatus.dev`; 42 + }; 43 + 44 + const link = `${getBaseUrl()}?authorize=${watchPassword}`; 45 + 46 + const hasFreePlan = !plan || plan === "free" ? true : false; 47 + 48 + return ( 49 + <div className="grid w-full gap-4 md:grid-cols-2"> 50 + <SectionHeader 51 + title="Visibility" 52 + description="Hide your page from the public by setting a password." 53 + className="md:col-span-full" 54 + /> 55 + <ProFeatureHoverCard 56 + workspaceSlug={workspaceSlug} 57 + plan={plan} 58 + minRequiredPlan="starter" 59 + > 60 + <div className="grid w-full gap-4 md:col-span-full md:grid-cols-2"> 61 + <FormField 62 + control={form.control} 63 + name="passwordProtected" 64 + disabled={hasFreePlan} 65 + render={({ field }) => ( 66 + <FormItem className="flex flex-row items-start space-x-3 space-y-0 md:col-span-full"> 67 + <FormControl> 68 + <Checkbox 69 + disabled={field.disabled} 70 + checked={field.value ?? false} 71 + onCheckedChange={field.onChange} 72 + /> 73 + </FormControl> 74 + <div className="space-y-1 leading-none"> 75 + <FormLabel>Protect with password</FormLabel> 76 + <FormDescription> 77 + Hide the page from the public 78 + </FormDescription> 79 + </div> 80 + </FormItem> 81 + )} 82 + /> 83 + <FormField 84 + control={form.control} 85 + name="password" 86 + disabled={hasFreePlan} 87 + render={({ field }) => ( 88 + <FormItem className="md:col-span-1"> 89 + <FormLabel>Password</FormLabel> 90 + <div className="flex items-center gap-2"> 91 + <FormControl> 92 + <Input 93 + {...field} 94 + placeholder="top-secret" 95 + disabled={!watchPasswordProtected} 96 + value={field.value ?? ""} // REMINDER: remove nullish coalescing from db schema 97 + /> 98 + </FormControl> 99 + <Button 100 + size="icon" 101 + variant="ghost" 102 + type="button" 103 + onClick={() => form.setValue("password", "")} 104 + disabled={!field.value || !watchPasswordProtected} 105 + > 106 + <X className="h-4 w-4" /> 107 + </Button> 108 + </div> 109 + <FormDescription> 110 + No restriction on the password. It&apos;s just a simple 111 + password you define. 112 + </FormDescription> 113 + <FormMessage /> 114 + </FormItem> 115 + )} 116 + /> 117 + {watchPasswordProtected ? ( 118 + <div className="text-sm md:col-span-full"> 119 + <p className="text-muted-foreground"> 120 + If you want to share the page without the need to enter the 121 + password, you can share the following link: 122 + </p> 123 + <div className="flex flex-wrap items-center gap-2"> 124 + <p className="text-foreground">{link} </p> 125 + <CopyToClipboardButton 126 + text={link} 127 + tooltipText="Copy to clipboard" 128 + /> 129 + </div> 130 + </div> 131 + ) : null} 132 + </div> 133 + </ProFeatureHoverCard> 134 + </div> 135 + ); 136 + }
+13
apps/web/src/content/changelog/password-protected-status-page.mdx
··· 1 + --- 2 + title: Password-protected status page 3 + description: Hide your page from the public. 4 + image: /assets/changelog/password-protected-status-page.png 5 + publishedAt: 2024-05-03 6 + --- 7 + 8 + You can now protect your status page with a password. This will hide the page 9 + from the public and only allow access to users with the password. 10 + 11 + You can enable the password protection from the status page settings. 12 + 13 + > This feature is available on the Starter plan and above.
+32 -5
packages/api/src/router/page.ts
··· 10 10 monitorsToStatusReport, 11 11 page, 12 12 pagesToStatusReports, 13 + selectPageSchemaWithMonitorsRelation, 13 14 selectPublicPageSchemaWithRelation, 14 15 statusReport, 15 16 workspace, ··· 29 30 }) 30 31 ).length; 31 32 32 - const limit = allPlans[opts.ctx.workspace.plan].limits["status-pages"]; 33 + const limit = allPlans[opts.ctx.workspace.plan].limits; 33 34 34 - // the user has reached the limits 35 - if (pageNumbers >= limit) { 35 + // the user has reached the status page number limits 36 + if (pageNumbers >= limit["status-pages"]) { 36 37 throw new TRPCError({ 37 38 code: "FORBIDDEN", 38 39 message: "You reached your status-page limits.", 39 40 }); 40 41 } 41 42 43 + // the user is not eligible for password protection 44 + if ( 45 + limit["password-protection"] === false && 46 + opts.input.passwordProtected === true 47 + ) { 48 + throw new TRPCError({ 49 + code: "FORBIDDEN", 50 + message: "Password protection is not available for your current plan.", 51 + }); 52 + } 53 + 42 54 const newPage = await opts.ctx.db 43 55 .insert(page) 44 56 .values({ workspaceId: opts.ctx.workspace.id, ...pageProps }) ··· 70 82 getPageById: protectedProcedure 71 83 .input(z.object({ id: z.number() })) 72 84 .query(async (opts) => { 73 - return await opts.ctx.db.query.page.findFirst({ 85 + const firstPage = await opts.ctx.db.query.page.findFirst({ 74 86 where: and( 75 87 eq(page.id, opts.input.id), 76 88 eq(page.workspaceId, opts.ctx.workspace.id), ··· 79 91 monitorsToPages: { with: { monitor: true } }, 80 92 }, 81 93 }); 94 + return selectPageSchemaWithMonitorsRelation.parse(firstPage); 82 95 }), 83 96 84 97 update: protectedProcedure.input(insertPageSchema).mutation(async (opts) => { 85 98 const { monitors, ...pageInput } = opts.input; 86 99 if (!pageInput.id) return; 100 + 101 + const limit = allPlans[opts.ctx.workspace.plan].limits; 102 + 103 + // the user is not eligible for password protection 104 + if ( 105 + limit["password-protection"] === false && 106 + opts.input.passwordProtected === true 107 + ) { 108 + throw new TRPCError({ 109 + code: "FORBIDDEN", 110 + message: "Password protection is not available for your current plan.", 111 + }); 112 + } 87 113 88 114 const currentPage = await opts.ctx.db 89 115 .update(page) ··· 146 172 .run(); 147 173 }), 148 174 getPagesByWorkspace: protectedProcedure.query(async (opts) => { 149 - return opts.ctx.db.query.page.findMany({ 175 + const allPages = await opts.ctx.db.query.page.findMany({ 150 176 where: and(eq(page.workspaceId, opts.ctx.workspace.id)), 151 177 with: { 152 178 monitorsToPages: { with: { monitor: true } }, 153 179 }, 154 180 }); 181 + return z.array(selectPageSchemaWithMonitorsRelation).parse(allPages); 155 182 }), 156 183 157 184 // public if we use trpc hooks to get the page from the url
+2
packages/db/drizzle/0027_bizarre_bastion.sql
··· 1 + ALTER TABLE page ADD `password` text(256);--> statement-breakpoint 2 + ALTER TABLE page ADD `password_protected` integer DEFAULT false;
+1671
packages/db/drizzle/meta/0027_snapshot.json
··· 1 + { 2 + "version": "5", 3 + "dialect": "sqlite", 4 + "id": "4488e03b-52ea-45eb-aac9-7d263c8f2e43", 5 + "prevId": "0021b887-ceab-4294-a17c-34c902d14a12", 6 + "tables": { 7 + "status_report_to_monitors": { 8 + "name": "status_report_to_monitors", 9 + "columns": { 10 + "monitor_id": { 11 + "name": "monitor_id", 12 + "type": "integer", 13 + "primaryKey": false, 14 + "notNull": true, 15 + "autoincrement": false 16 + }, 17 + "status_report_id": { 18 + "name": "status_report_id", 19 + "type": "integer", 20 + "primaryKey": false, 21 + "notNull": true, 22 + "autoincrement": false 23 + }, 24 + "created_at": { 25 + "name": "created_at", 26 + "type": "integer", 27 + "primaryKey": false, 28 + "notNull": false, 29 + "autoincrement": false, 30 + "default": "(strftime('%s', 'now'))" 31 + } 32 + }, 33 + "indexes": {}, 34 + "foreignKeys": { 35 + "status_report_to_monitors_monitor_id_monitor_id_fk": { 36 + "name": "status_report_to_monitors_monitor_id_monitor_id_fk", 37 + "tableFrom": "status_report_to_monitors", 38 + "tableTo": "monitor", 39 + "columnsFrom": [ 40 + "monitor_id" 41 + ], 42 + "columnsTo": [ 43 + "id" 44 + ], 45 + "onDelete": "cascade", 46 + "onUpdate": "no action" 47 + }, 48 + "status_report_to_monitors_status_report_id_status_report_id_fk": { 49 + "name": "status_report_to_monitors_status_report_id_status_report_id_fk", 50 + "tableFrom": "status_report_to_monitors", 51 + "tableTo": "status_report", 52 + "columnsFrom": [ 53 + "status_report_id" 54 + ], 55 + "columnsTo": [ 56 + "id" 57 + ], 58 + "onDelete": "cascade", 59 + "onUpdate": "no action" 60 + } 61 + }, 62 + "compositePrimaryKeys": { 63 + "status_report_to_monitors_monitor_id_status_report_id_pk": { 64 + "columns": [ 65 + "monitor_id", 66 + "status_report_id" 67 + ], 68 + "name": "status_report_to_monitors_monitor_id_status_report_id_pk" 69 + } 70 + }, 71 + "uniqueConstraints": {} 72 + }, 73 + "status_reports_to_pages": { 74 + "name": "status_reports_to_pages", 75 + "columns": { 76 + "page_id": { 77 + "name": "page_id", 78 + "type": "integer", 79 + "primaryKey": false, 80 + "notNull": true, 81 + "autoincrement": false 82 + }, 83 + "status_report_id": { 84 + "name": "status_report_id", 85 + "type": "integer", 86 + "primaryKey": false, 87 + "notNull": true, 88 + "autoincrement": false 89 + }, 90 + "created_at": { 91 + "name": "created_at", 92 + "type": "integer", 93 + "primaryKey": false, 94 + "notNull": false, 95 + "autoincrement": false, 96 + "default": "(strftime('%s', 'now'))" 97 + } 98 + }, 99 + "indexes": {}, 100 + "foreignKeys": { 101 + "status_reports_to_pages_page_id_page_id_fk": { 102 + "name": "status_reports_to_pages_page_id_page_id_fk", 103 + "tableFrom": "status_reports_to_pages", 104 + "tableTo": "page", 105 + "columnsFrom": [ 106 + "page_id" 107 + ], 108 + "columnsTo": [ 109 + "id" 110 + ], 111 + "onDelete": "cascade", 112 + "onUpdate": "no action" 113 + }, 114 + "status_reports_to_pages_status_report_id_status_report_id_fk": { 115 + "name": "status_reports_to_pages_status_report_id_status_report_id_fk", 116 + "tableFrom": "status_reports_to_pages", 117 + "tableTo": "status_report", 118 + "columnsFrom": [ 119 + "status_report_id" 120 + ], 121 + "columnsTo": [ 122 + "id" 123 + ], 124 + "onDelete": "cascade", 125 + "onUpdate": "no action" 126 + } 127 + }, 128 + "compositePrimaryKeys": { 129 + "status_reports_to_pages_page_id_status_report_id_pk": { 130 + "columns": [ 131 + "page_id", 132 + "status_report_id" 133 + ], 134 + "name": "status_reports_to_pages_page_id_status_report_id_pk" 135 + } 136 + }, 137 + "uniqueConstraints": {} 138 + }, 139 + "status_report": { 140 + "name": "status_report", 141 + "columns": { 142 + "id": { 143 + "name": "id", 144 + "type": "integer", 145 + "primaryKey": true, 146 + "notNull": true, 147 + "autoincrement": false 148 + }, 149 + "status": { 150 + "name": "status", 151 + "type": "text", 152 + "primaryKey": false, 153 + "notNull": true, 154 + "autoincrement": false 155 + }, 156 + "title": { 157 + "name": "title", 158 + "type": "text(256)", 159 + "primaryKey": false, 160 + "notNull": true, 161 + "autoincrement": false 162 + }, 163 + "workspace_id": { 164 + "name": "workspace_id", 165 + "type": "integer", 166 + "primaryKey": false, 167 + "notNull": false, 168 + "autoincrement": false 169 + }, 170 + "created_at": { 171 + "name": "created_at", 172 + "type": "integer", 173 + "primaryKey": false, 174 + "notNull": false, 175 + "autoincrement": false, 176 + "default": "(strftime('%s', 'now'))" 177 + }, 178 + "updated_at": { 179 + "name": "updated_at", 180 + "type": "integer", 181 + "primaryKey": false, 182 + "notNull": false, 183 + "autoincrement": false, 184 + "default": "(strftime('%s', 'now'))" 185 + } 186 + }, 187 + "indexes": {}, 188 + "foreignKeys": { 189 + "status_report_workspace_id_workspace_id_fk": { 190 + "name": "status_report_workspace_id_workspace_id_fk", 191 + "tableFrom": "status_report", 192 + "tableTo": "workspace", 193 + "columnsFrom": [ 194 + "workspace_id" 195 + ], 196 + "columnsTo": [ 197 + "id" 198 + ], 199 + "onDelete": "no action", 200 + "onUpdate": "no action" 201 + } 202 + }, 203 + "compositePrimaryKeys": {}, 204 + "uniqueConstraints": {} 205 + }, 206 + "status_report_update": { 207 + "name": "status_report_update", 208 + "columns": { 209 + "id": { 210 + "name": "id", 211 + "type": "integer", 212 + "primaryKey": true, 213 + "notNull": true, 214 + "autoincrement": false 215 + }, 216 + "status": { 217 + "name": "status", 218 + "type": "text(4)", 219 + "primaryKey": false, 220 + "notNull": true, 221 + "autoincrement": false 222 + }, 223 + "date": { 224 + "name": "date", 225 + "type": "integer", 226 + "primaryKey": false, 227 + "notNull": true, 228 + "autoincrement": false 229 + }, 230 + "message": { 231 + "name": "message", 232 + "type": "text", 233 + "primaryKey": false, 234 + "notNull": true, 235 + "autoincrement": false 236 + }, 237 + "status_report_id": { 238 + "name": "status_report_id", 239 + "type": "integer", 240 + "primaryKey": false, 241 + "notNull": true, 242 + "autoincrement": false 243 + }, 244 + "created_at": { 245 + "name": "created_at", 246 + "type": "integer", 247 + "primaryKey": false, 248 + "notNull": false, 249 + "autoincrement": false, 250 + "default": "(strftime('%s', 'now'))" 251 + }, 252 + "updated_at": { 253 + "name": "updated_at", 254 + "type": "integer", 255 + "primaryKey": false, 256 + "notNull": false, 257 + "autoincrement": false, 258 + "default": "(strftime('%s', 'now'))" 259 + } 260 + }, 261 + "indexes": {}, 262 + "foreignKeys": { 263 + "status_report_update_status_report_id_status_report_id_fk": { 264 + "name": "status_report_update_status_report_id_status_report_id_fk", 265 + "tableFrom": "status_report_update", 266 + "tableTo": "status_report", 267 + "columnsFrom": [ 268 + "status_report_id" 269 + ], 270 + "columnsTo": [ 271 + "id" 272 + ], 273 + "onDelete": "cascade", 274 + "onUpdate": "no action" 275 + } 276 + }, 277 + "compositePrimaryKeys": {}, 278 + "uniqueConstraints": {} 279 + }, 280 + "integration": { 281 + "name": "integration", 282 + "columns": { 283 + "id": { 284 + "name": "id", 285 + "type": "integer", 286 + "primaryKey": true, 287 + "notNull": true, 288 + "autoincrement": false 289 + }, 290 + "name": { 291 + "name": "name", 292 + "type": "text(256)", 293 + "primaryKey": false, 294 + "notNull": true, 295 + "autoincrement": false 296 + }, 297 + "workspace_id": { 298 + "name": "workspace_id", 299 + "type": "integer", 300 + "primaryKey": false, 301 + "notNull": false, 302 + "autoincrement": false 303 + }, 304 + "credential": { 305 + "name": "credential", 306 + "type": "text", 307 + "primaryKey": false, 308 + "notNull": false, 309 + "autoincrement": false 310 + }, 311 + "external_id": { 312 + "name": "external_id", 313 + "type": "text", 314 + "primaryKey": false, 315 + "notNull": true, 316 + "autoincrement": false 317 + }, 318 + "created_at": { 319 + "name": "created_at", 320 + "type": "integer", 321 + "primaryKey": false, 322 + "notNull": false, 323 + "autoincrement": false, 324 + "default": "(strftime('%s', 'now'))" 325 + }, 326 + "updated_at": { 327 + "name": "updated_at", 328 + "type": "integer", 329 + "primaryKey": false, 330 + "notNull": false, 331 + "autoincrement": false, 332 + "default": "(strftime('%s', 'now'))" 333 + }, 334 + "data": { 335 + "name": "data", 336 + "type": "text", 337 + "primaryKey": false, 338 + "notNull": true, 339 + "autoincrement": false 340 + } 341 + }, 342 + "indexes": {}, 343 + "foreignKeys": { 344 + "integration_workspace_id_workspace_id_fk": { 345 + "name": "integration_workspace_id_workspace_id_fk", 346 + "tableFrom": "integration", 347 + "tableTo": "workspace", 348 + "columnsFrom": [ 349 + "workspace_id" 350 + ], 351 + "columnsTo": [ 352 + "id" 353 + ], 354 + "onDelete": "no action", 355 + "onUpdate": "no action" 356 + } 357 + }, 358 + "compositePrimaryKeys": {}, 359 + "uniqueConstraints": {} 360 + }, 361 + "page": { 362 + "name": "page", 363 + "columns": { 364 + "id": { 365 + "name": "id", 366 + "type": "integer", 367 + "primaryKey": true, 368 + "notNull": true, 369 + "autoincrement": false 370 + }, 371 + "workspace_id": { 372 + "name": "workspace_id", 373 + "type": "integer", 374 + "primaryKey": false, 375 + "notNull": true, 376 + "autoincrement": false 377 + }, 378 + "title": { 379 + "name": "title", 380 + "type": "text", 381 + "primaryKey": false, 382 + "notNull": true, 383 + "autoincrement": false 384 + }, 385 + "description": { 386 + "name": "description", 387 + "type": "text", 388 + "primaryKey": false, 389 + "notNull": true, 390 + "autoincrement": false 391 + }, 392 + "icon": { 393 + "name": "icon", 394 + "type": "text(256)", 395 + "primaryKey": false, 396 + "notNull": false, 397 + "autoincrement": false, 398 + "default": "''" 399 + }, 400 + "slug": { 401 + "name": "slug", 402 + "type": "text(256)", 403 + "primaryKey": false, 404 + "notNull": true, 405 + "autoincrement": false 406 + }, 407 + "custom_domain": { 408 + "name": "custom_domain", 409 + "type": "text(256)", 410 + "primaryKey": false, 411 + "notNull": true, 412 + "autoincrement": false 413 + }, 414 + "published": { 415 + "name": "published", 416 + "type": "integer", 417 + "primaryKey": false, 418 + "notNull": false, 419 + "autoincrement": false, 420 + "default": false 421 + }, 422 + "password": { 423 + "name": "password", 424 + "type": "text(256)", 425 + "primaryKey": false, 426 + "notNull": false, 427 + "autoincrement": false 428 + }, 429 + "password_protected": { 430 + "name": "password_protected", 431 + "type": "integer", 432 + "primaryKey": false, 433 + "notNull": false, 434 + "autoincrement": false, 435 + "default": false 436 + }, 437 + "created_at": { 438 + "name": "created_at", 439 + "type": "integer", 440 + "primaryKey": false, 441 + "notNull": false, 442 + "autoincrement": false, 443 + "default": "(strftime('%s', 'now'))" 444 + }, 445 + "updated_at": { 446 + "name": "updated_at", 447 + "type": "integer", 448 + "primaryKey": false, 449 + "notNull": false, 450 + "autoincrement": false, 451 + "default": "(strftime('%s', 'now'))" 452 + } 453 + }, 454 + "indexes": { 455 + "page_slug_unique": { 456 + "name": "page_slug_unique", 457 + "columns": [ 458 + "slug" 459 + ], 460 + "isUnique": true 461 + } 462 + }, 463 + "foreignKeys": { 464 + "page_workspace_id_workspace_id_fk": { 465 + "name": "page_workspace_id_workspace_id_fk", 466 + "tableFrom": "page", 467 + "tableTo": "workspace", 468 + "columnsFrom": [ 469 + "workspace_id" 470 + ], 471 + "columnsTo": [ 472 + "id" 473 + ], 474 + "onDelete": "cascade", 475 + "onUpdate": "no action" 476 + } 477 + }, 478 + "compositePrimaryKeys": {}, 479 + "uniqueConstraints": {} 480 + }, 481 + "monitor": { 482 + "name": "monitor", 483 + "columns": { 484 + "id": { 485 + "name": "id", 486 + "type": "integer", 487 + "primaryKey": true, 488 + "notNull": true, 489 + "autoincrement": false 490 + }, 491 + "job_type": { 492 + "name": "job_type", 493 + "type": "text", 494 + "primaryKey": false, 495 + "notNull": true, 496 + "autoincrement": false, 497 + "default": "'other'" 498 + }, 499 + "periodicity": { 500 + "name": "periodicity", 501 + "type": "text", 502 + "primaryKey": false, 503 + "notNull": true, 504 + "autoincrement": false, 505 + "default": "'other'" 506 + }, 507 + "status": { 508 + "name": "status", 509 + "type": "text", 510 + "primaryKey": false, 511 + "notNull": true, 512 + "autoincrement": false, 513 + "default": "'active'" 514 + }, 515 + "active": { 516 + "name": "active", 517 + "type": "integer", 518 + "primaryKey": false, 519 + "notNull": false, 520 + "autoincrement": false, 521 + "default": false 522 + }, 523 + "regions": { 524 + "name": "regions", 525 + "type": "text", 526 + "primaryKey": false, 527 + "notNull": true, 528 + "autoincrement": false, 529 + "default": "''" 530 + }, 531 + "url": { 532 + "name": "url", 533 + "type": "text(2048)", 534 + "primaryKey": false, 535 + "notNull": true, 536 + "autoincrement": false 537 + }, 538 + "name": { 539 + "name": "name", 540 + "type": "text(256)", 541 + "primaryKey": false, 542 + "notNull": true, 543 + "autoincrement": false, 544 + "default": "''" 545 + }, 546 + "description": { 547 + "name": "description", 548 + "type": "text", 549 + "primaryKey": false, 550 + "notNull": true, 551 + "autoincrement": false, 552 + "default": "''" 553 + }, 554 + "headers": { 555 + "name": "headers", 556 + "type": "text", 557 + "primaryKey": false, 558 + "notNull": false, 559 + "autoincrement": false, 560 + "default": "''" 561 + }, 562 + "body": { 563 + "name": "body", 564 + "type": "text", 565 + "primaryKey": false, 566 + "notNull": false, 567 + "autoincrement": false, 568 + "default": "''" 569 + }, 570 + "method": { 571 + "name": "method", 572 + "type": "text", 573 + "primaryKey": false, 574 + "notNull": false, 575 + "autoincrement": false, 576 + "default": "'GET'" 577 + }, 578 + "workspace_id": { 579 + "name": "workspace_id", 580 + "type": "integer", 581 + "primaryKey": false, 582 + "notNull": false, 583 + "autoincrement": false 584 + }, 585 + "assertions": { 586 + "name": "assertions", 587 + "type": "text", 588 + "primaryKey": false, 589 + "notNull": false, 590 + "autoincrement": false 591 + }, 592 + "public": { 593 + "name": "public", 594 + "type": "integer", 595 + "primaryKey": false, 596 + "notNull": false, 597 + "autoincrement": false, 598 + "default": false 599 + }, 600 + "created_at": { 601 + "name": "created_at", 602 + "type": "integer", 603 + "primaryKey": false, 604 + "notNull": false, 605 + "autoincrement": false, 606 + "default": "(strftime('%s', 'now'))" 607 + }, 608 + "updated_at": { 609 + "name": "updated_at", 610 + "type": "integer", 611 + "primaryKey": false, 612 + "notNull": false, 613 + "autoincrement": false, 614 + "default": "(strftime('%s', 'now'))" 615 + }, 616 + "deleted_at": { 617 + "name": "deleted_at", 618 + "type": "integer", 619 + "primaryKey": false, 620 + "notNull": false, 621 + "autoincrement": false 622 + } 623 + }, 624 + "indexes": {}, 625 + "foreignKeys": { 626 + "monitor_workspace_id_workspace_id_fk": { 627 + "name": "monitor_workspace_id_workspace_id_fk", 628 + "tableFrom": "monitor", 629 + "tableTo": "workspace", 630 + "columnsFrom": [ 631 + "workspace_id" 632 + ], 633 + "columnsTo": [ 634 + "id" 635 + ], 636 + "onDelete": "no action", 637 + "onUpdate": "no action" 638 + } 639 + }, 640 + "compositePrimaryKeys": {}, 641 + "uniqueConstraints": {} 642 + }, 643 + "monitors_to_pages": { 644 + "name": "monitors_to_pages", 645 + "columns": { 646 + "monitor_id": { 647 + "name": "monitor_id", 648 + "type": "integer", 649 + "primaryKey": false, 650 + "notNull": true, 651 + "autoincrement": false 652 + }, 653 + "page_id": { 654 + "name": "page_id", 655 + "type": "integer", 656 + "primaryKey": false, 657 + "notNull": true, 658 + "autoincrement": false 659 + }, 660 + "created_at": { 661 + "name": "created_at", 662 + "type": "integer", 663 + "primaryKey": false, 664 + "notNull": false, 665 + "autoincrement": false, 666 + "default": "(strftime('%s', 'now'))" 667 + } 668 + }, 669 + "indexes": {}, 670 + "foreignKeys": { 671 + "monitors_to_pages_monitor_id_monitor_id_fk": { 672 + "name": "monitors_to_pages_monitor_id_monitor_id_fk", 673 + "tableFrom": "monitors_to_pages", 674 + "tableTo": "monitor", 675 + "columnsFrom": [ 676 + "monitor_id" 677 + ], 678 + "columnsTo": [ 679 + "id" 680 + ], 681 + "onDelete": "cascade", 682 + "onUpdate": "no action" 683 + }, 684 + "monitors_to_pages_page_id_page_id_fk": { 685 + "name": "monitors_to_pages_page_id_page_id_fk", 686 + "tableFrom": "monitors_to_pages", 687 + "tableTo": "page", 688 + "columnsFrom": [ 689 + "page_id" 690 + ], 691 + "columnsTo": [ 692 + "id" 693 + ], 694 + "onDelete": "cascade", 695 + "onUpdate": "no action" 696 + } 697 + }, 698 + "compositePrimaryKeys": { 699 + "monitors_to_pages_monitor_id_page_id_pk": { 700 + "columns": [ 701 + "monitor_id", 702 + "page_id" 703 + ], 704 + "name": "monitors_to_pages_monitor_id_page_id_pk" 705 + } 706 + }, 707 + "uniqueConstraints": {} 708 + }, 709 + "user": { 710 + "name": "user", 711 + "columns": { 712 + "id": { 713 + "name": "id", 714 + "type": "integer", 715 + "primaryKey": true, 716 + "notNull": true, 717 + "autoincrement": false 718 + }, 719 + "tenant_id": { 720 + "name": "tenant_id", 721 + "type": "text(256)", 722 + "primaryKey": false, 723 + "notNull": false, 724 + "autoincrement": false 725 + }, 726 + "first_name": { 727 + "name": "first_name", 728 + "type": "text", 729 + "primaryKey": false, 730 + "notNull": false, 731 + "autoincrement": false, 732 + "default": "''" 733 + }, 734 + "last_name": { 735 + "name": "last_name", 736 + "type": "text", 737 + "primaryKey": false, 738 + "notNull": false, 739 + "autoincrement": false, 740 + "default": "''" 741 + }, 742 + "email": { 743 + "name": "email", 744 + "type": "text", 745 + "primaryKey": false, 746 + "notNull": false, 747 + "autoincrement": false, 748 + "default": "''" 749 + }, 750 + "photo_url": { 751 + "name": "photo_url", 752 + "type": "text", 753 + "primaryKey": false, 754 + "notNull": false, 755 + "autoincrement": false, 756 + "default": "''" 757 + }, 758 + "created_at": { 759 + "name": "created_at", 760 + "type": "integer", 761 + "primaryKey": false, 762 + "notNull": false, 763 + "autoincrement": false, 764 + "default": "(strftime('%s', 'now'))" 765 + }, 766 + "updated_at": { 767 + "name": "updated_at", 768 + "type": "integer", 769 + "primaryKey": false, 770 + "notNull": false, 771 + "autoincrement": false, 772 + "default": "(strftime('%s', 'now'))" 773 + } 774 + }, 775 + "indexes": { 776 + "user_tenant_id_unique": { 777 + "name": "user_tenant_id_unique", 778 + "columns": [ 779 + "tenant_id" 780 + ], 781 + "isUnique": true 782 + } 783 + }, 784 + "foreignKeys": {}, 785 + "compositePrimaryKeys": {}, 786 + "uniqueConstraints": {} 787 + }, 788 + "users_to_workspaces": { 789 + "name": "users_to_workspaces", 790 + "columns": { 791 + "user_id": { 792 + "name": "user_id", 793 + "type": "integer", 794 + "primaryKey": false, 795 + "notNull": true, 796 + "autoincrement": false 797 + }, 798 + "workspace_id": { 799 + "name": "workspace_id", 800 + "type": "integer", 801 + "primaryKey": false, 802 + "notNull": true, 803 + "autoincrement": false 804 + }, 805 + "role": { 806 + "name": "role", 807 + "type": "text", 808 + "primaryKey": false, 809 + "notNull": true, 810 + "autoincrement": false, 811 + "default": "'member'" 812 + }, 813 + "created_at": { 814 + "name": "created_at", 815 + "type": "integer", 816 + "primaryKey": false, 817 + "notNull": false, 818 + "autoincrement": false, 819 + "default": "(strftime('%s', 'now'))" 820 + } 821 + }, 822 + "indexes": {}, 823 + "foreignKeys": { 824 + "users_to_workspaces_user_id_user_id_fk": { 825 + "name": "users_to_workspaces_user_id_user_id_fk", 826 + "tableFrom": "users_to_workspaces", 827 + "tableTo": "user", 828 + "columnsFrom": [ 829 + "user_id" 830 + ], 831 + "columnsTo": [ 832 + "id" 833 + ], 834 + "onDelete": "no action", 835 + "onUpdate": "no action" 836 + }, 837 + "users_to_workspaces_workspace_id_workspace_id_fk": { 838 + "name": "users_to_workspaces_workspace_id_workspace_id_fk", 839 + "tableFrom": "users_to_workspaces", 840 + "tableTo": "workspace", 841 + "columnsFrom": [ 842 + "workspace_id" 843 + ], 844 + "columnsTo": [ 845 + "id" 846 + ], 847 + "onDelete": "no action", 848 + "onUpdate": "no action" 849 + } 850 + }, 851 + "compositePrimaryKeys": { 852 + "users_to_workspaces_user_id_workspace_id_pk": { 853 + "columns": [ 854 + "user_id", 855 + "workspace_id" 856 + ], 857 + "name": "users_to_workspaces_user_id_workspace_id_pk" 858 + } 859 + }, 860 + "uniqueConstraints": {} 861 + }, 862 + "page_subscriber": { 863 + "name": "page_subscriber", 864 + "columns": { 865 + "id": { 866 + "name": "id", 867 + "type": "integer", 868 + "primaryKey": true, 869 + "notNull": true, 870 + "autoincrement": false 871 + }, 872 + "email": { 873 + "name": "email", 874 + "type": "text", 875 + "primaryKey": false, 876 + "notNull": true, 877 + "autoincrement": false 878 + }, 879 + "page_id": { 880 + "name": "page_id", 881 + "type": "integer", 882 + "primaryKey": false, 883 + "notNull": true, 884 + "autoincrement": false 885 + }, 886 + "token": { 887 + "name": "token", 888 + "type": "text", 889 + "primaryKey": false, 890 + "notNull": false, 891 + "autoincrement": false 892 + }, 893 + "accepted_at": { 894 + "name": "accepted_at", 895 + "type": "integer", 896 + "primaryKey": false, 897 + "notNull": false, 898 + "autoincrement": false 899 + }, 900 + "expires_at": { 901 + "name": "expires_at", 902 + "type": "integer", 903 + "primaryKey": false, 904 + "notNull": false, 905 + "autoincrement": false 906 + }, 907 + "created_at": { 908 + "name": "created_at", 909 + "type": "integer", 910 + "primaryKey": false, 911 + "notNull": false, 912 + "autoincrement": false, 913 + "default": "(strftime('%s', 'now'))" 914 + }, 915 + "updated_at": { 916 + "name": "updated_at", 917 + "type": "integer", 918 + "primaryKey": false, 919 + "notNull": false, 920 + "autoincrement": false, 921 + "default": "(strftime('%s', 'now'))" 922 + } 923 + }, 924 + "indexes": {}, 925 + "foreignKeys": { 926 + "page_subscriber_page_id_page_id_fk": { 927 + "name": "page_subscriber_page_id_page_id_fk", 928 + "tableFrom": "page_subscriber", 929 + "tableTo": "page", 930 + "columnsFrom": [ 931 + "page_id" 932 + ], 933 + "columnsTo": [ 934 + "id" 935 + ], 936 + "onDelete": "no action", 937 + "onUpdate": "no action" 938 + } 939 + }, 940 + "compositePrimaryKeys": {}, 941 + "uniqueConstraints": {} 942 + }, 943 + "workspace": { 944 + "name": "workspace", 945 + "columns": { 946 + "id": { 947 + "name": "id", 948 + "type": "integer", 949 + "primaryKey": true, 950 + "notNull": true, 951 + "autoincrement": false 952 + }, 953 + "slug": { 954 + "name": "slug", 955 + "type": "text", 956 + "primaryKey": false, 957 + "notNull": true, 958 + "autoincrement": false 959 + }, 960 + "name": { 961 + "name": "name", 962 + "type": "text", 963 + "primaryKey": false, 964 + "notNull": false, 965 + "autoincrement": false 966 + }, 967 + "stripe_id": { 968 + "name": "stripe_id", 969 + "type": "text(256)", 970 + "primaryKey": false, 971 + "notNull": false, 972 + "autoincrement": false 973 + }, 974 + "subscription_id": { 975 + "name": "subscription_id", 976 + "type": "text", 977 + "primaryKey": false, 978 + "notNull": false, 979 + "autoincrement": false 980 + }, 981 + "plan": { 982 + "name": "plan", 983 + "type": "text", 984 + "primaryKey": false, 985 + "notNull": false, 986 + "autoincrement": false 987 + }, 988 + "ends_at": { 989 + "name": "ends_at", 990 + "type": "integer", 991 + "primaryKey": false, 992 + "notNull": false, 993 + "autoincrement": false 994 + }, 995 + "paid_until": { 996 + "name": "paid_until", 997 + "type": "integer", 998 + "primaryKey": false, 999 + "notNull": false, 1000 + "autoincrement": false 1001 + }, 1002 + "created_at": { 1003 + "name": "created_at", 1004 + "type": "integer", 1005 + "primaryKey": false, 1006 + "notNull": false, 1007 + "autoincrement": false, 1008 + "default": "(strftime('%s', 'now'))" 1009 + }, 1010 + "updated_at": { 1011 + "name": "updated_at", 1012 + "type": "integer", 1013 + "primaryKey": false, 1014 + "notNull": false, 1015 + "autoincrement": false, 1016 + "default": "(strftime('%s', 'now'))" 1017 + }, 1018 + "dsn": { 1019 + "name": "dsn", 1020 + "type": "text", 1021 + "primaryKey": false, 1022 + "notNull": false, 1023 + "autoincrement": false 1024 + } 1025 + }, 1026 + "indexes": { 1027 + "workspace_slug_unique": { 1028 + "name": "workspace_slug_unique", 1029 + "columns": [ 1030 + "slug" 1031 + ], 1032 + "isUnique": true 1033 + }, 1034 + "workspace_stripe_id_unique": { 1035 + "name": "workspace_stripe_id_unique", 1036 + "columns": [ 1037 + "stripe_id" 1038 + ], 1039 + "isUnique": true 1040 + }, 1041 + "workspace_id_dsn_unique": { 1042 + "name": "workspace_id_dsn_unique", 1043 + "columns": [ 1044 + "id", 1045 + "dsn" 1046 + ], 1047 + "isUnique": true 1048 + } 1049 + }, 1050 + "foreignKeys": {}, 1051 + "compositePrimaryKeys": {}, 1052 + "uniqueConstraints": {} 1053 + }, 1054 + "notification": { 1055 + "name": "notification", 1056 + "columns": { 1057 + "id": { 1058 + "name": "id", 1059 + "type": "integer", 1060 + "primaryKey": true, 1061 + "notNull": true, 1062 + "autoincrement": false 1063 + }, 1064 + "name": { 1065 + "name": "name", 1066 + "type": "text", 1067 + "primaryKey": false, 1068 + "notNull": true, 1069 + "autoincrement": false 1070 + }, 1071 + "provider": { 1072 + "name": "provider", 1073 + "type": "text", 1074 + "primaryKey": false, 1075 + "notNull": true, 1076 + "autoincrement": false 1077 + }, 1078 + "data": { 1079 + "name": "data", 1080 + "type": "text", 1081 + "primaryKey": false, 1082 + "notNull": false, 1083 + "autoincrement": false, 1084 + "default": "'{}'" 1085 + }, 1086 + "workspace_id": { 1087 + "name": "workspace_id", 1088 + "type": "integer", 1089 + "primaryKey": false, 1090 + "notNull": false, 1091 + "autoincrement": false 1092 + }, 1093 + "created_at": { 1094 + "name": "created_at", 1095 + "type": "integer", 1096 + "primaryKey": false, 1097 + "notNull": false, 1098 + "autoincrement": false, 1099 + "default": "(strftime('%s', 'now'))" 1100 + }, 1101 + "updated_at": { 1102 + "name": "updated_at", 1103 + "type": "integer", 1104 + "primaryKey": false, 1105 + "notNull": false, 1106 + "autoincrement": false, 1107 + "default": "(strftime('%s', 'now'))" 1108 + } 1109 + }, 1110 + "indexes": {}, 1111 + "foreignKeys": { 1112 + "notification_workspace_id_workspace_id_fk": { 1113 + "name": "notification_workspace_id_workspace_id_fk", 1114 + "tableFrom": "notification", 1115 + "tableTo": "workspace", 1116 + "columnsFrom": [ 1117 + "workspace_id" 1118 + ], 1119 + "columnsTo": [ 1120 + "id" 1121 + ], 1122 + "onDelete": "no action", 1123 + "onUpdate": "no action" 1124 + } 1125 + }, 1126 + "compositePrimaryKeys": {}, 1127 + "uniqueConstraints": {} 1128 + }, 1129 + "notifications_to_monitors": { 1130 + "name": "notifications_to_monitors", 1131 + "columns": { 1132 + "monitor_id": { 1133 + "name": "monitor_id", 1134 + "type": "integer", 1135 + "primaryKey": false, 1136 + "notNull": true, 1137 + "autoincrement": false 1138 + }, 1139 + "notification_id": { 1140 + "name": "notification_id", 1141 + "type": "integer", 1142 + "primaryKey": false, 1143 + "notNull": true, 1144 + "autoincrement": false 1145 + }, 1146 + "created_at": { 1147 + "name": "created_at", 1148 + "type": "integer", 1149 + "primaryKey": false, 1150 + "notNull": false, 1151 + "autoincrement": false, 1152 + "default": "(strftime('%s', 'now'))" 1153 + } 1154 + }, 1155 + "indexes": {}, 1156 + "foreignKeys": { 1157 + "notifications_to_monitors_monitor_id_monitor_id_fk": { 1158 + "name": "notifications_to_monitors_monitor_id_monitor_id_fk", 1159 + "tableFrom": "notifications_to_monitors", 1160 + "tableTo": "monitor", 1161 + "columnsFrom": [ 1162 + "monitor_id" 1163 + ], 1164 + "columnsTo": [ 1165 + "id" 1166 + ], 1167 + "onDelete": "cascade", 1168 + "onUpdate": "no action" 1169 + }, 1170 + "notifications_to_monitors_notification_id_notification_id_fk": { 1171 + "name": "notifications_to_monitors_notification_id_notification_id_fk", 1172 + "tableFrom": "notifications_to_monitors", 1173 + "tableTo": "notification", 1174 + "columnsFrom": [ 1175 + "notification_id" 1176 + ], 1177 + "columnsTo": [ 1178 + "id" 1179 + ], 1180 + "onDelete": "cascade", 1181 + "onUpdate": "no action" 1182 + } 1183 + }, 1184 + "compositePrimaryKeys": { 1185 + "notifications_to_monitors_monitor_id_notification_id_pk": { 1186 + "columns": [ 1187 + "monitor_id", 1188 + "notification_id" 1189 + ], 1190 + "name": "notifications_to_monitors_monitor_id_notification_id_pk" 1191 + } 1192 + }, 1193 + "uniqueConstraints": {} 1194 + }, 1195 + "monitor_status": { 1196 + "name": "monitor_status", 1197 + "columns": { 1198 + "monitor_id": { 1199 + "name": "monitor_id", 1200 + "type": "integer", 1201 + "primaryKey": false, 1202 + "notNull": true, 1203 + "autoincrement": false 1204 + }, 1205 + "region": { 1206 + "name": "region", 1207 + "type": "text", 1208 + "primaryKey": false, 1209 + "notNull": true, 1210 + "autoincrement": false, 1211 + "default": "''" 1212 + }, 1213 + "status": { 1214 + "name": "status", 1215 + "type": "text", 1216 + "primaryKey": false, 1217 + "notNull": true, 1218 + "autoincrement": false, 1219 + "default": "'active'" 1220 + }, 1221 + "created_at": { 1222 + "name": "created_at", 1223 + "type": "integer", 1224 + "primaryKey": false, 1225 + "notNull": false, 1226 + "autoincrement": false, 1227 + "default": "(strftime('%s', 'now'))" 1228 + }, 1229 + "updated_at": { 1230 + "name": "updated_at", 1231 + "type": "integer", 1232 + "primaryKey": false, 1233 + "notNull": false, 1234 + "autoincrement": false, 1235 + "default": "(strftime('%s', 'now'))" 1236 + } 1237 + }, 1238 + "indexes": { 1239 + "monitor_status_idx": { 1240 + "name": "monitor_status_idx", 1241 + "columns": [ 1242 + "monitor_id", 1243 + "region" 1244 + ], 1245 + "isUnique": false 1246 + } 1247 + }, 1248 + "foreignKeys": { 1249 + "monitor_status_monitor_id_monitor_id_fk": { 1250 + "name": "monitor_status_monitor_id_monitor_id_fk", 1251 + "tableFrom": "monitor_status", 1252 + "tableTo": "monitor", 1253 + "columnsFrom": [ 1254 + "monitor_id" 1255 + ], 1256 + "columnsTo": [ 1257 + "id" 1258 + ], 1259 + "onDelete": "cascade", 1260 + "onUpdate": "no action" 1261 + } 1262 + }, 1263 + "compositePrimaryKeys": { 1264 + "monitor_status_monitor_id_region_pk": { 1265 + "columns": [ 1266 + "monitor_id", 1267 + "region" 1268 + ], 1269 + "name": "monitor_status_monitor_id_region_pk" 1270 + } 1271 + }, 1272 + "uniqueConstraints": {} 1273 + }, 1274 + "invitation": { 1275 + "name": "invitation", 1276 + "columns": { 1277 + "id": { 1278 + "name": "id", 1279 + "type": "integer", 1280 + "primaryKey": true, 1281 + "notNull": true, 1282 + "autoincrement": false 1283 + }, 1284 + "email": { 1285 + "name": "email", 1286 + "type": "text", 1287 + "primaryKey": false, 1288 + "notNull": true, 1289 + "autoincrement": false 1290 + }, 1291 + "role": { 1292 + "name": "role", 1293 + "type": "text", 1294 + "primaryKey": false, 1295 + "notNull": true, 1296 + "autoincrement": false, 1297 + "default": "'member'" 1298 + }, 1299 + "workspace_id": { 1300 + "name": "workspace_id", 1301 + "type": "integer", 1302 + "primaryKey": false, 1303 + "notNull": true, 1304 + "autoincrement": false 1305 + }, 1306 + "token": { 1307 + "name": "token", 1308 + "type": "text", 1309 + "primaryKey": false, 1310 + "notNull": true, 1311 + "autoincrement": false 1312 + }, 1313 + "expires_at": { 1314 + "name": "expires_at", 1315 + "type": "integer", 1316 + "primaryKey": false, 1317 + "notNull": true, 1318 + "autoincrement": false 1319 + }, 1320 + "created_at": { 1321 + "name": "created_at", 1322 + "type": "integer", 1323 + "primaryKey": false, 1324 + "notNull": false, 1325 + "autoincrement": false, 1326 + "default": "(strftime('%s', 'now'))" 1327 + }, 1328 + "accepted_at": { 1329 + "name": "accepted_at", 1330 + "type": "integer", 1331 + "primaryKey": false, 1332 + "notNull": false, 1333 + "autoincrement": false 1334 + } 1335 + }, 1336 + "indexes": {}, 1337 + "foreignKeys": {}, 1338 + "compositePrimaryKeys": {}, 1339 + "uniqueConstraints": {} 1340 + }, 1341 + "incident": { 1342 + "name": "incident", 1343 + "columns": { 1344 + "id": { 1345 + "name": "id", 1346 + "type": "integer", 1347 + "primaryKey": true, 1348 + "notNull": true, 1349 + "autoincrement": false 1350 + }, 1351 + "title": { 1352 + "name": "title", 1353 + "type": "text", 1354 + "primaryKey": false, 1355 + "notNull": true, 1356 + "autoincrement": false, 1357 + "default": "''" 1358 + }, 1359 + "summary": { 1360 + "name": "summary", 1361 + "type": "text", 1362 + "primaryKey": false, 1363 + "notNull": true, 1364 + "autoincrement": false, 1365 + "default": "''" 1366 + }, 1367 + "status": { 1368 + "name": "status", 1369 + "type": "text", 1370 + "primaryKey": false, 1371 + "notNull": true, 1372 + "autoincrement": false, 1373 + "default": "'triage'" 1374 + }, 1375 + "monitor_id": { 1376 + "name": "monitor_id", 1377 + "type": "integer", 1378 + "primaryKey": false, 1379 + "notNull": false, 1380 + "autoincrement": false 1381 + }, 1382 + "workspace_id": { 1383 + "name": "workspace_id", 1384 + "type": "integer", 1385 + "primaryKey": false, 1386 + "notNull": false, 1387 + "autoincrement": false 1388 + }, 1389 + "started_at": { 1390 + "name": "started_at", 1391 + "type": "integer", 1392 + "primaryKey": false, 1393 + "notNull": true, 1394 + "autoincrement": false, 1395 + "default": "(strftime('%s', 'now'))" 1396 + }, 1397 + "acknowledged_at": { 1398 + "name": "acknowledged_at", 1399 + "type": "integer", 1400 + "primaryKey": false, 1401 + "notNull": false, 1402 + "autoincrement": false 1403 + }, 1404 + "acknowledged_by": { 1405 + "name": "acknowledged_by", 1406 + "type": "integer", 1407 + "primaryKey": false, 1408 + "notNull": false, 1409 + "autoincrement": false 1410 + }, 1411 + "resolved_at": { 1412 + "name": "resolved_at", 1413 + "type": "integer", 1414 + "primaryKey": false, 1415 + "notNull": false, 1416 + "autoincrement": false 1417 + }, 1418 + "resolved_by": { 1419 + "name": "resolved_by", 1420 + "type": "integer", 1421 + "primaryKey": false, 1422 + "notNull": false, 1423 + "autoincrement": false 1424 + }, 1425 + "incident_screenshot_url": { 1426 + "name": "incident_screenshot_url", 1427 + "type": "text", 1428 + "primaryKey": false, 1429 + "notNull": false, 1430 + "autoincrement": false 1431 + }, 1432 + "recovery_screenshot_url": { 1433 + "name": "recovery_screenshot_url", 1434 + "type": "text", 1435 + "primaryKey": false, 1436 + "notNull": false, 1437 + "autoincrement": false 1438 + }, 1439 + "auto_resolved": { 1440 + "name": "auto_resolved", 1441 + "type": "integer", 1442 + "primaryKey": false, 1443 + "notNull": false, 1444 + "autoincrement": false, 1445 + "default": false 1446 + }, 1447 + "created_at": { 1448 + "name": "created_at", 1449 + "type": "integer", 1450 + "primaryKey": false, 1451 + "notNull": false, 1452 + "autoincrement": false, 1453 + "default": "(strftime('%s', 'now'))" 1454 + }, 1455 + "updated_at": { 1456 + "name": "updated_at", 1457 + "type": "integer", 1458 + "primaryKey": false, 1459 + "notNull": false, 1460 + "autoincrement": false, 1461 + "default": "(strftime('%s', 'now'))" 1462 + } 1463 + }, 1464 + "indexes": { 1465 + "incident_monitor_id_started_at_unique": { 1466 + "name": "incident_monitor_id_started_at_unique", 1467 + "columns": [ 1468 + "monitor_id", 1469 + "started_at" 1470 + ], 1471 + "isUnique": true 1472 + } 1473 + }, 1474 + "foreignKeys": { 1475 + "incident_monitor_id_monitor_id_fk": { 1476 + "name": "incident_monitor_id_monitor_id_fk", 1477 + "tableFrom": "incident", 1478 + "tableTo": "monitor", 1479 + "columnsFrom": [ 1480 + "monitor_id" 1481 + ], 1482 + "columnsTo": [ 1483 + "id" 1484 + ], 1485 + "onDelete": "set default", 1486 + "onUpdate": "no action" 1487 + }, 1488 + "incident_workspace_id_workspace_id_fk": { 1489 + "name": "incident_workspace_id_workspace_id_fk", 1490 + "tableFrom": "incident", 1491 + "tableTo": "workspace", 1492 + "columnsFrom": [ 1493 + "workspace_id" 1494 + ], 1495 + "columnsTo": [ 1496 + "id" 1497 + ], 1498 + "onDelete": "no action", 1499 + "onUpdate": "no action" 1500 + }, 1501 + "incident_acknowledged_by_user_id_fk": { 1502 + "name": "incident_acknowledged_by_user_id_fk", 1503 + "tableFrom": "incident", 1504 + "tableTo": "user", 1505 + "columnsFrom": [ 1506 + "acknowledged_by" 1507 + ], 1508 + "columnsTo": [ 1509 + "id" 1510 + ], 1511 + "onDelete": "no action", 1512 + "onUpdate": "no action" 1513 + }, 1514 + "incident_resolved_by_user_id_fk": { 1515 + "name": "incident_resolved_by_user_id_fk", 1516 + "tableFrom": "incident", 1517 + "tableTo": "user", 1518 + "columnsFrom": [ 1519 + "resolved_by" 1520 + ], 1521 + "columnsTo": [ 1522 + "id" 1523 + ], 1524 + "onDelete": "no action", 1525 + "onUpdate": "no action" 1526 + } 1527 + }, 1528 + "compositePrimaryKeys": {}, 1529 + "uniqueConstraints": {} 1530 + }, 1531 + "monitor_tag": { 1532 + "name": "monitor_tag", 1533 + "columns": { 1534 + "id": { 1535 + "name": "id", 1536 + "type": "integer", 1537 + "primaryKey": true, 1538 + "notNull": true, 1539 + "autoincrement": false 1540 + }, 1541 + "workspace_id": { 1542 + "name": "workspace_id", 1543 + "type": "integer", 1544 + "primaryKey": false, 1545 + "notNull": true, 1546 + "autoincrement": false 1547 + }, 1548 + "name": { 1549 + "name": "name", 1550 + "type": "text", 1551 + "primaryKey": false, 1552 + "notNull": true, 1553 + "autoincrement": false 1554 + }, 1555 + "color": { 1556 + "name": "color", 1557 + "type": "text", 1558 + "primaryKey": false, 1559 + "notNull": true, 1560 + "autoincrement": false 1561 + }, 1562 + "created_at": { 1563 + "name": "created_at", 1564 + "type": "integer", 1565 + "primaryKey": false, 1566 + "notNull": false, 1567 + "autoincrement": false, 1568 + "default": "(strftime('%s', 'now'))" 1569 + }, 1570 + "updated_at": { 1571 + "name": "updated_at", 1572 + "type": "integer", 1573 + "primaryKey": false, 1574 + "notNull": false, 1575 + "autoincrement": false, 1576 + "default": "(strftime('%s', 'now'))" 1577 + } 1578 + }, 1579 + "indexes": {}, 1580 + "foreignKeys": { 1581 + "monitor_tag_workspace_id_workspace_id_fk": { 1582 + "name": "monitor_tag_workspace_id_workspace_id_fk", 1583 + "tableFrom": "monitor_tag", 1584 + "tableTo": "workspace", 1585 + "columnsFrom": [ 1586 + "workspace_id" 1587 + ], 1588 + "columnsTo": [ 1589 + "id" 1590 + ], 1591 + "onDelete": "cascade", 1592 + "onUpdate": "no action" 1593 + } 1594 + }, 1595 + "compositePrimaryKeys": {}, 1596 + "uniqueConstraints": {} 1597 + }, 1598 + "monitor_tag_to_monitor": { 1599 + "name": "monitor_tag_to_monitor", 1600 + "columns": { 1601 + "monitor_id": { 1602 + "name": "monitor_id", 1603 + "type": "integer", 1604 + "primaryKey": false, 1605 + "notNull": true, 1606 + "autoincrement": false 1607 + }, 1608 + "monitor_tag_id": { 1609 + "name": "monitor_tag_id", 1610 + "type": "integer", 1611 + "primaryKey": false, 1612 + "notNull": true, 1613 + "autoincrement": false 1614 + }, 1615 + "created_at": { 1616 + "name": "created_at", 1617 + "type": "integer", 1618 + "primaryKey": false, 1619 + "notNull": false, 1620 + "autoincrement": false, 1621 + "default": "(strftime('%s', 'now'))" 1622 + } 1623 + }, 1624 + "indexes": {}, 1625 + "foreignKeys": { 1626 + "monitor_tag_to_monitor_monitor_id_monitor_id_fk": { 1627 + "name": "monitor_tag_to_monitor_monitor_id_monitor_id_fk", 1628 + "tableFrom": "monitor_tag_to_monitor", 1629 + "tableTo": "monitor", 1630 + "columnsFrom": [ 1631 + "monitor_id" 1632 + ], 1633 + "columnsTo": [ 1634 + "id" 1635 + ], 1636 + "onDelete": "cascade", 1637 + "onUpdate": "no action" 1638 + }, 1639 + "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk": { 1640 + "name": "monitor_tag_to_monitor_monitor_tag_id_monitor_tag_id_fk", 1641 + "tableFrom": "monitor_tag_to_monitor", 1642 + "tableTo": "monitor_tag", 1643 + "columnsFrom": [ 1644 + "monitor_tag_id" 1645 + ], 1646 + "columnsTo": [ 1647 + "id" 1648 + ], 1649 + "onDelete": "cascade", 1650 + "onUpdate": "no action" 1651 + } 1652 + }, 1653 + "compositePrimaryKeys": { 1654 + "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk": { 1655 + "columns": [ 1656 + "monitor_id", 1657 + "monitor_tag_id" 1658 + ], 1659 + "name": "monitor_tag_to_monitor_monitor_id_monitor_tag_id_pk" 1660 + } 1661 + }, 1662 + "uniqueConstraints": {} 1663 + } 1664 + }, 1665 + "enums": {}, 1666 + "_meta": { 1667 + "schemas": {}, 1668 + "tables": {}, 1669 + "columns": {} 1670 + } 1671 + }
+7
packages/db/drizzle/meta/_journal.json
··· 190 190 "when": 1713384976187, 191 191 "tag": "0026_giant_absorbing_man", 192 192 "breakpoints": true 193 + }, 194 + { 195 + "idx": 27, 196 + "version": "5", 197 + "when": 1714586658374, 198 + "tag": "0027_bizarre_bastion", 199 + "breakpoints": true 193 200 } 194 201 ] 195 202 }
+6
packages/db/src/schema/pages/page.ts
··· 19 19 customDomain: text("custom_domain", { length: 256 }).notNull(), 20 20 published: integer("published", { mode: "boolean" }).default(false), 21 21 22 + // Password protecting the status page - no specific restriction on password 23 + password: text("password", { length: 256 }), 24 + passwordProtected: integer("password_protected", { mode: "boolean" }).default( 25 + false, 26 + ), 27 + 22 28 createdAt: integer("created_at", { mode: "timestamp" }).default( 23 29 sql`(strftime('%s', 'now'))`, 24 30 ),
+4 -1
packages/db/src/schema/pages/validation.ts
··· 25 25 icon: z.string().optional(), 26 26 slug: slugSchema, 27 27 }).extend({ 28 + password: z.string().nullable().optional().default(""), 28 29 monitors: z.array(z.number()).optional().default([]), 29 30 }); 30 31 31 - export const selectPageSchema = createSelectSchema(page); 32 + export const selectPageSchema = createSelectSchema(page).extend({ 33 + password: z.string().optional().nullable().default(""), 34 + }); 32 35 33 36 export type InsertPage = z.infer<typeof insertPageSchema>; 34 37 export type Page = z.infer<typeof selectPageSchema>;
+10
packages/db/src/schema/shared.ts
··· 35 35 statusReports: z.array(selectStatusReportPageSchema), 36 36 }); 37 37 38 + export const selectPageSchemaWithMonitorsRelation = selectPageSchema.extend({ 39 + monitorsToPages: z.array( 40 + z.object({ 41 + monitorId: z.number(), 42 + pageId: z.number(), 43 + monitor: selectMonitorSchema, 44 + }), 45 + ), 46 + }); 47 + 38 48 export const selectPublicPageSchemaWithRelation = selectPageSchema 39 49 .extend({ 40 50 monitors: z.array(selectPublicMonitorSchema),
+10
packages/db/src/schema/workspaces/constants.ts
··· 1 1 export const workspacePlans = ["free", "starter", "team", "pro"] as const; 2 2 export const workspaceRole = ["owner", "admin", "member"] as const; 3 + 4 + export const workspacePlanHierarchy: Record< 5 + (typeof workspacePlans)[number], 6 + number 7 + > = { 8 + free: 0, 9 + starter: 1, 10 + team: 2, 11 + pro: 3, 12 + };
+4
packages/plans/src/config.ts
··· 24 24 "status-pages": 1, 25 25 "status-subscribers": false, 26 26 "custom-domain": false, 27 + "password-protection": false, 27 28 "white-label": false, 28 29 notifications: true, 29 30 sms: false, ··· 44 45 "status-pages": 1, 45 46 "status-subscribers": true, 46 47 "custom-domain": true, 48 + "password-protection": true, 47 49 "white-label": false, 48 50 notifications: true, 49 51 sms: true, ··· 64 66 "status-pages": 5, 65 67 "status-subscribers": true, 66 68 "custom-domain": true, 69 + "password-protection": true, 67 70 "white-label": false, 68 71 notifications: true, 69 72 sms: true, ··· 84 87 "status-pages": 20, 85 88 "status-subscribers": true, 86 89 "custom-domain": true, 90 + "password-protection": true, 87 91 "white-label": true, 88 92 notifications: true, 89 93 sms: true,
+4
packages/plans/src/pricing-table.ts
··· 41 41 label: "Custom domain", 42 42 }, 43 43 { 44 + value: "password-protection", 45 + label: "Password-protected", 46 + }, 47 + { 44 48 value: "white-label", 45 49 label: "White Label", 46 50 },
+1
packages/plans/src/types.ts
··· 10 10 "status-pages": number; 11 11 "status-subscribers": boolean; 12 12 "custom-domain": boolean; 13 + "password-protection": boolean; 13 14 "white-label": boolean; 14 15 // alerts 15 16 notifications: boolean;