diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/key-created-success-dialog.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/key-created-success-dialog.tsx index a554a001c2..0a956d2bb3 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/key-created-success-dialog.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/key-created-success-dialog.tsx @@ -2,12 +2,12 @@ import { ConfirmPopover } from "@/components/confirmation-popover"; import { Dialog, DialogContent } from "@/components/ui/dialog"; -import { ArrowRight, Check, CircleInfo, Key2, Plus } from "@unkey/icons"; -import { Button, Code, CopyButton, InfoTooltip, VisibleButton, toast } from "@unkey/ui"; +import { ArrowRight, Check, Key2, Plus } from "@unkey/icons"; +import { Button, InfoTooltip, toast } from "@unkey/ui"; import { useRouter } from "next/navigation"; import { useEffect, useRef, useState } from "react"; import { UNNAMED_KEY } from "../create-key.constants"; -import { SecretKey } from "./secret-key"; +import { KeySecretSection } from "./key-secret-section"; export const KeyCreatedSuccessDialog = ({ isOpen, @@ -24,7 +24,6 @@ export const KeyCreatedSuccessDialog = ({ keyspaceId?: string | null; onCreateAnother?: () => void; }) => { - const [showKeyInSnippet, setShowKeyInSnippet] = useState(false); const [isConfirmOpen, setIsConfirmOpen] = useState(false); const [pendingAction, setPendingAction] = useState< "close" | "create-another" | "go-to-details" | null @@ -53,21 +52,6 @@ export const KeyCreatedSuccessDialog = ({ return null; } - const split = keyData.key.split("_") ?? []; - const maskedKey = - split.length >= 2 - ? `${split.at(0)}_${"*".repeat(split.at(1)?.length ?? 0)}` - : "*".repeat(split.at(0)?.length ?? 0); - - const snippet = `curl -XPOST '${ - process.env.NEXT_PUBLIC_UNKEY_API_URL ?? "https://api.unkey.dev" - }/v1/keys.verifyKey' \\ - -H 'Content-Type: application/json' \\ - -d '{ - "key": "${keyData.key}", - "apiId": "${apiId}" - }'`; - const handleCloseAttempt = (action: "close" | "create-another" | "go-to-details" = "close") => { setPendingAction(action); setIsConfirmOpen(true); @@ -199,36 +183,12 @@ export const KeyCreatedSuccessDialog = ({ -
-
Key Secret
- -
- - - Copy and save this key secret as it won't be shown again.{" "} - - Learn more - - -
-
-
-
Try It Out
- - - } - copyButton={} - > - {showKeyInSnippet ? snippet : snippet.replace(keyData.key, maskedKey)} - -
+
All set! You can now create another key or explore the docs to learn more diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/key-secret-section.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/key-secret-section.tsx new file mode 100644 index 0000000000..f24fa9d1a4 --- /dev/null +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/key-secret-section.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { SecretKey } from "@/app/(app)/apis/[apiId]/_components/create-key/components/secret-key"; +import { CircleInfo } from "@unkey/icons"; +import { Code, CopyButton, VisibleButton } from "@unkey/ui"; +import { useState } from "react"; + +type KeySecretSectionProps = { + keyValue: string; + apiId: string; + className?: string; + secretKeyClassName?: string; + codeClassName?: string; +}; + +export const KeySecretSection = ({ + keyValue, + apiId, + className, + secretKeyClassName = "bg-white dark:bg-black", + codeClassName, +}: KeySecretSectionProps) => { + const [showKeyInSnippet, setShowKeyInSnippet] = useState(false); + + const split = keyValue.split("_") ?? []; + const maskedKey = + split.length >= 2 + ? `${split.at(0)}_${"*".repeat(split.at(1)?.length ?? 0)}` + : "*".repeat(split.at(0)?.length ?? 0); + + const snippet = `curl -XPOST '${ + process.env.NEXT_PUBLIC_UNKEY_API_URL ?? "https://api.unkey.dev" + }/v1/keys.verifyKey' \\ + -H 'Content-Type: application/json' \\ + -d '{ + "key": "${keyValue}", + "apiId": "${apiId}" + }'`; + + return ( +
+
+
Key Secret
+ +
+ + + Copy and save this key secret as it won't be shown again.{" "} + + Learn more + + +
+
+
+
Try It Out
+ + } + copyButton={} + > + {showKeyInSnippet ? snippet : snippet.replace(keyValue, maskedKey)} + +
+
+ ); +}; diff --git a/apps/dashboard/app/new-2/constants.ts b/apps/dashboard/app/new-2/constants.ts deleted file mode 100644 index cac7355520..0000000000 --- a/apps/dashboard/app/new-2/constants.ts +++ /dev/null @@ -1,21 +0,0 @@ -export type StepInfo = { - title: string; - description: string; -}; - -export const stepInfos: StepInfo[] = [ - { - title: "Create company workspace", - description: - "Customize your workspace name, logo, and handle. This is how it’ll appear in your dashboard and URLs.", - }, - { - title: "Create your first API key", - description: - "Generate a key for your public API. You’ll be able to verify, revoke, and track usage — all globally distributed with built-in analytics.", - }, - { - title: "Configure your dashboard", - description: "Customize your dashboard settings and invite team members to collaborate.", - }, -]; diff --git a/apps/dashboard/app/new-2/page.tsx b/apps/dashboard/app/new-2/page.tsx deleted file mode 100644 index 7e85b42b1e..0000000000 --- a/apps/dashboard/app/new-2/page.tsx +++ /dev/null @@ -1,146 +0,0 @@ -"use client"; -import { StackPerspective2 } from "@unkey/icons"; -import { FormInput } from "@unkey/ui"; -import { Suspense, useState } from "react"; -import { type OnboardingStep, OnboardingWizard } from "./components/onboarding-wizard"; -import { stepInfos } from "./constants"; -import { useKeyCreationStep } from "./hooks/use-key-creation-step"; -import { useWorkspaceStep } from "./hooks/use-workspace-step"; - -export default function OnboardingPage() { - return ( - }> - - - ); -} - -function OnboardingFallback() { - return ( -
- {/* Unkey Logo */} -
Unkey
- {/* Spacer */} -
- {/* Static content while loading */} -
-
-
- - Step 1 of 3 - -
-
-
- Create workspace -
-
-
- Set up your workspace to get started with Unkey -
-
-
-
- , - body: ( -
-
-
- -
-
-
- ), - kind: "required" as const, - validFieldCount: 0, - requiredFieldCount: 1, - buttonText: "Continue", - description: "Set up your workspace to get started", - onStepNext: () => {}, - onStepBack: () => {}, - }, - ]} - onComplete={() => {}} - onStepChange={() => {}} - /> -
-
-
- ); -} - -function OnboardingContent() { - const [currentStepIndex, setCurrentStepIndex] = useState(0); - const workspaceStep = useWorkspaceStep(); - const keyCreationStep = useKeyCreationStep(); - - const steps: OnboardingStep[] = [ - workspaceStep, - keyCreationStep, - { - name: "Dashboard", - icon: , - body:
Dashboard setup content
, - kind: "non-required" as const, - description: "Next: you'll create your first API key", - buttonText: "Continue", - }, - ]; - - const handleComplete = () => { - console.info("Onboarding completed!"); - }; - - const handleStepChange = (newStepIndex: number) => { - setCurrentStepIndex(newStepIndex); - }; - - const currentStepInfo = stepInfos[currentStepIndex]; - - return ( -
- {/* Unkey Logo */} -
Unkey
- {/* Spacer */} -
- {/* Onboarding part. This will be a step wizard*/} -
- {/* Explanation part - Fixed height to prevent layout shifts */} -
-
- - Step {currentStepIndex + 1} of {steps.length} - -
-
-
- {currentStepInfo.title} -
-
-
- {currentStepInfo.description} -
-
-
- {/* Form part */} -
- -
-
-
- ); -} diff --git a/apps/dashboard/app/new-2/components/circle-progress.tsx b/apps/dashboard/app/new/components/circle-progress.tsx similarity index 100% rename from apps/dashboard/app/new-2/components/circle-progress.tsx rename to apps/dashboard/app/new/components/circle-progress.tsx diff --git a/apps/dashboard/app/new-2/components/expandable-settings.tsx b/apps/dashboard/app/new/components/expandable-settings.tsx similarity index 86% rename from apps/dashboard/app/new-2/components/expandable-settings.tsx rename to apps/dashboard/app/new/components/expandable-settings.tsx index 8f98330114..25cb28563d 100644 --- a/apps/dashboard/app/new-2/components/expandable-settings.tsx +++ b/apps/dashboard/app/new/components/expandable-settings.tsx @@ -1,4 +1,6 @@ +"use client"; import { Switch } from "@/components/ui/switch"; + import { CircleInfo } from "@unkey/icons"; import { InfoTooltip } from "@unkey/ui"; import { useState } from "react"; @@ -12,6 +14,7 @@ type ExpandableSettingsProps = { defaultChecked?: boolean; onCheckedChange?: (checked: boolean) => void; disabled?: boolean; + disabledTooltip?: string; }; export const ExpandableSettings = ({ @@ -22,6 +25,7 @@ export const ExpandableSettings = ({ defaultChecked = false, onCheckedChange, disabled = false, + disabledTooltip = "You need to have a valid API name", }: ExpandableSettingsProps) => { const [isEnabled, setIsEnabled] = useState(defaultChecked); @@ -32,13 +36,19 @@ export const ExpandableSettings = ({ setIsEnabled(checked); onCheckedChange?.(checked); }; + const handleSwitchClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + }; - const handleHeaderClick = () => { + const handleHeaderClick = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); handleCheckedChange(!isEnabled); }; return ( - +
{/* Header */} -
- -
- - -
- ); -}; diff --git a/apps/dashboard/app/new/create-ratelimit.tsx b/apps/dashboard/app/new/create-ratelimit.tsx deleted file mode 100644 index cfdb818527..0000000000 --- a/apps/dashboard/app/new/create-ratelimit.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { getCurrentUser } from "@/lib/auth"; -import { router } from "@/lib/trpc/routers"; -import { createCallerFactory } from "@trpc/server"; -import type { Workspace } from "@unkey/db"; -import { Button, Code, CopyButton } from "@unkey/ui"; -import { GlobeLock } from "lucide-react"; -import Link from "next/link"; - -type Props = { - workspace: Workspace; -}; - -export const CreateRatelimit: React.FC = async (props) => { - const user = await getCurrentUser(); - - // make typescript happy - if (!user || !user.orgId || !user.role) { - return null; - } - - const trpc = createCallerFactory()(router)({ - // biome-ignore lint/suspicious/noExplicitAny: tRPC context req object is not used in server-side calls, empty object is acceptable - req: {} as any, - user: { - id: user.id, - }, - workspace: props.workspace, - tenant: { - id: user.orgId, - role: user.role, - }, - audit: { - location: "", - userAgent: "", - }, - }); - - const rootKey = await trpc.rootKey.create({ - name: "onboarding", - permissions: ["ratelimit.*.create_namespace", "ratelimit.*.limit"], - }); - - const snippet = `curl -XPOST 'https://api.unkey.dev/v1/ratelimits.limit' \\ - -H 'Content-Type: application/json' \\ - -H 'Authorization: Bearer ${rootKey.key}' \\ - -d '{ - "namespace": "hello-ratelimit", - "identifier": "${user?.email ?? "hello"}", - "limit": 10, - "duration": 10000 - }'`; - function AsideContent() { - return ( -
-
- -
-

What is Unkey ratelimiting?

-

- Global low latency ratelimiting for your application. -

-
    -
  1. Low latency
  2. -
  3. Globally consistent
  4. -
  5. Powerful analytics
  6. -
-
- ); - } - return ( -
-
- - -
-

- Try this curl command and limit your first request, you can use a different namespace or - identifier if you want. -

-

- The following request will limit the user to 10 requests per 10 seconds. -

- - } - > - {snippet} - -
- - - - -
- -
- ); -}; diff --git a/apps/dashboard/app/new/create-workspace.tsx b/apps/dashboard/app/new/create-workspace.tsx deleted file mode 100644 index f544052686..0000000000 --- a/apps/dashboard/app/new/create-workspace.tsx +++ /dev/null @@ -1,148 +0,0 @@ -"use client"; - -import { setCookie } from "@/lib/auth/cookies"; -import { UNKEY_SESSION_COOKIE } from "@/lib/auth/types"; -import { trpc } from "@/lib/trpc/client"; -import { zodResolver } from "@hookform/resolvers/zod"; -import { Button, FormInput, toast } from "@unkey/ui"; -import { Box } from "lucide-react"; -import { useRouter } from "next/navigation"; -import { useRef, useTransition } from "react"; -import { Controller, useForm } from "react-hook-form"; -import { z } from "zod"; - -const formSchema = z.object({ - name: z.string().trim().min(3, "Name is required and should be at least 3 characters").max(50), -}); - -export const CreateWorkspace: React.FC = () => { - const { - handleSubmit, - control, - formState: { errors, isValid }, - } = useForm>({ - resolver: zodResolver(formSchema), - }); - - const router = useRouter(); - const [isPending, startTransition] = useTransition(); - const workspaceIdRef = useRef(null); - - const switchOrgMutation = trpc.user.switchOrg.useMutation({ - onSuccess: async (sessionData) => { - if (!sessionData.expiresAt) { - console.error("Missing session data: ", sessionData); - toast.error(`Failed to switch organizations: ${sessionData.error}`); - return; - } - - await setCookie({ - name: UNKEY_SESSION_COOKIE, - value: sessionData.token, - options: { - httpOnly: true, - secure: true, - sameSite: "strict", - path: "/", - maxAge: Math.floor((sessionData.expiresAt.getTime() - Date.now()) / 1000), - }, - }).then(() => { - startTransition(() => { - router.push(`/new?workspaceId=${workspaceIdRef.current}`); - }); - }); - }, - onError: (error) => { - toast.error(`Failed to load new workspace: ${error.message}`); - }, - }); - - const createWorkspace = trpc.workspace.create.useMutation({ - onSuccess: async ({ workspace, organizationId }) => { - workspaceIdRef.current = workspace.id; - switchOrgMutation.mutate(organizationId); - }, - onError: (error) => { - if (error.data?.code === "METHOD_NOT_SUPPORTED") { - toast.error("", { - style: { - display: "flex", - flexDirection: "column", - }, - duration: 20000, - description: error.message, - action: ( -
- -
- ), - }); - } else { - toast.error(`Failed to create workspace: ${error.message}`); - } - }, - }); - - function AsideContent() { - return ( -
-
- -
-

What is a workspace?

-

- A workspace groups all your resources and billing. You can create free workspaces for - individual use, or upgrade to a paid workspace to collaborate with team members. -

-
- ); - } - - return ( -
-
-
createWorkspace.mutate({ ...values }))} - className="flex flex-col space-y-4" - > - ( -
-
Name
- -
- )} - /> - -
- -
- -
- -
- ); -}; diff --git a/apps/dashboard/app/new-2/hooks/use-key-creation-step.tsx b/apps/dashboard/app/new/hooks/use-key-creation-step.tsx similarity index 78% rename from apps/dashboard/app/new-2/hooks/use-key-creation-step.tsx rename to apps/dashboard/app/new/hooks/use-key-creation-step.tsx index 2773539e21..0ffde675c1 100644 --- a/apps/dashboard/app/new-2/hooks/use-key-creation-step.tsx +++ b/apps/dashboard/app/new/hooks/use-key-creation-step.tsx @@ -19,12 +19,13 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { CalendarClock, ChartPie, Code, Gauge, Key2, StackPerspective2 } from "@unkey/icons"; import { FormInput, toast } from "@unkey/ui"; import { addDays } from "date-fns"; -import { useSearchParams } from "next/navigation"; -import { useRef } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useRef, useState, useTransition } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { z } from "zod"; import { ExpandableSettings } from "../components/expandable-settings"; import type { OnboardingStep } from "../components/onboarding-wizard"; +import { API_ID_PARAM, KEY_PARAM } from "../constants"; const extendedFormSchema = formSchema.and( z.object({ @@ -37,15 +38,21 @@ const extendedFormSchema = formSchema.and( ); export const useKeyCreationStep = (): OnboardingStep => { + const [apiCreated, setApiCreated] = useState(false); const formRef = useRef(null); + const router = useRouter(); const searchParams = useSearchParams(); - const workspaceId = searchParams?.get("workspaceId") || ""; + const [isPending, startTransition] = useTransition(); const createApiAndKey = trpc.workspace.onboarding.useMutation({ onSuccess: (data) => { - console.info("Successfully created API and key:", data); - //TODO: We'll get rid of this in the following PR - toast.success("API and key created successfully!"); + setApiCreated(true); + startTransition(() => { + const params = new URLSearchParams(searchParams?.toString()); + params.set(API_ID_PARAM, data.apiId); + params.set(KEY_PARAM, data.key); + router.push(`?${params.toString()}`); + }); }, onError: (error) => { console.error("Failed to create API and key:", error); @@ -66,7 +73,6 @@ export const useKeyCreationStep = (): OnboardingStep => { shouldUnregister: true, defaultValues: { ...getDefaultValues(), - apiName: "", }, }); @@ -78,18 +84,11 @@ export const useKeyCreationStep = (): OnboardingStep => { } = methods; const onSubmit = async (data: FormValues & { apiName: string }) => { - if (!isValidWorkspaceId) { - console.error("Invalid workspace ID in URL parameters"); - toast.error("Invalid workspace ID. Please go back and create a new workspace."); - return; - } - try { const keyInput = formValuesToApiInput(data, ""); // Empty keyAuthId since we'll create it const { keyAuthId, ...keyInputWithoutAuthId } = keyInput; // Remove keyAuthId const submitData = { - workspaceId, apiName: data.apiName, ...keyInputWithoutAuthId, }; @@ -100,14 +99,15 @@ export const useKeyCreationStep = (): OnboardingStep => { } }; - // Check if workspaceId looks like a valid workspace ID format - const isValidWorkspaceId = workspaceId && /^ws_[a-zA-Z0-9_-]+$/.test(workspaceId); - const apiNameValue = watch("apiName"); - const isFormReady = Boolean(isValidWorkspaceId && apiNameValue); - const isLoading = createApiAndKey.isLoading; + const isFormReady = Boolean(apiNameValue); + const isLoading = createApiAndKey.isLoading || isPending; - const validFieldCount = apiNameValue ? 1 : 0; + const tooltipContent = apiCreated + ? "API already created - settings cannot be modified" + : isFormReady + ? "Settings are currently disabled" + : "You need to have a valid API name"; return { name: "API key", @@ -124,7 +124,7 @@ export const useKeyCreationStep = (): OnboardingStep => { description="Choose a name for your API that helps you identify it" label="API name" className="w-full" - disabled={!isValidWorkspaceId || isLoading} + disabled={isLoading || apiCreated} />
@@ -137,7 +137,8 @@ export const useKeyCreationStep = (): OnboardingStep => {
} title="General Setup" description="Configure basic API key settings like prefix, byte length, and External ID" @@ -146,7 +147,8 @@ export const useKeyCreationStep = (): OnboardingStep => { } title="Ratelimit" description="Set request limits per time window to control API usage frequency" @@ -160,7 +162,8 @@ export const useKeyCreationStep = (): OnboardingStep => { } title="Credits" description="Set usage limits based on credits or quota to control consumption" @@ -174,7 +177,8 @@ export const useKeyCreationStep = (): OnboardingStep => { } title="Expiration" description="Set when this API key should automatically expire and become invalid" @@ -192,7 +196,8 @@ export const useKeyCreationStep = (): OnboardingStep => { } title="Metadata" description="Add custom key-value pairs to store additional information with your API key" @@ -214,24 +219,22 @@ export const useKeyCreationStep = (): OnboardingStep => {
), - kind: "required" as const, - validFieldCount, - requiredFieldCount: 1, - buttonText: isLoading ? "Creating API & Key..." : "Create API & Key", - description: "Setup your API with an initial key and advanced configurations", - onStepNext: () => { - if (!isValidWorkspaceId) { - toast.error("Invalid workspace ID. Please go back and create a new workspace."); - return; - } - if (isLoading) { - return; - } - formRef.current?.requestSubmit(); - }, - onStepBack: () => { - console.info("Going back from API key creation step"); + kind: "non-required" as const, + buttonText: apiCreated ? "Continue" : isLoading ? "Creating API & Key..." : "Create API & Key", + description: apiCreated + ? "API and key created successfully, continue to next step" + : "Setup your API with an initial key and advanced configurations", + onStepSkip: () => { + router.push("/apis"); }, + onStepNext: apiCreated + ? undefined + : () => { + if (isLoading) { + return; + } + formRef.current?.requestSubmit(); + }, isLoading, }; }; diff --git a/apps/dashboard/app/new-2/hooks/use-workspace-step.tsx b/apps/dashboard/app/new/hooks/use-workspace-step.tsx similarity index 86% rename from apps/dashboard/app/new-2/hooks/use-workspace-step.tsx rename to apps/dashboard/app/new/hooks/use-workspace-step.tsx index ea34977454..efd5941056 100644 --- a/apps/dashboard/app/new-2/hooks/use-workspace-step.tsx +++ b/apps/dashboard/app/new/hooks/use-workspace-step.tsx @@ -4,8 +4,8 @@ import { trpc } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { StackPerspective2 } from "@unkey/icons"; import { Button, FormInput, toast } from "@unkey/ui"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useRef, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import type { OnboardingStep } from "../components/onboarding-wizard"; @@ -29,17 +29,12 @@ type WorkspaceFormData = z.infer; export const useWorkspaceStep = (): OnboardingStep => { // const [isSlugGenerated, setIsSlugGenerated] = useState(false); + const [workspaceCreated, setWorkspaceCreated] = useState(false); const formRef = useRef(null); const router = useRouter(); - const searchParams = useSearchParams(); - const [isPending, startTransition] = useTransition(); - const workspaceIdRef = useRef(null); const form = useForm({ resolver: zodResolver(workspaceSchema), - defaultValues: { - workspaceName: searchParams?.get("workspaceName") || "", - }, mode: "onChange", }); @@ -61,15 +56,6 @@ export const useWorkspaceStep = (): OnboardingStep => { path: "/", maxAge: Math.floor((sessionData.expiresAt.getTime() - Date.now()) / 1000), }, - }).then(() => { - startTransition(() => { - const params = new URLSearchParams(searchParams?.toString()); - if (!workspaceIdRef.current) { - throw new Error("WorkspaceId cannot be null"); - } - params.set("workspaceId", workspaceIdRef.current); - router.push(`?${params.toString()}`); - }); }); }, onError: (error) => { @@ -78,8 +64,8 @@ export const useWorkspaceStep = (): OnboardingStep => { }); const createWorkspace = trpc.workspace.create.useMutation({ - onSuccess: async ({ workspace, organizationId }) => { - workspaceIdRef.current = workspace.id; + onSuccess: async ({ organizationId }) => { + setWorkspaceCreated(true); switchOrgMutation.mutate(organizationId); }, onError: (error) => { @@ -111,6 +97,10 @@ export const useWorkspaceStep = (): OnboardingStep => { }); const onSubmit = async (data: WorkspaceFormData) => { + if (workspaceCreated) { + // Workspace already created, just proceed + return; + } createWorkspace.mutateAsync({ name: data.workspaceName }); }; @@ -121,7 +111,7 @@ export const useWorkspaceStep = (): OnboardingStep => { return !hasError && hasValue; }).length; - const isLoading = createWorkspace.isLoading || isPending; + const isLoading = createWorkspace.isLoading; return { name: "Workspace", @@ -173,7 +163,7 @@ export const useWorkspaceStep = (): OnboardingStep => { // }} required error={form.formState.errors.workspaceName?.message} - disabled={isLoading} + disabled={isLoading || workspaceCreated} /> {/* { kind: "required" as const, validFieldCount, requiredFieldCount: 1, - buttonText: "Continue", - description: "Set up your workspace to get started", - onStepNext: () => { - if (isLoading) { - return; - } - formRef.current?.requestSubmit(); - }, + buttonText: workspaceCreated ? "Continue" : "Create workspace", + description: workspaceCreated + ? "Workspace created successfully, continue to next step" + : "Set up your workspace to get started", + onStepNext: workspaceCreated + ? undefined + : () => { + if (isLoading) { + return; + } + + formRef.current?.requestSubmit(); + }, onStepBack: () => { console.info("Going back from workspace step"); }, diff --git a/apps/dashboard/app/new/keys.tsx b/apps/dashboard/app/new/keys.tsx deleted file mode 100644 index 468f99eaff..0000000000 --- a/apps/dashboard/app/new/keys.tsx +++ /dev/null @@ -1,277 +0,0 @@ -"use client"; - -import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { trpc } from "@/lib/trpc/client"; -import { - Button, - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, - Code, - CopyButton, - Empty, - Separator, - VisibleButton, -} from "@unkey/ui"; -import { AlertCircle, KeyRound, Lock } from "lucide-react"; -import Link from "next/link"; -import { useState } from "react"; - -type Steps = - | { - step: "CREATE_ROOT_KEY"; - key?: never; - rootKey?: never; - } - | { - step: "CREATE_KEY"; - key?: never; - rootKey: string; - } - | { - step: "VERIFY_KEY"; - key?: string; - rootKey?: never; - }; - -type Props = { - apiId: string; - keyAuthId: string; -}; - -export const Keys: React.FC = ({ keyAuthId, apiId }) => { - const [step, setStep] = useState({ step: "CREATE_ROOT_KEY" }); - const rootKey = trpc.rootKey.create.useMutation({ - onSuccess(res) { - setStep({ step: "CREATE_KEY", rootKey: res.key }); - }, - }); - const key = trpc.key.create.useMutation({ - onSuccess(res) { - setStep({ step: "VERIFY_KEY", key: res.key }); - }, - }); - - const [showKey, setShowKey] = useState(false); - const [showKeyInSnippet, setShowKeyInSnippet] = useState(false); - - const createKeySnippet = `curl -XPOST '${ - process.env.NEXT_PUBLIC_UNKEY_API_URL ?? "https://api.unkey.dev" - }/v1/keys.createKey' \\ - -H 'Authorization: Bearer ${rootKey.data?.key}' \\ - -H 'Content-Type: application/json' \\ - -d '{ - "apiId": "${apiId}" - }' - `; - - const verifyKeySnippet = `curl -XPOST '${ - process.env.NEXT_PUBLIC_UNKEY_API_URL ?? "https://api.unkey.dev" - }/v1/keys.verifyKey' \\ - -H 'Content-Type: application/json' \\ - -d '{ - "key": "${key.data?.key ?? ""}", - "apiId": "${apiId}" - }' - `; - function maskKey(key: string): string { - if (key.length === 0) { - return ""; - } - - const split = key.split("_"); - - if (split.length === 1) { - const firstPart = split[0]; - if (!firstPart) { - return ""; - } - return "*".repeat(firstPart.length); - } - - const prefix = split[0]; - const suffix = split[1]; - - if (!prefix || !suffix) { - return "*".repeat(key.length); - } - - return `${prefix}_${"*".repeat(suffix.length)}`; - } - function AsideContent() { - return ( -
-
-
- -
-

Root Keys

-

- Root keys create resources such as keys or APIs on Unkey. You should never give this to - your users. -

-
-
-
- -
-

Regular Keys

-

- Regular API keys are used to authenticate your users. You can use your root key to - create regular API keys and give them to your users. -

-
-
- ); - } - return ( -
-
- - {step.step === "CREATE_ROOT_KEY" ? ( - - Let's begin by creating a root key - - - - ) : step.step === "CREATE_KEY" ? ( - - - Your root key - - - - This key is only shown once and can not be recovered - - Please store it somewhere safe for future use. - - - - - - - {showKey ? step.rootKey : maskKey(step.rootKey)} -
- - -
-
- - - -

Try it out

-

- Use your new root key to create a new API key for your users: -

- -
- {showKeyInSnippet - ? createKeySnippet - : createKeySnippet.replace(step.rootKey, maskKey(step.rootKey))} -
-
- - -
-
-
- - - - -
- ) : step.step === "VERIFY_KEY" ? ( - - - Verify a key - Use the key you created and verify it. - - - {step.key ? ( - - {showKey ? step.key : maskKey(step.key)} -
- - -
-
- ) : null} - - -
- {step.key - ? showKeyInSnippet - ? verifyKeySnippet - : verifyKeySnippet.replace(step.key, maskKey(step.key)) - : verifyKeySnippet} -
-
- {step.key ? ( - - ) : null} - -
-
-
- - - - - - - - -
- ) : null} -
- -
- ); -}; diff --git a/apps/dashboard/app/new/page.tsx b/apps/dashboard/app/new/page.tsx index 3abc868886..eeffe49d5c 100644 --- a/apps/dashboard/app/new/page.tsx +++ b/apps/dashboard/app/new/page.tsx @@ -1,210 +1,17 @@ -import { PageHeader } from "@/components/dashboard/page-header"; +"use server"; import { getAuth } from "@/lib/auth/get-auth"; -import { db } from "@/lib/db"; -import { Separator } from "@unkey/ui"; -import { Button } from "@unkey/ui"; -import { ArrowRight, GlobeLock, KeySquare } from "lucide-react"; -import Link from "next/link"; -import { notFound, redirect } from "next/navigation"; -import { CreateApi } from "./create-api"; -import { CreateRatelimit } from "./create-ratelimit"; -import { CreateWorkspace } from "./create-workspace"; -import { Keys } from "./keys"; +import { Suspense } from "react"; +import { OnboardingContent } from "./components/onboarding-content"; +import { OnboardingFallback } from "./components/onboarding-fallback"; -export const dynamic = "force-dynamic"; - -type Props = { - searchParams: { - workspaceId?: string; - apiId?: string; - ratelimitNamespaceId?: string; - product?: "keys" | "ratelimit"; - }; -}; - -export default async function (props: Props) { +export default async function OnboardingPage() { // ensure we have an authenticated user // we don't actually need any user data though await getAuth(); - const apiId = props.searchParams.apiId; - - if (apiId) { - const api = await db.query.apis.findFirst({ - where: (table, { eq }) => eq(table.id, apiId), - }); - - if (!api) { - return notFound(); - } - - if (!api.keyAuthId) { - console.error(`API ${api.id} is missing keyAuthId`); - return notFound(); // or redirect to error page - } - - return ( -
- - Skip {" "} - , - ]} - /> - - -
- ); - } - - const workspaceId = props.searchParams.workspaceId; - if (workspaceId && !props.searchParams.product) { - const workspace = await db.query.workspaces.findFirst({ - where: (table, { and, eq, isNull }) => - and(eq(table.id, workspaceId), isNull(table.deletedAtM)), - }); - - if (!workspace) { - return redirect("/new"); - } - - return ( -
- - Skip {" "} - , - ]} - /> - -
-
-
-
- -
-

I need API keys

-

- Create, verify, revoke keys for your public API. -

-
    -
  1. Globally distributed in 300+ locations
  2. -
  3. Key and API analytics
  4. -
  5. Scale to millions of requests
  6. -
-
- - - - -
-
-
-
- -
-

I want to ratelimit something

-

- Global low latency ratelimiting for your application. -

-
    -
  1. Low latency
  2. -
  3. Globally consistent
  4. -
  5. Powerful analytics
  6. -
-
- - - -
-
-
- ); - } - - if (props.searchParams.product === "keys" && workspaceId) { - const workspace = await db.query.workspaces.findFirst({ - where: (table, { and, eq, isNull }) => - and(eq(table.id, workspaceId), isNull(table.deletedAtM)), - }); - if (!workspace) { - return redirect("/new"); - } - return ( -
- - Skip {" "} - , - ]} - /> - - - - -
- ); - } - if (props.searchParams.product === "ratelimit" && workspaceId) { - const workspace = await db.query.workspaces.findFirst({ - where: (table, { and, eq, isNull }) => - and(eq(table.id, workspaceId), isNull(table.deletedAtM)), - }); - if (!workspace) { - return redirect("/new"); - } - return ( -
- - Skip {" "} - , - ]} - /> - - - - -
- ); - } return ( -
- - - -
+ }> + + ); } diff --git a/apps/dashboard/components/navigation/sidebar/app-sidebar/index.tsx b/apps/dashboard/components/navigation/sidebar/app-sidebar/index.tsx index 8a13063eb5..9b392558e1 100644 --- a/apps/dashboard/components/navigation/sidebar/app-sidebar/index.tsx +++ b/apps/dashboard/components/navigation/sidebar/app-sidebar/index.tsx @@ -79,7 +79,7 @@ export function AppSidebar({ [], ); - const { state, isMobile, toggleSidebar } = useSidebar(); + const { state, isMobile, toggleSidebar, openMobile } = useSidebar(); const isCollapsed = state === "collapsed"; const headerContent = useMemo( @@ -132,7 +132,11 @@ export function AppSidebar({ "flex-row": state === "expanded", })} > - +
diff --git a/apps/dashboard/components/navigation/sidebar/help-button.tsx b/apps/dashboard/components/navigation/sidebar/help-button.tsx index 79cc9ef9b2..e16b06c9ff 100644 --- a/apps/dashboard/components/navigation/sidebar/help-button.tsx +++ b/apps/dashboard/components/navigation/sidebar/help-button.tsx @@ -12,6 +12,7 @@ import type React from "react"; import { useFeedback } from "@/components/dashboard/feedback-component"; import { Book2, BracketsCurly, Chats, CircleCaretRight, CircleQuestion } from "@unkey/icons"; import { useState } from "react"; + export const HelpButton: React.FC = () => { const [_, openFeedback] = useFeedback(); const [open, setOpen] = useState(false); @@ -24,7 +25,7 @@ export const HelpButton: React.FC = () => { > - + diff --git a/apps/dashboard/components/navigation/sidebar/sidebar-mobile.tsx b/apps/dashboard/components/navigation/sidebar/sidebar-mobile.tsx index 9115b1bb28..4d45bc6937 100644 --- a/apps/dashboard/components/navigation/sidebar/sidebar-mobile.tsx +++ b/apps/dashboard/components/navigation/sidebar/sidebar-mobile.tsx @@ -6,8 +6,9 @@ import type { Workspace } from "@unkey/db"; import { SidebarLeftShow } from "@unkey/icons"; import { Button } from "@unkey/ui"; import { HelpButton } from "./help-button"; + export const SidebarMobile = ({ workspace }: { workspace: Workspace }) => { - const { isMobile, setOpenMobile } = useSidebar(); + const { isMobile, setOpenMobile, state, openMobile } = useSidebar(); if (!isMobile) { return null; @@ -21,7 +22,11 @@ export const SidebarMobile = ({ workspace }: { workspace: Workspace }) => {
- +
); diff --git a/apps/dashboard/components/navigation/sidebar/user-button.tsx b/apps/dashboard/components/navigation/sidebar/user-button.tsx index 46d222f894..1d513dc535 100644 --- a/apps/dashboard/components/navigation/sidebar/user-button.tsx +++ b/apps/dashboard/components/navigation/sidebar/user-button.tsx @@ -9,7 +9,6 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { useSidebar } from "@/components/ui/sidebar"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { signOut } from "@/lib/auth/utils"; import { trpc } from "@/lib/trpc/client"; @@ -18,16 +17,22 @@ import { Laptop2, MoonStars, Sun } from "@unkey/icons"; import { useTheme } from "next-themes"; import type React from "react"; -export const UserButton: React.FC = () => { - const { isMobile, state, openMobile } = useSidebar(); - const { data: user, isLoading } = trpc.user.getCurrentUser.useQuery(); +type UserButtonProps = { + isCollapsed?: boolean; + isMobile?: boolean; + isMobileSidebarOpen?: boolean; + className?: string; +}; +export const UserButton: React.FC = ({ + isCollapsed = false, + isMobile = false, + isMobileSidebarOpen = false, + className, +}) => { + const { data: user, isLoading } = trpc.user.getCurrentUser.useQuery(); const { theme, setTheme } = useTheme(); - // When mobile sidebar is open, we want to show the full component - const isCollapsed = (state === "collapsed" || isMobile) && !(isMobile && openMobile); - - // Get user display name const displayName = user?.fullName ?? user?.email ?? ""; return ( @@ -36,19 +41,19 @@ export const UserButton: React.FC = () => { className={cn( "px-2 py-1 flex hover:bg-grayA-4 rounded-lg min-w-0", isCollapsed ? "justify-center size-8 p-0" : "justify-between gap-2 flex-grow h-8", + className, )} > -
+
{user?.avatarUrl ? ( ) : null} - + {user ? (user?.fullName ?? "U").slice(0, 1).toUpperCase() : null} - {/* Show username when not collapsed OR when on mobile with sidebar open */} - {!isCollapsed || (isMobile && openMobile) ? ( + {!isCollapsed || (isMobile && isMobileSidebarOpen) ? ( isLoading ? (
) : ( @@ -59,14 +64,9 @@ export const UserButton: React.FC = () => { ) : null}
- + Theme - diff --git a/apps/dashboard/lib/trpc/routers/key/create.ts b/apps/dashboard/lib/trpc/routers/key/create.ts index 74d8e2ff84..e851d07a3c 100644 --- a/apps/dashboard/lib/trpc/routers/key/create.ts +++ b/apps/dashboard/lib/trpc/routers/key/create.ts @@ -50,8 +50,8 @@ export const createKey = t.procedure return await createKeyCore( { ...input, - storeEncryptedKeys: keyAuth.storeEncryptedKeys, keyAuthId: keyAuth.id, + storeEncryptedKeys: keyAuth.storeEncryptedKeys, }, ctx, tx, diff --git a/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts b/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts index c97b046679..f22c0e7f7a 100644 --- a/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts +++ b/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts @@ -7,7 +7,6 @@ import { createApiCore } from "../api/create"; import { createKeyCore } from "../key/create"; const createWorkspaceWithApiAndKeyInputSchema = z.object({ - workspaceId: z.string(), apiName: z .string() .min(3, "API name must be at least 3 characters") @@ -27,13 +26,15 @@ export const onboardingKeyCreation = t.procedure // Create API const apiResult = await createApiCore({ name: apiName }, ctx, tx); - // Create key using the keyAuthId from the API - const keyAuth = { - keyAuthId: apiResult.keyAuthId, - storeEncryptedKeys: false, // Default for new APIs. Can be activated by unkey with a support ticket. - }; - - const keyResult = await createKeyCore({ ...keyInput, ...keyAuth }, ctx, tx); + const keyResult = await createKeyCore( + { + ...keyInput, + keyAuthId: apiResult.keyAuthId, + storeEncryptedKeys: false, // Default for new APIs. Can be activated by unkey with a support ticket. + }, + ctx, + tx, + ); return { apiId: apiResult.id,