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 ( + <> + + {children} + { + event.preventDefault(); + document.body.style.pointerEvents = ""; + }} + > + + Create API Key + + Create a new API key to access your workspace data. + + + setOpen(false)} + onSubmit={async (values) => { + const data = await createApiKeyMutation.mutateAsync({ + name: values.name.trim(), + description: values.description?.trim() || undefined, + expiresAt: values.expiresAt + ? new Date(values.expiresAt) + : undefined, + scopes: [values.scope], + }); + if (!data) { + throw new Error("Failed to create API key"); + } + queryClient.invalidateQueries({ + queryKey: trpc.apiKey.list.queryKey(), + }); + setResult({ token: data.token, key: data.key.name }); + setOpen(false); + }} + /> + + + 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 ( +
+ +
+ ( + + Name + + + + + + )} + /> + ( + + Description + +