From 33dee4ab38d725f77f21d30c195fb2421407c037 Mon Sep 17 00:00:00 2001
From: Maximilian Kaske <56969857+mxkaske@users.noreply.github.com>
Date: Fri, 24 Jul 2026 12:31:20 +0200
Subject: [PATCH] feat: onboarding agent first no clickops card (#2424)
* feat: onboarding agent first no clickops card
* wip: review
* fix: review
* refactor: typo
---
.../(dashboard)/settings/general/layout.tsx | 2 +-
.../src/app/onboarding/_steps/step-3.tsx | 97 +++++
apps/dashboard/src/app/onboarding/page.tsx | 9 +-
.../settings/api-key/data-table.tsx | 4 +-
.../src/components/forms/api-key/dialog.tsx | 131 +++++++
.../src/components/forms/api-key/form.tsx | 259 +++++++++++++
.../forms/settings/form-api-key.tsx | 347 +-----------------
.../forms/status-report-update/sheet.tsx | 11 +-
.../components/onboarding/feature-badges.tsx | 9 +-
packages/api/src/lambda.ts | 2 +-
packages/api/src/router/apiKey.ts | 2 +-
11 files changed, 522 insertions(+), 351 deletions(-)
create mode 100644 apps/dashboard/src/components/forms/api-key/dialog.tsx
create mode 100644 apps/dashboard/src/components/forms/api-key/form.tsx
diff --git a/apps/dashboard/src/app/(dashboard)/settings/general/layout.tsx b/apps/dashboard/src/app/(dashboard)/settings/general/layout.tsx
index a0f03113..bbbd8dd1 100644
--- a/apps/dashboard/src/app/(dashboard)/settings/general/layout.tsx
+++ b/apps/dashboard/src/app/(dashboard)/settings/general/layout.tsx
@@ -19,7 +19,7 @@ export default async function Layout({
await Promise.all([
queryClient.prefetchQuery(trpc.member.list.queryOptions()),
queryClient.prefetchQuery(trpc.invitation.list.queryOptions()),
- queryClient.prefetchQuery(trpc.apiKeyRouter.getAll.queryOptions()),
+ queryClient.prefetchQuery(trpc.apiKey.list.queryOptions()),
]);
return (
diff --git a/apps/dashboard/src/app/onboarding/_steps/step-3.tsx b/apps/dashboard/src/app/onboarding/_steps/step-3.tsx
index 8b85996e..451c199b 100644
--- a/apps/dashboard/src/app/onboarding/_steps/step-3.tsx
+++ b/apps/dashboard/src/app/onboarding/_steps/step-3.tsx
@@ -1,6 +1,7 @@
"use client";
import {
+ ApiKey,
Monitor,
Next,
Notification,
@@ -11,9 +12,22 @@ import {
Invite,
} from "@openstatus/icons";
import { Button } from "@openstatus/ui/components/ui/button";
+import { useQuery } from "@tanstack/react-query";
import Link from "next/link";
import { useMemo } from "react";
+import { Code } from "@/components/common/code";
+import { Link as CommonLink } from "@/components/common/link";
+import { CreateApiKeyDialog } from "@/components/forms/api-key/dialog";
+import {
+ FormCard,
+ FormCardContent,
+ FormCardDescription,
+ FormCardFooter,
+ FormCardFooterInfo,
+ FormCardHeader,
+ FormCardTitle,
+} from "@/components/forms/form-card";
import {
OnboardingActions,
OnboardingFormColumn,
@@ -28,6 +42,7 @@ import {
FeatureBadgeWall,
QuestionPanel,
} from "@/components/onboarding/feature-badges";
+import { useTRPC } from "@/lib/trpc/client";
export function Step3({
stepperSteps,
@@ -117,6 +132,7 @@ export function Step3({
SOC2 audit incoming? Ping us for a 14-day free trial.
+
@@ -125,3 +141,84 @@ export function Step3({
>
);
}
+
+const TERRAFORM_SNIPPET = `resource "openstatus_monitor" "api" {
+ name = "api-health"
+ url = "https://api.acme.dev/health"
+ regions = ["ams", "iad", "syd"]
+}
+
+$ terraform apply # no clickops`;
+
+function NoClickopsCard() {
+ const trpc = useTRPC();
+ const { data: apiKeys } = useQuery(trpc.apiKey.list.queryOptions());
+ const hasKeys = (apiKeys?.length ?? 0) > 0;
+
+ return (
+
+
+ Agent-first, no clickops
+
+ Monitors, status pages, notifications, private locations — all
+ manageable as code. Create an API key and hand it to your agent via{" "}
+
+ MCP
+
+ , script it with the{" "}
+
+ CLI
+
+ , or commit it with{" "}
+
+ Terraform
+
+ .
+
+
+
+ {TERRAFORM_SNIPPET}
+
+
+
+ Read more in our{" "}
+
+ docs
+
+ .
+
+ {hasKeys ? (
+
+ ) : (
+
+
+
+ )}
+
+
+ );
+}
diff --git a/apps/dashboard/src/app/onboarding/page.tsx b/apps/dashboard/src/app/onboarding/page.tsx
index 2d30beb0..fbdbc731 100644
--- a/apps/dashboard/src/app/onboarding/page.tsx
+++ b/apps/dashboard/src/app/onboarding/page.tsx
@@ -2,7 +2,7 @@ import { cookies } from "next/headers";
import { redirect } from "next/navigation";
import type { SearchParams } from "nuqs";
-import { getQueryClient, trpc } from "@/lib/trpc/server";
+import { HydrateClient, getQueryClient, trpc } from "@/lib/trpc/server";
import { Client } from "./client";
import { searchParamsCache } from "./search-params";
@@ -55,6 +55,7 @@ export default async function Page({
const [monitors, pages] = await Promise.all([
queryClient.fetchQuery(trpc.monitor.list.queryOptions()),
queryClient.fetchQuery(trpc.page.list.queryOptions()),
+ queryClient.fetchQuery(trpc.apiKey.list.queryOptions()),
]);
const updates: {
@@ -89,5 +90,9 @@ export default async function Page({
redirect(`/onboarding?${next.toString()}`);
}
- return ;
+ return (
+
+
+
+ );
}
diff --git a/apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx b/apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx
index 079ac235..73c8072c 100644
--- a/apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx
+++ b/apps/dashboard/src/components/data-table/settings/api-key/data-table.tsx
@@ -14,7 +14,7 @@ import { QuickActions } from "@/components/dropdowns/quick-actions";
import { formatDate } from "@/lib/formatter";
import { useTRPC } from "@/lib/trpc/client";
-type ApiKey = RouterOutputs["apiKeyRouter"]["getAll"][number];
+type ApiKey = RouterOutputs["apiKey"]["list"][number];
/**
* Map a key's `scopes` array to a single human label. v1 keys carry one
@@ -47,7 +47,7 @@ export function DataTable({
}) {
const trpc = useTRPC();
const revokeApiKeyMutation = useMutation(
- trpc.apiKeyRouter.revoke.mutationOptions({
+ trpc.apiKey.revoke.mutationOptions({
onSuccess: () => refetch(),
}),
);
diff --git a/apps/dashboard/src/components/forms/api-key/dialog.tsx b/apps/dashboard/src/components/forms/api-key/dialog.tsx
new file mode 100644
index 00000000..25b9d442
--- /dev/null
+++ b/apps/dashboard/src/components/forms/api-key/dialog.tsx
@@ -0,0 +1,131 @@
+"use client";
+
+import { Check, Copy } from "@openstatus/icons";
+import {
+ AlertDialog,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@openstatus/ui/components/ui/alert-dialog";
+import { Button } from "@openstatus/ui/components/ui/button";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@openstatus/ui/components/ui/dialog";
+import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard";
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import { useState } from "react";
+
+import { useTRPC } from "@/lib/trpc/client";
+
+import { ApiKeyForm, type FormValues } from "./form";
+
+export function CreateApiKeyDialog({
+ children,
+ defaultScope = "read",
+}: {
+ children: React.ReactNode;
+ defaultScope?: FormValues["scope"];
+}) {
+ const trpc = useTRPC();
+ const queryClient = useQueryClient();
+ const { copy, isCopied } = useCopyToClipboard();
+ const [result, setResult] = useState<{
+ token: string;
+ key: string;
+ } | null>(null);
+ const [open, setOpen] = useState(false);
+
+ const { data: workspace } = useQuery(trpc.workspace.get.queryOptions());
+ const createApiKeyMutation = useMutation(
+ trpc.apiKey.create.mutationOptions(),
+ );
+
+ return (
+ <>
+
+ setResult(null)}>
+ {
+ event.preventDefault();
+ document.body.style.pointerEvents = "";
+ }}
+ >
+
+ API Key Created
+
+ Ensure you copy your API key before closing this dialog. You will
+ not see it again.
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/apps/dashboard/src/components/forms/api-key/form.tsx b/apps/dashboard/src/components/forms/api-key/form.tsx
new file mode 100644
index 00000000..d8db436f
--- /dev/null
+++ b/apps/dashboard/src/components/forms/api-key/form.tsx
@@ -0,0 +1,259 @@
+"use client";
+
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Calendar as CalendarIcon } from "@openstatus/icons";
+import { Button } from "@openstatus/ui/components/ui/button";
+import { Calendar } from "@openstatus/ui/components/ui/calendar";
+import { DialogFooter } from "@openstatus/ui/components/ui/dialog";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@openstatus/ui/components/ui/form";
+import { Input } from "@openstatus/ui/components/ui/input";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@openstatus/ui/components/ui/popover";
+import {
+ RadioGroup,
+ RadioGroupItem,
+} from "@openstatus/ui/components/ui/radio-group";
+import { Textarea } from "@openstatus/ui/components/ui/textarea";
+import { cn } from "@openstatus/ui/lib/utils";
+import { isTRPCClientError } from "@trpc/client";
+import { format, parse } from "date-fns";
+import { useTransition } from "react";
+import { useForm } from "react-hook-form";
+import { toast } from "sonner";
+import { z } from "zod";
+
+export const schema = z.object({
+ name: z.string().min(1, "Name is required"),
+ description: z.string().optional(),
+ expiresAt: z.string().optional(),
+ // Single-value radio. The wire format on the create-key API is
+ // an array (`scopes: Scope[]`) so per-resource scopes can land
+ // additively later, but the v1 dashboard surface only ever picks
+ // one of two options — flat enum here, lift to array on submit.
+ //
+ // Duplicates `apiKeySettableScopes` from `@openstatus/db` — the
+ // dashboard's client bundle can't reach into the db package
+ // (drizzle pulls in node-only deps via the schema barrel). The
+ // services input schema validates whatever the form sends, so
+ // drift here surfaces as a parse error on submit, not a silent
+ // mismatch. Keep this list in sync with
+ // `packages/db/src/schema/api-keys/constants.ts`.
+ scope: z.enum(["read", "write"]),
+});
+
+export type FormValues = z.infer;
+
+interface ApiKeyFormProps {
+ defaultValues?: Partial;
+ onSubmit: (values: FormValues) => Promise;
+ onCancel?: () => void;
+ submitDisabled?: boolean;
+}
+
+export function ApiKeyForm({
+ defaultValues,
+ onSubmit,
+ onCancel,
+ submitDisabled,
+}: ApiKeyFormProps) {
+ const [isPending, startTransition] = useTransition();
+
+ const form = useForm({
+ resolver: zodResolver(schema),
+ defaultValues: {
+ name: defaultValues?.name ?? "",
+ description: defaultValues?.description ?? "",
+ expiresAt: defaultValues?.expiresAt ?? "",
+ // Default Read-only: AI agents are the most common new use
+ // case for keys, and read-only is the safer starting point.
+ // CI/CD users actively pick "Read & write."
+ scope: defaultValues?.scope ?? "read",
+ },
+ });
+
+ function submitAction(values: FormValues) {
+ if (isPending) return;
+
+ startTransition(async () => {
+ try {
+ const promise = onSubmit(values);
+ toast.promise(promise, {
+ loading: "Creating...",
+ success: () => "Created",
+ error: (error) => {
+ if (isTRPCClientError(error)) {
+ return error.message;
+ }
+ return "Failed to create API key";
+ },
+ });
+ await promise;
+ form.reset();
+ } catch (error) {
+ console.error(error);
+ }
+ });
+ }
+
+ return (
+
+
+ );
+}
diff --git a/apps/dashboard/src/components/forms/settings/form-api-key.tsx b/apps/dashboard/src/components/forms/settings/form-api-key.tsx
index 694c1412..29b1662c 100644
--- a/apps/dashboard/src/components/forms/settings/form-api-key.tsx
+++ b/apps/dashboard/src/components/forms/settings/form-api-key.tsx
@@ -1,54 +1,7 @@
"use client";
-import { zodResolver } from "@hookform/resolvers/zod";
-import { Calendar as CalendarIcon, Check, Copy } from "@openstatus/icons";
-import {
- AlertDialog,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@openstatus/ui/components/ui/alert-dialog";
import { Button } from "@openstatus/ui/components/ui/button";
-import { Calendar } from "@openstatus/ui/components/ui/calendar";
-import {
- Dialog,
- DialogContent,
- DialogDescription,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- DialogTrigger,
-} from "@openstatus/ui/components/ui/dialog";
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@openstatus/ui/components/ui/form";
-import { Input } from "@openstatus/ui/components/ui/input";
-import {
- Popover,
- PopoverContent,
- PopoverTrigger,
-} from "@openstatus/ui/components/ui/popover";
-import {
- RadioGroup,
- RadioGroupItem,
-} from "@openstatus/ui/components/ui/radio-group";
-import { Textarea } from "@openstatus/ui/components/ui/textarea";
-import { useCopyToClipboard } from "@openstatus/ui/hooks/use-copy-to-clipboard";
-import { cn } from "@openstatus/ui/lib/utils";
-import { useMutation, useQuery } from "@tanstack/react-query";
-import { isTRPCClientError } from "@trpc/client";
-import { format, parse } from "date-fns";
-import { useState, useTransition } from "react";
-import { useForm } from "react-hook-form";
-import { toast } from "sonner";
-import { z } from "zod";
+import { useQuery } from "@tanstack/react-query";
import { Link } from "@/components/common/link";
import {
@@ -57,6 +10,7 @@ import {
} from "@/components/content/empty-state";
import { EmptyStateContainer } from "@/components/content/empty-state";
import { DataTable } from "@/components/data-table/settings/api-key/data-table";
+import { CreateApiKeyDialog } from "@/components/forms/api-key/dialog";
import {
FormCard,
FormCardContent,
@@ -70,99 +24,12 @@ import { useTRPC } from "@/lib/trpc/client";
// we should prefetch the api key on the server (layout)
-const schema = z.object({
- name: z.string().min(1, "Name is required"),
- description: z.string().optional(),
- expiresAt: z.string().optional(),
- // Single-value radio. The wire format on the create-key API is
- // an array (`scopes: Scope[]`) so per-resource scopes can land
- // additively later, but the v1 dashboard surface only ever picks
- // one of two options — flat enum here, lift to array on submit.
- //
- // Duplicates `apiKeySettableScopes` from `@openstatus/db` — the
- // dashboard's client bundle can't reach into the db package
- // (drizzle pulls in node-only deps via the schema barrel). The
- // services input schema validates whatever the form sends, so
- // drift here surfaces as a parse error on submit, not a silent
- // mismatch. Keep this list in sync with
- // `packages/db/src/schema/api-keys/constants.ts`.
- scope: z.enum(["read", "write"]),
-});
-
-type FormValues = z.infer;
-
export function FormApiKey() {
const trpc = useTRPC();
- const [isPending, startTransition] = useTransition();
- const { copy, isCopied } = useCopyToClipboard();
- const [result, setResult] = useState<{
- token: string;
- key: string;
- } | null>(null);
- const [createDialogOpen, setCreateDialogOpen] = useState(false);
-
- const form = useForm({
- resolver: zodResolver(schema),
- defaultValues: {
- name: "",
- description: "",
- expiresAt: "",
- // Default Read-only: AI agents are the most common new use
- // case for keys, and read-only is the safer starting point.
- // CI/CD users actively pick "Read & write."
- scope: "read",
- },
- });
-
- const { data: workspace } = useQuery(trpc.workspace.get.queryOptions());
const { data: apiKeys = [], refetch } = useQuery(
- trpc.apiKeyRouter.getAll.queryOptions(),
- );
- const createApiKeyMutation = useMutation(
- trpc.apiKeyRouter.create.mutationOptions({
- onSuccess: (data) => {
- if (data) {
- refetch();
- setResult({ token: data.token, key: data.key.name });
- setCreateDialogOpen(false);
- form.reset();
- } else {
- throw new Error("Failed to create API key");
- }
- },
- }),
+ trpc.apiKey.list.queryOptions(),
);
- function createAction(values: FormValues) {
- if (isPending || !workspace) {
- return;
- }
-
- startTransition(async () => {
- try {
- const promise = createApiKeyMutation.mutateAsync({
- name: values.name.trim(),
- description: values.description?.trim() || undefined,
- expiresAt: values.expiresAt ? new Date(values.expiresAt) : undefined,
- scopes: [values.scope],
- });
- toast.promise(promise, {
- loading: "Creating...",
- success: () => "Created",
- error: (error) => {
- if (isTRPCClientError(error)) {
- return error.message;
- }
- return "Failed to create API key";
- },
- });
- await promise;
- } catch (error) {
- console.error(error);
- }
- });
- }
-
return (
@@ -195,212 +62,10 @@ export function FormApiKey() {
.
-
+
+
+
- setResult(null)}>
- {
- event.preventDefault();
- document.body.style.pointerEvents = "";
- }}
- >
-
- API Key Created
-
- Ensure you copy your API key before closing this dialog. You will
- not see it again.
-
-
-
-
-
-
-
-
-
-
);
}
diff --git a/apps/dashboard/src/components/forms/status-report-update/sheet.tsx b/apps/dashboard/src/components/forms/status-report-update/sheet.tsx
index 6c0d104b..86928e1b 100644
--- a/apps/dashboard/src/components/forms/status-report-update/sheet.tsx
+++ b/apps/dashboard/src/components/forms/status-report-update/sheet.tsx
@@ -35,8 +35,15 @@ export function FormSheetStatusReportUpdate({
onOpenChange?: (open: boolean) => void;
}) {
const [internalOpen, setInternalOpen] = useState(false);
- const open = controlledOpen ?? internalOpen;
- const setOpen = onOpenChange ?? setInternalOpen;
+ const isControlled = controlledOpen !== undefined;
+ const open = isControlled ? controlledOpen : internalOpen;
+ const setOpen = (next: boolean) => {
+ if (isControlled) {
+ onOpenChange?.(next);
+ } else {
+ setInternalOpen(next);
+ }
+ };
return (
{children ? (
diff --git a/apps/dashboard/src/components/onboarding/feature-badges.tsx b/apps/dashboard/src/components/onboarding/feature-badges.tsx
index a2ff6f20..14be8a26 100644
--- a/apps/dashboard/src/components/onboarding/feature-badges.tsx
+++ b/apps/dashboard/src/components/onboarding/feature-badges.tsx
@@ -5,6 +5,7 @@ import {
Notification,
BookOpen,
Api,
+ Server,
FileText,
Globe,
Theme,
@@ -116,7 +117,7 @@ const FEATURE_CATEGORIES: { label: string; items: FeatureLinkItem[] }[] = [
items: [
{
name: "REST API",
- href: `${DOCS}/api-reference`,
+ href: "https://api.openstatus.dev/openapi",
icon: Api,
external: true,
},
@@ -132,6 +133,12 @@ const FEATURE_CATEGORIES: { label: string; items: FeatureLinkItem[] }[] = [
icon: ModelContextProtocolIcon,
external: true,
},
+ {
+ name: "Terraform",
+ href: "https://registry.terraform.io/providers/openstatusHQ/openstatus/latest",
+ icon: Server,
+ external: true,
+ },
],
},
{
diff --git a/packages/api/src/lambda.ts b/packages/api/src/lambda.ts
index 5a48f3dc..489a3105 100644
--- a/packages/api/src/lambda.ts
+++ b/packages/api/src/lambda.ts
@@ -9,7 +9,7 @@ import { createTRPCRouter } from "./trpc";
export const lambdaRouter = createTRPCRouter({
stripeRouter: stripeRouter,
emailRouter: emailRouter,
- apiKeyRouter: apiKeyRouter,
+ apiKey: apiKeyRouter,
integrationRouter: integrationRouter,
blob: blobRouter,
subscriberNotification: subscriberNotificationRouter,
diff --git a/packages/api/src/router/apiKey.ts b/packages/api/src/router/apiKey.ts
index c70de633..c8955117 100644
--- a/packages/api/src/router/apiKey.ts
+++ b/packages/api/src/router/apiKey.ts
@@ -41,7 +41,7 @@ export const apiKeyRouter = createTRPCRouter({
}
}),
- getAll: protectedProcedure.query(async ({ ctx }) => {
+ list: protectedProcedure.query(async ({ ctx }) => {
try {
return await listApiKeys({ ctx: toServiceCtx(ctx) });
} catch (err) {
--
2.51.2