From 00a3208f92728abb558041db023b917893c25e18 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 8 Jul 2025 17:41:56 +0300 Subject: [PATCH 01/19] fix: validation issues --- .../create-key/components/metadata-setup.tsx | 2 +- .../app/new-2/components/expandable-settings.tsx | 2 +- .../app/new-2/hooks/use-key-creation-step.tsx | 16 +++++++++++++++- apps/dashboard/app/new-2/page.tsx | 3 --- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/metadata-setup.tsx b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/metadata-setup.tsx index 954ad49d0c..2299889ff0 100644 --- a/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/metadata-setup.tsx +++ b/apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/metadata-setup.tsx @@ -6,7 +6,7 @@ import { useFormContext, useWatch } from "react-hook-form"; import type { MetadataFormValues } from "../create-key.schema"; import { ProtectionSwitch } from "./protection-switch"; -const EXAMPLE_JSON = { +export const EXAMPLE_JSON = { user: { id: "user_123456", role: "admin", diff --git a/apps/dashboard/app/new-2/components/expandable-settings.tsx b/apps/dashboard/app/new-2/components/expandable-settings.tsx index 51df2d8e5b..8f98330114 100644 --- a/apps/dashboard/app/new-2/components/expandable-settings.tsx +++ b/apps/dashboard/app/new-2/components/expandable-settings.tsx @@ -84,7 +84,7 @@ export const ExpandableSettings = ({ style={{ left: `${14 + 4}px` }} /> {/* Content */} -
+
{typeof children === "function" ? children(isEnabled) : children}
diff --git a/apps/dashboard/app/new-2/hooks/use-key-creation-step.tsx b/apps/dashboard/app/new-2/hooks/use-key-creation-step.tsx index 98dd26c36b..e143d53bff 100644 --- a/apps/dashboard/app/new-2/hooks/use-key-creation-step.tsx +++ b/apps/dashboard/app/new-2/hooks/use-key-creation-step.tsx @@ -1,7 +1,10 @@ import { UsageSetup } from "@/app/(app)/apis/[apiId]/_components/create-key/components/credits-setup"; import { ExpirationSetup } from "@/app/(app)/apis/[apiId]/_components/create-key/components/expiration-setup"; import { GeneralSetup } from "@/app/(app)/apis/[apiId]/_components/create-key/components/general-setup"; -import { MetadataSetup } from "@/app/(app)/apis/[apiId]/_components/create-key/components/metadata-setup"; +import { + EXAMPLE_JSON, + MetadataSetup, +} from "@/app/(app)/apis/[apiId]/_components/create-key/components/metadata-setup"; import { RatelimitSetup } from "@/app/(app)/apis/[apiId]/_components/create-key/components/ratelimit-setup"; import { type FormValues, @@ -11,6 +14,7 @@ import { getDefaultValues } from "@/app/(app)/apis/[apiId]/_components/create-ke import { zodResolver } from "@hookform/resolvers/zod"; import { CalendarClock, ChartPie, Code, Gauge, Key2, StackPerspective2 } from "@unkey/icons"; import { FormInput } from "@unkey/ui"; +import { addDays } from "date-fns"; import { useRef } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { z } from "zod"; @@ -118,6 +122,11 @@ export const useKeyCreationStep = (): OnboardingStep => { defaultChecked={methods.watch("expiration.enabled")} onCheckedChange={(checked) => { methods.setValue("expiration.enabled", checked); + const currentExpiryDate = methods.getValues("expiration.data"); + // Set default expiry date (1 day) when enabling if not already set + if (checked && !currentExpiryDate) { + methods.setValue("expiration.data", addDays(new Date(), 1)); + } methods.trigger("expiration"); }} > @@ -132,6 +141,11 @@ export const useKeyCreationStep = (): OnboardingStep => { defaultChecked={methods.watch("metadata.enabled")} onCheckedChange={(checked) => { methods.setValue("metadata.enabled", checked); + const currentMetadata = methods.getValues("metadata.data"); + if (checked && !currentMetadata) { + methods.setValue("metadata.data", JSON.stringify(EXAMPLE_JSON, null, 2)); + } + methods.trigger("metadata"); }} > diff --git a/apps/dashboard/app/new-2/page.tsx b/apps/dashboard/app/new-2/page.tsx index 73c0bf3f38..129d4a7795 100644 --- a/apps/dashboard/app/new-2/page.tsx +++ b/apps/dashboard/app/new-2/page.tsx @@ -4,15 +4,12 @@ import { 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() { const [currentStepIndex, setCurrentStepIndex] = useState(0); - const workspaceStep = useWorkspaceStep(); const keyCreationStep = useKeyCreationStep(); const steps: OnboardingStep[] = [ - workspaceStep, keyCreationStep, { name: "Dashboard", From 300710e4e15035dff102193f4e25b4b0b4bd4b6e Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 8 Jul 2025 18:45:58 +0300 Subject: [PATCH 02/19] feat: add tRPC endpoint --- .../app/new-2/hooks/use-key-creation-step.tsx | 134 ++++++++--- .../app/new-2/hooks/use-workspace-step.tsx | 139 ++++++----- apps/dashboard/app/new-2/page.tsx | 3 + apps/dashboard/lib/trpc/routers/api/create.ts | 128 +++++----- apps/dashboard/lib/trpc/routers/index.ts | 2 + apps/dashboard/lib/trpc/routers/key/create.ts | 220 ++++++++++-------- .../lib/trpc/routers/workspace/create.ts | 180 ++++++++------ .../lib/trpc/routers/workspace/onboarding.ts | 78 +++++++ 8 files changed, 558 insertions(+), 326 deletions(-) create mode 100644 apps/dashboard/lib/trpc/routers/workspace/onboarding.ts diff --git a/apps/dashboard/app/new-2/hooks/use-key-creation-step.tsx b/apps/dashboard/app/new-2/hooks/use-key-creation-step.tsx index e143d53bff..0b2b1d4f6f 100644 --- a/apps/dashboard/app/new-2/hooks/use-key-creation-step.tsx +++ b/apps/dashboard/app/new-2/hooks/use-key-creation-step.tsx @@ -10,32 +10,59 @@ import { type FormValues, formSchema, } from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.schema"; -import { getDefaultValues } from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.utils"; +import { + formValuesToApiInput, + getDefaultValues, +} from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.utils"; +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 { CalendarClock, ChartPie, Code, Gauge, Key2, StackPerspective2 } from "@unkey/icons"; -import { FormInput } from "@unkey/ui"; +import { FormInput, toast } from "@unkey/ui"; import { addDays } from "date-fns"; +import { useSearchParams } from "next/navigation"; import { useRef } 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"; -const apiName = z.object({ - apiName: z.string().trim().min(3, "API name must be at least 3 characters long").max(50), -}); - -const extendedFormSchema = formSchema.and(apiName); +const extendedFormSchema = formSchema.and( + z.object({ + apiName: z + .string() + .trim() + .min(3, "API name must be at least 3 characters long") + .max(50, "API name must not exceed 50 characters"), + }), +); export const useKeyCreationStep = (): OnboardingStep => { const formRef = useRef(null); + const searchParams = useSearchParams(); + const workspaceName = searchParams?.get("workspaceName") || ""; + + const createWorkspaceWithApiAndKey = trpc.workspace.onboarding.useMutation({ + onSuccess: (data) => { + console.info("Successfully created workspace, API and key:", data); + switchOrgMutation.mutate(data.organizationId); + }, + onError: (error) => { + console.error("Failed to create workspace, API and key:", error); + // Handle error - show toast notification + }, + }); const methods = useForm({ resolver: zodResolver(extendedFormSchema), mode: "onChange", shouldFocusError: true, shouldUnregister: true, - defaultValues: getDefaultValues(), + defaultValues: { + ...getDefaultValues(), + apiName: "", + }, }); const { @@ -44,16 +71,59 @@ export const useKeyCreationStep = (): OnboardingStep => { watch, formState: { errors }, } = methods; - const onSubmit = async (data: FormValues) => { - console.info("DATA", data); + + 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), + }, + }); + }, + onError: (error) => { + toast.error(`Failed to load new workspace: ${error.message}`); + }, + }); + + const onSubmit = async (data: FormValues & { apiName: string }) => { + console.info("Submitting onboarding data:", data); + + if (!workspaceName) { + console.error("Workspace name not found in URL parameters"); + return; + } + try { - } catch { - // `useCreateKey` already shows a toast, but we still need to - // prevent unhandled‐rejection noise in the console. + const keyInput = formValuesToApiInput(data, ""); // Empty keyAuthId since we'll create it + const { keyAuthId, ...keyInputWithoutAuthId } = keyInput; // Remove keyAuthId + + const submitData = { + workspaceName, + apiName: data.apiName, + ...keyInputWithoutAuthId, + }; + + await createWorkspaceWithApiAndKey.mutateAsync(submitData); + } catch (error) { + console.error("Submit error:", error); } }; const apiNameValue = watch("apiName"); + const isFormReady = Boolean(workspaceName && apiNameValue); + return { name: "API key", icon: , @@ -65,20 +135,24 @@ export const useKeyCreationStep = (): OnboardingStep => { +
+
Fine-tune your API key by enabling additional options
+
+
} title="General Setup" description="Configure basic API key settings like prefix, byte length, and External ID" @@ -87,7 +161,7 @@ export const useKeyCreationStep = (): OnboardingStep => { } title="Ratelimit" description="Set request limits per time window to control API usage frequency" @@ -101,7 +175,7 @@ export const useKeyCreationStep = (): OnboardingStep => { } title="Credits" description="Set usage limits based on credits or quota to control consumption" @@ -115,7 +189,7 @@ export const useKeyCreationStep = (): OnboardingStep => { } title="Expiration" description="Set when this API key should automatically expire and become invalid" @@ -123,7 +197,6 @@ export const useKeyCreationStep = (): OnboardingStep => { onCheckedChange={(checked) => { methods.setValue("expiration.enabled", checked); const currentExpiryDate = methods.getValues("expiration.data"); - // Set default expiry date (1 day) when enabling if not already set if (checked && !currentExpiryDate) { methods.setValue("expiration.data", addDays(new Date(), 1)); } @@ -134,7 +207,7 @@ export const useKeyCreationStep = (): OnboardingStep => { } title="Metadata" description="Add custom key-value pairs to store additional information with your API key" @@ -145,7 +218,6 @@ export const useKeyCreationStep = (): OnboardingStep => { if (checked && !currentMetadata) { methods.setValue("metadata.data", JSON.stringify(EXAMPLE_JSON, null, 2)); } - methods.trigger("metadata"); }} > @@ -158,13 +230,21 @@ export const useKeyCreationStep = (): OnboardingStep => {
), kind: "non-required" as const, - buttonText: "Continue", - description: "Setup your API key with extended configurations", + buttonText: createWorkspaceWithApiAndKey.isLoading + ? "Creating workspace..." + : workspaceName + ? "Create API & Key" + : "Go Back", + description: "Setup your API with an initial key and advanced configurations", onStepNext: () => { + if (!workspaceName) { + // Handle going back if workspace name is missing + return; + } formRef.current?.requestSubmit(); }, onStepBack: () => { - console.info("Going back from workspace step"); + console.info("Going back from API key creation step"); }, }; }; diff --git a/apps/dashboard/app/new-2/hooks/use-workspace-step.tsx b/apps/dashboard/app/new-2/hooks/use-workspace-step.tsx index daa3954b9d..4fd23a894a 100644 --- a/apps/dashboard/app/new-2/hooks/use-workspace-step.tsx +++ b/apps/dashboard/app/new-2/hooks/use-workspace-step.tsx @@ -1,7 +1,8 @@ import { zodResolver } from "@hookform/resolvers/zod"; -import { Refresh3, StackPerspective2, Trash } from "@unkey/icons"; -import { Button, FormInput } from "@unkey/ui"; -import { useRef, useState } from "react"; +import { StackPerspective2 } from "@unkey/icons"; +import { FormInput } from "@unkey/ui"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useRef } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import type { OnboardingStep } from "../components/onboarding-wizard"; @@ -12,32 +13,38 @@ const workspaceSchema = z.object({ .trim() .min(3, "Workspace name is required") .max(50, "Workspace name must be 50 characters or less"), - workspaceUrl: z - .string() - .min(3, "Workspace URL is required") - .regex( - /^[a-zA-Z0-9-_]+$/, - "URL handle can only contain letters, numbers, hyphens, and underscores", - ), + // workspaceUrl: z + // .string() + // .min(3, "Workspace URL is required") + // .regex( + // /^[a-zA-Z0-9-_]+$/, + // "URL handle can only contain letters, numbers, hyphens, and underscores" + // ), }); type WorkspaceFormData = z.infer; export const useWorkspaceStep = (): OnboardingStep => { - const [isSlugGenerated, setIsSlugGenerated] = useState(false); + // const [isSlugGenerated, setIsSlugGenerated] = useState(false); const formRef = useRef(null); + const router = useRouter(); + const searchParams = useSearchParams(); const form = useForm({ resolver: zodResolver(workspaceSchema), defaultValues: { - workspaceName: "", - workspaceUrl: "", + workspaceName: searchParams?.get("workspaceName") || "", }, mode: "onChange", }); const onSubmit = (data: WorkspaceFormData) => { console.info("Workspace form submitted:", data); + // Save workspace name to URL and proceed to next step + const params = new URLSearchParams(searchParams?.toString()); + params.set("workspaceName", data.workspaceName); + router.push(`?${params.toString()}`); + // The onboarding wizard will handle moving to the next step }; const validFieldCount = Object.keys(form.getValues()).filter((field) => { @@ -53,60 +60,66 @@ export const useWorkspaceStep = (): OnboardingStep => { body: (
-
-
-
-
Company workspace logo
-
- - - -
-
- .png, .jpg, or .svg up to 10MB, and 480×480px -
-
-
-
+ {/*
*/} + {/*
*/} + {/*
*/} + {/*
Company workspace logo
*/} + {/*
*/} + {/* */} + {/* */} + {/* */} + {/*
*/} + {/*
*/} + {/* .png, .jpg, or .svg up to 10MB, and 480×480px */} + {/*
*/} + {/*
*/} + {/*
*/} + + {/* Use this 'pt-7' version when implementing profile photo and slug based onboarding*/} + {/*
*/} +
{ - if (!isSlugGenerated) { - form.setValue("workspaceUrl", slugify(evt.currentTarget.value)); - form.trigger("workspaceUrl"); - setIsSlugGenerated(true); - } - }} + // onBlur={(evt) => { + // if (!isSlugGenerated) { + // form.setValue( + // "workspaceUrl", + // slugify(evt.currentTarget.value) + // ); + // form.trigger("workspaceUrl"); + // setIsSlugGenerated(true); + // } + // }} required error={form.formState.errors.workspaceName?.message} /> - + {/* */}
), kind: "required" as const, validFieldCount, - requiredFieldCount: 2, + requiredFieldCount: 1, buttonText: "Continue", description: "Set up your workspace to get started", onStepNext: () => { @@ -118,12 +131,12 @@ export const useWorkspaceStep = (): OnboardingStep => { }; }; -const slugify = (text: string): string => { - return text - .toLowerCase() - .trim() - .replace(/[^\w\s-]/g, "") // Remove special chars except spaces and hyphens - .replace(/\s+/g, "-") // Replace spaces with hyphens - .replace(/-+/g, "-") // Replace multiple hyphens with single - .replace(/^-|-$/g, ""); // Remove leading/trailing hyphens -}; +// const slugify = (text: string): string => { +// return text +// .toLowerCase() +// .trim() +// .replace(/[^\w\s-]/g, "") // Remove special chars except spaces and hyphens +// .replace(/\s+/g, "-") // Replace spaces with hyphens +// .replace(/-+/g, "-") // Replace multiple hyphens with single +// .replace(/^-|-$/g, ""); // Remove leading/trailing hyphens +// }; diff --git a/apps/dashboard/app/new-2/page.tsx b/apps/dashboard/app/new-2/page.tsx index 129d4a7795..73c0bf3f38 100644 --- a/apps/dashboard/app/new-2/page.tsx +++ b/apps/dashboard/app/new-2/page.tsx @@ -4,12 +4,15 @@ import { 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() { const [currentStepIndex, setCurrentStepIndex] = useState(0); + const workspaceStep = useWorkspaceStep(); const keyCreationStep = useKeyCreationStep(); const steps: OnboardingStep[] = [ + workspaceStep, keyCreationStep, { name: "Dashboard", diff --git a/apps/dashboard/lib/trpc/routers/api/create.ts b/apps/dashboard/lib/trpc/routers/api/create.ts index 31179ebcb9..2550cc76bc 100644 --- a/apps/dashboard/lib/trpc/routers/api/create.ts +++ b/apps/dashboard/lib/trpc/routers/api/create.ts @@ -1,9 +1,8 @@ -import { TRPCError } from "@trpc/server"; -import { z } from "zod"; - import { insertAuditLogs } from "@/lib/audit"; import { db, schema } from "@/lib/db"; +import { TRPCError } from "@trpc/server"; import { newId } from "@unkey/id"; +import { z } from "zod"; import { requireUser, requireWorkspace, t } from "../../trpc"; export const createApi = t.procedure @@ -18,72 +17,81 @@ export const createApi = t.procedure }), ) .mutation(async ({ input, ctx }) => { - const keyAuthId = newId("keyAuth"); try { - await db.insert(schema.keyAuth).values({ - id: keyAuthId, - workspaceId: ctx.workspace.id, - createdAtM: Date.now(), + return await db.transaction(async (tx) => { + const result = await createApiCore(input, ctx, tx); + return { id: result.id }; }); } catch (_err) { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: "We are unable to create an API. Please try again or contact support@unkey.dev", + message: "We are unable to create the API. Please try again or contact support@unkey.dev", }); } + }); - const apiId = newId("api"); +type CreateApiInput = { + name: string; +}; - await db - .transaction(async (tx) => { - await tx - .insert(schema.apis) - .values({ - id: apiId, - name: input.name, - workspaceId: ctx.workspace.id, - keyAuthId, - authType: "key", - ipWhitelist: null, - createdAtM: Date.now(), - }) - .catch((_err) => { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: - "We are unable to create the API. Please try again or contact support@unkey.dev", - }); - }); +type CreateApiContext = { + workspace: { id: string }; + user: { id: string }; + audit: { + location: string; + userAgent?: string; + }; +}; - await insertAuditLogs(tx, { - workspaceId: ctx.workspace.id, - actor: { - type: "user", - id: ctx.user.id, - }, - event: "api.create", - description: `Created ${apiId}`, - resources: [ - { - type: "api", - id: apiId, - name: input.name, - }, - ], - context: { - location: ctx.audit.location, - userAgent: ctx.audit.userAgent, - }, - }); - }) - .catch((_err) => { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: "We are unable to create the API. Please try again or contact support@unkey.dev", - }); - }); +type DatabaseTransaction = Parameters[0]>[0]; - return { - id: apiId, - }; +export async function createApiCore( + input: CreateApiInput, + ctx: CreateApiContext, + tx: DatabaseTransaction, +) { + const keyAuthId = newId("keyAuth"); + const apiId = newId("api"); + + await tx.insert(schema.keyAuth).values({ + id: keyAuthId, + workspaceId: ctx.workspace.id, + createdAtM: Date.now(), + }); + + await tx.insert(schema.apis).values({ + id: apiId, + name: input.name, + workspaceId: ctx.workspace.id, + keyAuthId, + authType: "key", + ipWhitelist: null, + createdAtM: Date.now(), }); + + await insertAuditLogs(tx, { + workspaceId: ctx.workspace.id, + actor: { + type: "user", + id: ctx.user.id, + }, + event: "api.create", + description: `Created ${apiId}`, + resources: [ + { + type: "api", + id: apiId, + name: input.name, + }, + ], + context: { + location: ctx.audit.location, + userAgent: ctx.audit.userAgent, + }, + }); + + return { + id: apiId, + keyAuthId, + }; +} diff --git a/apps/dashboard/lib/trpc/routers/index.ts b/apps/dashboard/lib/trpc/routers/index.ts index e0cf974086..81805709a1 100644 --- a/apps/dashboard/lib/trpc/routers/index.ts +++ b/apps/dashboard/lib/trpc/routers/index.ts @@ -111,6 +111,7 @@ import { getCurrentUser, listMemberships, switchOrg } from "./user"; import { vercelRouter } from "./vercel"; import { changeWorkspaceName } from "./workspace/changeName"; import { createWorkspace } from "./workspace/create"; +import { onboardingKeyCreation } from "./workspace/onboarding"; import { optWorkspaceIntoBeta } from "./workspace/optIntoBeta"; export const router = t.router({ @@ -188,6 +189,7 @@ export const router = t.router({ create: createWorkspace, updateName: changeWorkspaceName, optIntoBeta: optWorkspaceIntoBeta, + onboarding: onboardingKeyCreation, }), stripe: t.router({ createSubscription, diff --git a/apps/dashboard/lib/trpc/routers/key/create.ts b/apps/dashboard/lib/trpc/routers/key/create.ts index 960b64cce2..d01e7ae75c 100644 --- a/apps/dashboard/lib/trpc/routers/key/create.ts +++ b/apps/dashboard/lib/trpc/routers/key/create.ts @@ -1,4 +1,7 @@ -import { createKeyInputSchema } from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.schema"; +import { + type CreateKeyInput, + createKeyInputSchema, +} from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.schema"; import { insertAuditLogs } from "@/lib/audit"; import { db, schema } from "@/lib/db"; import { env } from "@/lib/env"; @@ -7,6 +10,7 @@ import { TRPCError } from "@trpc/server"; import { newId } from "@unkey/id"; import { newKey } from "@unkey/keys"; import { requireUser, requireWorkspace, t } from "../../trpc"; + const vault = new Vault({ baseUrl: env().AGENT_URL, token: env().AGENT_TOKEN, @@ -17,21 +21,14 @@ export const createKey = t.procedure .use(requireWorkspace) .input(createKeyInputSchema) .mutation(async ({ input, ctx }) => { - const keyAuth = await db.query.keyAuth - .findFirst({ - where: (table, { and, eq }) => - and(eq(table.workspaceId, ctx.workspace.id), eq(table.id, input.keyAuthId)), - with: { - api: true, - }, - }) - .catch((_err) => { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: - "We were unable to create a key for this API. Please try again or contact support@unkey.dev.", - }); - }); + const keyAuth = await db.query.keyAuth.findFirst({ + where: (table, { and, eq }) => + and(eq(table.workspaceId, ctx.workspace.id), eq(table.id, input.keyAuthId)), + with: { + api: true, + }, + }); + if (!keyAuth) { throw new TRPCError({ code: "NOT_FOUND", @@ -40,92 +37,117 @@ export const createKey = t.procedure }); } - const keyId = newId("key"); - const { key, hash, start } = await newKey({ - prefix: input.prefix, - byteLength: input.bytes, - }); - await db - .transaction(async (tx) => { - await tx.insert(schema.keys).values({ - id: keyId, - keyAuthId: keyAuth.id, - name: input.name, - hash, - start, - identityId: input.identityId, - ownerId: input.externalId, - meta: JSON.stringify(input.meta ?? {}), - workspaceId: ctx.workspace.id, - forWorkspaceId: null, - expires: input.expires ? new Date(input.expires) : null, - createdAtM: Date.now(), - updatedAtM: null, - remaining: input.remaining, - refillDay: input.refill?.refillDay ?? null, - refillAmount: input.refill?.amount ?? null, - lastRefillAt: input.refill ? new Date() : null, - enabled: input.enabled, - environment: input.environment, - }); + try { + return await db.transaction(async (tx) => { + return await createKeyCore(input, ctx, tx, keyAuth); + }); + } catch (_err) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "We are unable to create the key. Please contact support using support.unkey.dev", + }); + } + }); - if (keyAuth.storeEncryptedKeys) { - const { encrypted, keyId: encryptionKeyId } = await vault.encrypt({ - keyring: ctx.workspace.id, - data: key, - }); +type CreateKeyContext = { + workspace: { id: string }; + user: { id: string }; + audit: { + location: string; + userAgent?: string; + }; +}; - await tx.insert(schema.encryptedKeys).values({ - encrypted, - encryptionKeyId, - keyId, - workspaceId: ctx.workspace.id, - createdAt: Date.now(), - updatedAt: null, - }); - } +type KeyAuth = { + id: string; + storeEncryptedKeys: boolean; +}; - if (input.ratelimit?.length) { - await tx.insert(schema.ratelimits).values( - input.ratelimit.map((ratelimit) => ({ - id: newId("ratelimit"), - keyId, - duration: ratelimit.refillInterval, - limit: ratelimit.limit, - name: ratelimit.name, - workspaceId: ctx.workspace.id, - createdAt: Date.now(), - updatedAt: null, - autoApply: ratelimit.autoApply, - })), - ); - } +type DatabaseTransaction = Parameters[0]>[0]; - await insertAuditLogs(tx, { - workspaceId: ctx.workspace.id, - actor: { type: "user", id: ctx.user.id }, - event: "key.create", - description: `Created ${keyId}`, - resources: [ - { - type: "key", - id: keyId, - name: input.name, - }, - ], - context: { - location: ctx.audit.location, - userAgent: ctx.audit.userAgent, - }, - }); - }) - .catch((_err) => { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: - "We are unable to create the key. Please contact support using support.unkey.dev", - }); - }); +export async function createKeyCore( + input: CreateKeyInput, + ctx: CreateKeyContext, + tx: DatabaseTransaction, + keyAuth: KeyAuth, +) { + const keyId = newId("key"); + const { key, hash, start } = await newKey({ + prefix: input.prefix, + byteLength: input.bytes, + }); + + await tx.insert(schema.keys).values({ + id: keyId, + keyAuthId: keyAuth.id, + name: input.name, + hash, + start, + identityId: input.identityId, + ownerId: input.externalId, + meta: JSON.stringify(input.meta ?? {}), + workspaceId: ctx.workspace.id, + forWorkspaceId: null, + expires: input.expires ? new Date(input.expires) : null, + createdAtM: Date.now(), + updatedAtM: null, + remaining: input.remaining, + refillDay: input.refill?.refillDay ?? null, + refillAmount: input.refill?.amount ?? null, + lastRefillAt: input.refill ? new Date() : null, + enabled: input.enabled, + environment: input.environment, + }); + + if (keyAuth.storeEncryptedKeys) { + const { encrypted, keyId: encryptionKeyId } = await vault.encrypt({ + keyring: ctx.workspace.id, + data: key, + }); + + await tx.insert(schema.encryptedKeys).values({ + encrypted, + encryptionKeyId, + keyId, + workspaceId: ctx.workspace.id, + createdAt: Date.now(), + updatedAt: null, + }); + } - return { keyId, key, name: input.name }; + if (input.ratelimit?.length) { + await tx.insert(schema.ratelimits).values( + input.ratelimit.map((ratelimit) => ({ + id: newId("ratelimit"), + keyId, + duration: ratelimit.refillInterval, + limit: ratelimit.limit, + name: ratelimit.name, + workspaceId: ctx.workspace.id, + createdAt: Date.now(), + updatedAt: null, + autoApply: ratelimit.autoApply, + })), + ); + } + + await insertAuditLogs(tx, { + workspaceId: ctx.workspace.id, + actor: { type: "user", id: ctx.user.id }, + event: "key.create", + description: `Created ${keyId}`, + resources: [ + { + type: "key", + id: keyId, + name: input.name, + }, + ], + context: { + location: ctx.audit.location, + userAgent: ctx.audit.userAgent, + }, }); + + return { keyId, key, name: input.name }; +} diff --git a/apps/dashboard/lib/trpc/routers/workspace/create.ts b/apps/dashboard/lib/trpc/routers/workspace/create.ts index 8f515d8948..538102b95c 100644 --- a/apps/dashboard/lib/trpc/routers/workspace/create.ts +++ b/apps/dashboard/lib/trpc/routers/workspace/create.ts @@ -7,6 +7,7 @@ import { TRPCError } from "@trpc/server"; import { newId } from "@unkey/id"; import { z } from "zod"; import { requireUser, t } from "../../trpc"; + export const createWorkspace = t.procedure .use(requireUser) .input( @@ -15,93 +16,118 @@ export const createWorkspace = t.procedure }), ) .mutation(async ({ ctx, input }) => { - const userId = ctx.user?.id; - - if (!userId) { + try { + return await db.transaction(async (tx) => { + return await createWorkspaceCore(input, ctx, tx); + }); + } catch (error) { + if (error instanceof TRPCError) { + throw error; + } throw new TRPCError({ - code: "UNAUTHORIZED", + code: "INTERNAL_SERVER_ERROR", message: - "We are not able to authenticate the user. Please make sure you are logged in and try again", + "We are unable to create the workspace. Please try again or contact support@unkey.dev", }); } + }); - if (env().AUTH_PROVIDER === "local") { - // Check if this user already has a workspace - const existingWorkspaces = await db.query.workspaces.findMany({ - where: (workspaces, { eq }) => eq(workspaces.orgId, ctx.tenant.id), - }); +type CreateWorkspaceInput = { + name: string; +}; - if (existingWorkspaces.length > 0) { - throw new TRPCError({ - code: "METHOD_NOT_SUPPORTED", - message: - "You cannot create additional workspaces in local development mode. Use workOS auth provider if you need to test multi-workspace functionality.", - }); - } - } +type CreateWorkspaceContext = { + user: { id: string }; + tenant: { id: string }; + audit: { + location: string; + userAgent?: string; + }; +}; - const orgId = await authProvider.createTenant({ - name: input.name, - userId, +type DatabaseTransaction = Parameters[0]>[0]; + +export async function createWorkspaceCore( + input: CreateWorkspaceInput, + ctx: CreateWorkspaceContext, + tx: DatabaseTransaction, +) { + const userId = ctx.user?.id; + if (!userId) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: + "We are not able to authenticate the user. Please make sure you are logged in and try again", }); + } - const workspace: Workspace = { - id: newId("workspace"), - orgId: orgId, - name: input.name, - plan: "free", - tier: "Free", - stripeCustomerId: null, - stripeSubscriptionId: null, - features: {}, - betaFeatures: {}, - subscriptions: {}, - enabled: true, - deleteProtection: true, - createdAtM: Date.now(), - updatedAtM: null, - deletedAtM: null, - partitionId: null, - }; + if (env().AUTH_PROVIDER === "local") { + // Check if this user already has a workspace + const existingWorkspaces = await tx.query.workspaces.findMany({ + where: (workspaces, { eq }) => eq(workspaces.orgId, ctx.tenant.id), + }); + if (existingWorkspaces.length > 0) { + throw new TRPCError({ + code: "METHOD_NOT_SUPPORTED", + message: + "You cannot create additional workspaces in local development mode. Use workOS auth provider if you need to test multi-workspace functionality.", + }); + } + } - await db - .transaction(async (tx) => { - await tx.insert(schema.workspaces).values(workspace); - await tx.insert(schema.quotas).values({ - workspaceId: workspace.id, - ...freeTierQuotas, - }); + const orgId = await authProvider.createTenant({ + name: input.name, + userId, + }); - await insertAuditLogs(tx, [ - { - workspaceId: workspace.id, - actor: { type: "user", id: ctx.user.id }, - event: "workspace.create", - description: `Created ${workspace.id}`, - resources: [ - { - type: "workspace", - id: workspace.id, - name: input.name, - }, - ], - context: { - location: ctx.audit.location, - userAgent: ctx.audit.userAgent, - }, - }, - ]); - }) - .catch((_err) => { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: - "We are unable to create the workspace. Please try again or contact support@unkey.dev", - }); - }); + const workspace: Workspace = { + id: newId("workspace"), + orgId: orgId, + name: input.name, + plan: "free", + tier: "Free", + stripeCustomerId: null, + stripeSubscriptionId: null, + features: {}, + betaFeatures: {}, + subscriptions: {}, + enabled: true, + deleteProtection: true, + createdAtM: Date.now(), + updatedAtM: null, + deletedAtM: null, + partitionId: null, + }; + + await tx.insert(schema.workspaces).values(workspace); - return { - workspace, - organizationId: orgId, - }; + await tx.insert(schema.quotas).values({ + workspaceId: workspace.id, + ...freeTierQuotas, }); + + await insertAuditLogs(tx, [ + { + workspaceId: workspace.id, + actor: { type: "user", id: ctx.user.id }, + event: "workspace.create", + description: `Created ${workspace.id}`, + resources: [ + { + type: "workspace", + id: workspace.id, + name: input.name, + }, + ], + context: { + location: ctx.audit.location, + userAgent: ctx.audit.userAgent, + }, + }, + ]); + + return { + workspace, + organizationId: orgId, + }; +} diff --git a/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts b/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts new file mode 100644 index 0000000000..abb914c705 --- /dev/null +++ b/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts @@ -0,0 +1,78 @@ +import { createKeyInputSchema } from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.schema"; +import { db } from "@/lib/db"; +import { TRPCError } from "@trpc/server"; +import { z } from "zod"; +import { requireUser, t } from "../../trpc"; +import { createApiCore } from "../api/create"; +import { createKeyCore } from "../key/create"; +import { createWorkspaceCore } from "../workspace/create"; + +const createWorkspaceWithApiAndKeyInputSchema = z.object({ + workspaceName: z + .string() + .trim() + .min(3, "Workspace name must be at least 3 characters") + .max(50, "Workspace name must not exceed 50 characters"), + apiName: z + .string() + .min(3, "API name must be at least 3 characters") + .max(50, "API name must not exceed 50 characters"), + ...createKeyInputSchema.omit({ keyAuthId: true }).shape, +}); + +export const onboardingKeyCreation = t.procedure + .use(requireUser) + .input(createWorkspaceWithApiAndKeyInputSchema) + .mutation(async ({ input, ctx }) => { + const { workspaceName, apiName, ...keyInput } = input; + + try { + return await db.transaction(async (tx) => { + // Create workspace first + const workspaceResult = await createWorkspaceCore({ name: workspaceName }, ctx, tx); + + // Create workspace context for API and key creation + const workspaceCtx = { + ...ctx, + workspace: { id: workspaceResult.workspace.id }, + }; + + // Create API + const apiResult = await createApiCore({ name: apiName }, workspaceCtx, tx); + + // Create key using the keyAuthId from the API + const keyAuth = { + id: apiResult.keyAuthId, + storeEncryptedKeys: false, // Default for new APIs. Can be activated by unkey with a support ticket. + }; + + const keyResult = await createKeyCore( + { + ...keyInput, + keyAuthId: apiResult.keyAuthId, + }, + workspaceCtx, + tx, + keyAuth, + ); + + return { + workspace: workspaceResult.workspace, + organizationId: workspaceResult.organizationId, + apiId: apiResult.id, + keyId: keyResult.keyId, + key: keyResult.key, + keyName: keyResult.name, + }; + }); + } catch (error) { + if (error instanceof TRPCError) { + throw error; + } + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "We are unable to create the workspace, API, and key. Please try again or contact support@unkey.dev", + }); + } + }); From 5c0ecc997a15dbf3ce538482e19c80ac17141223 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 8 Jul 2025 19:29:19 +0300 Subject: [PATCH 03/19] fix: add suspense with fallabck --- apps/dashboard/app/new-2/page.tsx | 79 +++++++++++++++++++++++++++++-- 1 file changed, 76 insertions(+), 3 deletions(-) diff --git a/apps/dashboard/app/new-2/page.tsx b/apps/dashboard/app/new-2/page.tsx index 73c0bf3f38..7e85b42b1e 100644 --- a/apps/dashboard/app/new-2/page.tsx +++ b/apps/dashboard/app/new-2/page.tsx @@ -1,16 +1,89 @@ "use client"; import { StackPerspective2 } from "@unkey/icons"; -import { useState } from "react"; +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() { - const [currentStepIndex, setCurrentStepIndex] = useState(0); + 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, @@ -19,7 +92,7 @@ export default function OnboardingPage() { icon: , body:
Dashboard setup content
, kind: "non-required" as const, - description: "Next: you’ll create your first API key", + description: "Next: you'll create your first API key", buttonText: "Continue", }, ]; From cffa854f0ece78d755685c74af6b876ae3d7386f Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 9 Jul 2025 13:12:58 +0300 Subject: [PATCH 04/19] feat: add workspace creation to the first step --- .../new-2/components/onboarding-wizard.tsx | 35 ++++++-- .../app/new-2/hooks/use-workspace-step.tsx | 86 +++++++++++++++++-- 2 files changed, 106 insertions(+), 15 deletions(-) diff --git a/apps/dashboard/app/new-2/components/onboarding-wizard.tsx b/apps/dashboard/app/new-2/components/onboarding-wizard.tsx index 556f22f8f7..f60d249479 100644 --- a/apps/dashboard/app/new-2/components/onboarding-wizard.tsx +++ b/apps/dashboard/app/new-2/components/onboarding-wizard.tsx @@ -18,6 +18,8 @@ export type OnboardingStep = { description: string; /** Text displayed on the primary action button */ buttonText: string; + /** Whether this step is currently loading (e.g., submitting form data) */ + isLoading?: boolean; } & ( | { /** Step type - no validation required, can be skipped */ @@ -59,15 +61,20 @@ export const OnboardingWizard = ({ steps, onComplete, onStepChange }: Onboarding const currentStep = steps[currentStepIndex]; const isFirstStep = currentStepIndex === 0; const isLastStep = currentStepIndex === steps.length - 1; + const isLoading = currentStep.isLoading || false; const handleBack = () => { - if (!isFirstStep) { + if (!isFirstStep && !isLoading) { currentStep.onStepBack?.(currentStepIndex); setCurrentStepIndex(currentStepIndex - 1); } }; const handleNext = () => { + if (isLoading) { + return; + } + if (isLastStep) { currentStep.onStepNext?.(currentStepIndex); onComplete?.(); @@ -78,6 +85,10 @@ export const OnboardingWizard = ({ steps, onComplete, onStepChange }: Onboarding }; const handleSkip = () => { + if (isLoading) { + return; + } + // For non-required steps, skip to next step if (isLastStep) { onComplete?.(); @@ -86,6 +97,18 @@ export const OnboardingWizard = ({ steps, onComplete, onStepChange }: Onboarding } }; + const isNextButtonDisabled = () => { + if (isLoading) { + return true; + } + + if (currentStep.kind === "required") { + return currentStep.validFieldCount !== currentStep.requiredFieldCount; + } + + return false; + }; + return (
{/* Navigation part */} @@ -96,7 +119,7 @@ export const OnboardingWizard = ({ steps, onComplete, onStepChange }: Onboarding className="rounded-lg bg-grayA-3 hover:bg-grayA-4 h-[22px]" variant="outline" onClick={handleBack} - disabled={isFirstStep} + disabled={isFirstStep || isLoading} >
@@ -134,6 +157,7 @@ export const OnboardingWizard = ({ steps, onComplete, onStepChange }: Onboarding className="rounded-lg bg-grayA-3 hover:bg-grayA-4 h-[22px]" variant="outline" onClick={handleSkip} + disabled={isLoading} >
Skip step @@ -161,11 +185,8 @@ export const OnboardingWizard = ({ steps, onComplete, onStepChange }: Onboarding size="xlg" className="w-full rounded-lg" onClick={handleNext} - disabled={ - currentStep.kind === "required" - ? currentStep.validFieldCount !== currentStep.requiredFieldCount - : false - } + disabled={isNextButtonDisabled()} + loading={isLoading} > {currentStep.buttonText} diff --git a/apps/dashboard/app/new-2/hooks/use-workspace-step.tsx b/apps/dashboard/app/new-2/hooks/use-workspace-step.tsx index 4fd23a894a..91916d7e74 100644 --- a/apps/dashboard/app/new-2/hooks/use-workspace-step.tsx +++ b/apps/dashboard/app/new-2/hooks/use-workspace-step.tsx @@ -1,8 +1,12 @@ +import { toast } from "@/components/ui/toaster"; +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 { StackPerspective2 } from "@unkey/icons"; -import { FormInput } from "@unkey/ui"; +import { Button, FormInput } from "@unkey/ui"; import { useRouter, useSearchParams } from "next/navigation"; -import { useRef } from "react"; +import { useRef, useTransition } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import type { OnboardingStep } from "../components/onboarding-wizard"; @@ -29,6 +33,8 @@ export const useWorkspaceStep = (): OnboardingStep => { const formRef = useRef(null); const router = useRouter(); const searchParams = useSearchParams(); + const [isPending, startTransition] = useTransition(); + const workspaceIdRef = useRef(null); const form = useForm({ resolver: zodResolver(workspaceSchema), @@ -38,13 +44,70 @@ export const useWorkspaceStep = (): OnboardingStep => { mode: "onChange", }); + 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}`); + } + }, + }); + const onSubmit = (data: WorkspaceFormData) => { - console.info("Workspace form submitted:", data); - // Save workspace name to URL and proceed to next step - const params = new URLSearchParams(searchParams?.toString()); - params.set("workspaceName", data.workspaceName); - router.push(`?${params.toString()}`); - // The onboarding wizard will handle moving to the next step + createWorkspace.mutate({ name: data.workspaceName }); }; const validFieldCount = Object.keys(form.getValues()).filter((field) => { @@ -54,6 +117,8 @@ export const useWorkspaceStep = (): OnboardingStep => { return !hasError && hasValue; }).length; + const isLoading = createWorkspace.isLoading || isPending; + return { name: "Workspace", icon: , @@ -104,6 +169,7 @@ export const useWorkspaceStep = (): OnboardingStep => { // }} required error={form.formState.errors.workspaceName?.message} + disabled={isLoading} /> {/* { buttonText: "Continue", description: "Set up your workspace to get started", onStepNext: () => { + if (isLoading) { + return; + } formRef.current?.requestSubmit(); }, onStepBack: () => { console.info("Going back from workspace step"); }, + isLoading, }; }; From 948dcd68a961cbed58865ea5d23d4584912d869f Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 9 Jul 2025 13:53:24 +0300 Subject: [PATCH 05/19] feat: add run every step individually --- .../new-2/components/onboarding-wizard.tsx | 33 ++-- .../app/new-2/hooks/use-key-creation-step.tsx | 98 +++++----- .../app/new-2/hooks/use-workspace-step.tsx | 11 +- .../lib/trpc/routers/workspace/create.ts | 179 ++++++++---------- .../lib/trpc/routers/workspace/onboarding.ts | 39 ++-- 5 files changed, 176 insertions(+), 184 deletions(-) diff --git a/apps/dashboard/app/new-2/components/onboarding-wizard.tsx b/apps/dashboard/app/new-2/components/onboarding-wizard.tsx index f60d249479..19c00ae9f3 100644 --- a/apps/dashboard/app/new-2/components/onboarding-wizard.tsx +++ b/apps/dashboard/app/new-2/components/onboarding-wizard.tsx @@ -1,6 +1,6 @@ import { ChevronLeft, ChevronRight } from "@unkey/icons"; import { Button, Separator } from "@unkey/ui"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { CircleProgress } from "./circle-progress"; export type OnboardingStep = { @@ -49,20 +49,34 @@ const CIRCLE_PROGRESS_STROKE_WIDTH = 1.5; export const OnboardingWizard = ({ steps, onComplete, onStepChange }: OnboardingWizardProps) => { const [currentStepIndex, setCurrentStepIndex] = useState(0); + const previousLoadingRef = useRef(false); if (steps.length === 0) { throw new Error("OnboardingWizard requires at least one step"); } - useEffect(() => { - onStepChange?.(currentStepIndex); - }, [currentStepIndex, onStepChange]); - const currentStep = steps[currentStepIndex]; const isFirstStep = currentStepIndex === 0; const isLastStep = currentStepIndex === steps.length - 1; const isLoading = currentStep.isLoading || false; + // Auto-advance when loading ends + useEffect(() => { + if (previousLoadingRef.current && !isLoading) { + // Loading just ended, advance to next step + if (isLastStep) { + onComplete?.(); + } else { + setCurrentStepIndex(currentStepIndex + 1); + } + } + previousLoadingRef.current = isLoading; + }, [isLoading, isLastStep, currentStepIndex, onComplete]); + + useEffect(() => { + onStepChange?.(currentStepIndex); + }, [currentStepIndex, onStepChange]); + const handleBack = () => { if (!isFirstStep && !isLoading) { currentStep.onStepBack?.(currentStepIndex); @@ -75,13 +89,8 @@ export const OnboardingWizard = ({ steps, onComplete, onStepChange }: Onboarding return; } - if (isLastStep) { - currentStep.onStepNext?.(currentStepIndex); - onComplete?.(); - } else { - currentStep.onStepNext?.(currentStepIndex); - setCurrentStepIndex(currentStepIndex + 1); - } + // Only trigger the callback, don't advance automatically + currentStep.onStepNext?.(currentStepIndex); }; const handleSkip = () => { diff --git a/apps/dashboard/app/new-2/hooks/use-key-creation-step.tsx b/apps/dashboard/app/new-2/hooks/use-key-creation-step.tsx index 0b2b1d4f6f..c9604bd48b 100644 --- a/apps/dashboard/app/new-2/hooks/use-key-creation-step.tsx +++ b/apps/dashboard/app/new-2/hooks/use-key-creation-step.tsx @@ -14,12 +14,11 @@ import { formValuesToApiInput, getDefaultValues, } from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.utils"; -import { setCookie } from "@/lib/auth/cookies"; -import { UNKEY_SESSION_COOKIE } from "@/lib/auth/types"; +import { toast } from "@/components/ui/toaster"; import { trpc } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { CalendarClock, ChartPie, Code, Gauge, Key2, StackPerspective2 } from "@unkey/icons"; -import { FormInput, toast } from "@unkey/ui"; +import { FormInput } from "@unkey/ui"; import { addDays } from "date-fns"; import { useSearchParams } from "next/navigation"; import { useRef } from "react"; @@ -41,16 +40,23 @@ const extendedFormSchema = formSchema.and( export const useKeyCreationStep = (): OnboardingStep => { const formRef = useRef(null); const searchParams = useSearchParams(); - const workspaceName = searchParams?.get("workspaceName") || ""; + const workspaceId = searchParams?.get("workspaceId") || ""; - const createWorkspaceWithApiAndKey = trpc.workspace.onboarding.useMutation({ + const createApiAndKey = trpc.workspace.onboarding.useMutation({ onSuccess: (data) => { - console.info("Successfully created workspace, API and key:", data); - switchOrgMutation.mutate(data.organizationId); + 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!"); }, onError: (error) => { - console.error("Failed to create workspace, API and key:", error); - // Handle error - show toast notification + console.error("Failed to create API and key:", error); + + if (error.data?.code === "NOT_FOUND") { + // In case users try to feed tRPC with weird workspaceId or non existing one + toast.error("Invalid workspace. Please go back and create a new workspace."); + } else { + toast.error(`Failed to create API and key: ${error.message}`); + } }, }); @@ -72,36 +78,10 @@ export const useKeyCreationStep = (): OnboardingStep => { formState: { errors }, } = methods; - 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), - }, - }); - }, - onError: (error) => { - toast.error(`Failed to load new workspace: ${error.message}`); - }, - }); - const onSubmit = async (data: FormValues & { apiName: string }) => { - console.info("Submitting onboarding data:", data); - - if (!workspaceName) { - console.error("Workspace name not found in URL parameters"); + if (!isValidWorkspaceId) { + console.error("Invalid workspace ID in URL parameters"); + toast.error("Invalid workspace ID. Please go back and create a new workspace."); return; } @@ -110,19 +90,25 @@ export const useKeyCreationStep = (): OnboardingStep => { const { keyAuthId, ...keyInputWithoutAuthId } = keyInput; // Remove keyAuthId const submitData = { - workspaceName, + workspaceId, apiName: data.apiName, ...keyInputWithoutAuthId, }; - await createWorkspaceWithApiAndKey.mutateAsync(submitData); + await createApiAndKey.mutateAsync(submitData); } catch (error) { console.error("Submit error:", error); } }; + // 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(workspaceName && apiNameValue); + const isFormReady = Boolean(isValidWorkspaceId && apiNameValue); + const isLoading = createApiAndKey.isLoading; + + const validFieldCount = apiNameValue ? 1 : 0; return { name: "API key", @@ -139,7 +125,7 @@ export const useKeyCreationStep = (): OnboardingStep => { description="Choose a name for your API that helps you identify it" label="API name" className="w-full" - disabled={!workspaceName || createWorkspaceWithApiAndKey.isLoading} + disabled={!isValidWorkspaceId || isLoading} />
@@ -152,7 +138,7 @@ export const useKeyCreationStep = (): OnboardingStep => {
} title="General Setup" description="Configure basic API key settings like prefix, byte length, and External ID" @@ -161,7 +147,7 @@ export const useKeyCreationStep = (): OnboardingStep => { } title="Ratelimit" description="Set request limits per time window to control API usage frequency" @@ -175,7 +161,7 @@ export const useKeyCreationStep = (): OnboardingStep => { } title="Credits" description="Set usage limits based on credits or quota to control consumption" @@ -189,7 +175,7 @@ export const useKeyCreationStep = (): OnboardingStep => { } title="Expiration" description="Set when this API key should automatically expire and become invalid" @@ -207,7 +193,7 @@ export const useKeyCreationStep = (): OnboardingStep => { } title="Metadata" description="Add custom key-value pairs to store additional information with your API key" @@ -229,16 +215,17 @@ export const useKeyCreationStep = (): OnboardingStep => {
), - kind: "non-required" as const, - buttonText: createWorkspaceWithApiAndKey.isLoading - ? "Creating workspace..." - : workspaceName - ? "Create API & Key" - : "Go Back", + 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 (!workspaceName) { - // Handle going back if workspace name is missing + if (!isValidWorkspaceId) { + toast.error("Invalid workspace ID. Please go back and create a new workspace."); + return; + } + if (isLoading) { return; } formRef.current?.requestSubmit(); @@ -246,5 +233,6 @@ export const useKeyCreationStep = (): OnboardingStep => { onStepBack: () => { console.info("Going back from API key creation step"); }, + isLoading, }; }; diff --git a/apps/dashboard/app/new-2/hooks/use-workspace-step.tsx b/apps/dashboard/app/new-2/hooks/use-workspace-step.tsx index 91916d7e74..ea1ddc95bf 100644 --- a/apps/dashboard/app/new-2/hooks/use-workspace-step.tsx +++ b/apps/dashboard/app/new-2/hooks/use-workspace-step.tsx @@ -64,7 +64,12 @@ export const useWorkspaceStep = (): OnboardingStep => { }, }).then(() => { startTransition(() => { - router.push(`/new?workspaceId=${workspaceIdRef.current}`); + 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()}`); }); }); }, @@ -106,8 +111,8 @@ export const useWorkspaceStep = (): OnboardingStep => { }, }); - const onSubmit = (data: WorkspaceFormData) => { - createWorkspace.mutate({ name: data.workspaceName }); + const onSubmit = async (data: WorkspaceFormData) => { + createWorkspace.mutateAsync({ name: data.workspaceName }); }; const validFieldCount = Object.keys(form.getValues()).filter((field) => { diff --git a/apps/dashboard/lib/trpc/routers/workspace/create.ts b/apps/dashboard/lib/trpc/routers/workspace/create.ts index 538102b95c..ad60b45578 100644 --- a/apps/dashboard/lib/trpc/routers/workspace/create.ts +++ b/apps/dashboard/lib/trpc/routers/workspace/create.ts @@ -16,118 +16,93 @@ export const createWorkspace = t.procedure }), ) .mutation(async ({ ctx, input }) => { - try { - return await db.transaction(async (tx) => { - return await createWorkspaceCore(input, ctx, tx); - }); - } catch (error) { - if (error instanceof TRPCError) { - throw error; - } + const userId = ctx.user?.id; + + if (!userId) { throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", + code: "UNAUTHORIZED", message: - "We are unable to create the workspace. Please try again or contact support@unkey.dev", + "We are not able to authenticate the user. Please make sure you are logged in and try again", }); } - }); - -type CreateWorkspaceInput = { - name: string; -}; -type CreateWorkspaceContext = { - user: { id: string }; - tenant: { id: string }; - audit: { - location: string; - userAgent?: string; - }; -}; - -type DatabaseTransaction = Parameters[0]>[0]; + if (env().AUTH_PROVIDER === "local") { + // Check if this user already has a workspace + const existingWorkspaces = await db.query.workspaces.findMany({ + where: (workspaces, { eq }) => eq(workspaces.orgId, ctx.tenant.id), + }); -export async function createWorkspaceCore( - input: CreateWorkspaceInput, - ctx: CreateWorkspaceContext, - tx: DatabaseTransaction, -) { - const userId = ctx.user?.id; - if (!userId) { - throw new TRPCError({ - code: "UNAUTHORIZED", - message: - "We are not able to authenticate the user. Please make sure you are logged in and try again", - }); - } + if (existingWorkspaces.length > 0) { + throw new TRPCError({ + code: "METHOD_NOT_SUPPORTED", + message: + "You cannot create additional workspaces in local development mode. Use workOS auth provider if you need to test multi-workspace functionality.", + }); + } + } - if (env().AUTH_PROVIDER === "local") { - // Check if this user already has a workspace - const existingWorkspaces = await tx.query.workspaces.findMany({ - where: (workspaces, { eq }) => eq(workspaces.orgId, ctx.tenant.id), + const orgId = await authProvider.createTenant({ + name: input.name, + userId, }); - if (existingWorkspaces.length > 0) { - throw new TRPCError({ - code: "METHOD_NOT_SUPPORTED", - message: - "You cannot create additional workspaces in local development mode. Use workOS auth provider if you need to test multi-workspace functionality.", - }); - } - } - const orgId = await authProvider.createTenant({ - name: input.name, - userId, - }); + const workspace: Workspace = { + id: newId("workspace"), + orgId: orgId, + name: input.name, + plan: "free", + tier: "Free", + stripeCustomerId: null, + stripeSubscriptionId: null, + features: {}, + betaFeatures: {}, + subscriptions: {}, + enabled: true, + deleteProtection: true, + createdAtM: Date.now(), + updatedAtM: null, + deletedAtM: null, + partitionId: null, + }; - const workspace: Workspace = { - id: newId("workspace"), - orgId: orgId, - name: input.name, - plan: "free", - tier: "Free", - stripeCustomerId: null, - stripeSubscriptionId: null, - features: {}, - betaFeatures: {}, - subscriptions: {}, - enabled: true, - deleteProtection: true, - createdAtM: Date.now(), - updatedAtM: null, - deletedAtM: null, - partitionId: null, - }; + await db + .transaction(async (tx) => { + await tx.insert(schema.workspaces).values(workspace); + await tx.insert(schema.quotas).values({ + workspaceId: workspace.id, + ...freeTierQuotas, + }); - await tx.insert(schema.workspaces).values(workspace); + await insertAuditLogs(tx, [ + { + workspaceId: workspace.id, + actor: { type: "user", id: ctx.user.id }, + event: "workspace.create", + description: `Created ${workspace.id}`, + resources: [ + { + type: "workspace", + id: workspace.id, + name: input.name, + }, + ], + context: { + location: ctx.audit.location, + userAgent: ctx.audit.userAgent, + }, + }, + ]); + }) + .catch((_err) => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "We are unable to create the workspace. Please try again or contact support@unkey.dev", + }); + }); - await tx.insert(schema.quotas).values({ - workspaceId: workspace.id, - ...freeTierQuotas, + return { + workspace, + organizationId: orgId, + }; }); - - await insertAuditLogs(tx, [ - { - workspaceId: workspace.id, - actor: { type: "user", id: ctx.user.id }, - event: "workspace.create", - description: `Created ${workspace.id}`, - resources: [ - { - type: "workspace", - id: workspace.id, - name: input.name, - }, - ], - context: { - location: ctx.audit.location, - userAgent: ctx.audit.userAgent, - }, - }, - ]); - - return { - workspace, - organizationId: orgId, - }; -} diff --git a/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts b/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts index abb914c705..4aa6348e63 100644 --- a/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts +++ b/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts @@ -5,14 +5,9 @@ import { z } from "zod"; import { requireUser, t } from "../../trpc"; import { createApiCore } from "../api/create"; import { createKeyCore } from "../key/create"; -import { createWorkspaceCore } from "../workspace/create"; const createWorkspaceWithApiAndKeyInputSchema = z.object({ - workspaceName: z - .string() - .trim() - .min(3, "Workspace name must be at least 3 characters") - .max(50, "Workspace name must not exceed 50 characters"), + workspaceId: z.string(), apiName: z .string() .min(3, "API name must be at least 3 characters") @@ -24,17 +19,39 @@ export const onboardingKeyCreation = t.procedure .use(requireUser) .input(createWorkspaceWithApiAndKeyInputSchema) .mutation(async ({ input, ctx }) => { - const { workspaceName, apiName, ...keyInput } = input; + const { workspaceId, apiName, ...keyInput } = input; try { return await db.transaction(async (tx) => { - // Create workspace first - const workspaceResult = await createWorkspaceCore({ name: workspaceName }, ctx, tx); + // Validate that the workspace exists and user has access to it + const workspace = await tx.query.workspaces + .findFirst({ + where: (table, { and, eq, isNull }) => + and( + eq(table.id, workspaceId), + eq(table.orgId, ctx.tenant.id), + isNull(table.deletedAtM), + ), + }) + .catch((_err) => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "Failed to validate workspace access. If this issue persists, please contact support@unkey.dev", + }); + }); + + if (!workspace) { + throw new TRPCError({ + code: "NOT_FOUND", + message: "Workspace not found or you don't have access to it", + }); + } // Create workspace context for API and key creation const workspaceCtx = { ...ctx, - workspace: { id: workspaceResult.workspace.id }, + workspace: { id: workspaceId }, }; // Create API @@ -57,8 +74,6 @@ export const onboardingKeyCreation = t.procedure ); return { - workspace: workspaceResult.workspace, - organizationId: workspaceResult.organizationId, apiId: apiResult.id, keyId: keyResult.keyId, key: keyResult.key, From eb1d76767869aed4a341ead122846b767c6d5256 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 9 Jul 2025 16:00:55 +0300 Subject: [PATCH 06/19] feat: success step --- .../new-2/components/onboarding-wizard.tsx | 31 +++-- apps/dashboard/app/new-2/constants.ts | 9 +- .../app/new-2/hooks/use-key-creation-step.tsx | 6 +- apps/dashboard/app/new-2/page.tsx | 110 ++++++++++++++++-- 4 files changed, 129 insertions(+), 27 deletions(-) diff --git a/apps/dashboard/app/new-2/components/onboarding-wizard.tsx b/apps/dashboard/app/new-2/components/onboarding-wizard.tsx index 19c00ae9f3..211d594d3c 100644 --- a/apps/dashboard/app/new-2/components/onboarding-wizard.tsx +++ b/apps/dashboard/app/new-2/components/onboarding-wizard.tsx @@ -50,6 +50,7 @@ const CIRCLE_PROGRESS_STROKE_WIDTH = 1.5; export const OnboardingWizard = ({ steps, onComplete, onStepChange }: OnboardingWizardProps) => { const [currentStepIndex, setCurrentStepIndex] = useState(0); const previousLoadingRef = useRef(false); + const [shouldAdvance, setShouldAdvance] = useState(false); if (steps.length === 0) { throw new Error("OnboardingWizard requires at least one step"); @@ -60,18 +61,27 @@ export const OnboardingWizard = ({ steps, onComplete, onStepChange }: Onboarding const isLastStep = currentStepIndex === steps.length - 1; const isLoading = currentStep.isLoading || false; - // Auto-advance when loading ends + // Auto-advance logic useEffect(() => { - if (previousLoadingRef.current && !isLoading) { - // Loading just ended, advance to next step + if (shouldAdvance) { + setShouldAdvance(false); + if (isLastStep) { onComplete?.(); } else { setCurrentStepIndex(currentStepIndex + 1); } } + }, [shouldAdvance, isLastStep, currentStepIndex, onComplete]); + + // Handle loading state changes + useEffect(() => { + if (previousLoadingRef.current && !isLoading) { + // Loading just ended, trigger advance + setShouldAdvance(true); + } previousLoadingRef.current = isLoading; - }, [isLoading, isLastStep, currentStepIndex, onComplete]); + }, [isLoading]); useEffect(() => { onStepChange?.(currentStepIndex); @@ -89,8 +99,12 @@ export const OnboardingWizard = ({ steps, onComplete, onStepChange }: Onboarding return; } - // Only trigger the callback, don't advance automatically currentStep.onStepNext?.(currentStepIndex); + + // If not loading, advance immediately + if (!isLoading) { + setShouldAdvance(true); + } }; const handleSkip = () => { @@ -99,11 +113,8 @@ export const OnboardingWizard = ({ steps, onComplete, onStepChange }: Onboarding } // For non-required steps, skip to next step - if (isLastStep) { - onComplete?.(); - } else { - setCurrentStepIndex(currentStepIndex + 1); - } + currentStep.onStepNext?.(currentStepIndex); + setShouldAdvance(true); }; const isNextButtonDisabled = () => { diff --git a/apps/dashboard/app/new-2/constants.ts b/apps/dashboard/app/new-2/constants.ts index cac7355520..8f3d42d5c5 100644 --- a/apps/dashboard/app/new-2/constants.ts +++ b/apps/dashboard/app/new-2/constants.ts @@ -5,17 +5,18 @@ export type StepInfo = { export const stepInfos: StepInfo[] = [ { - title: "Create company workspace", + 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", + 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.", + title: "Your API Key is Ready", + description: + "Use the code snippet below to start authenticating requests. You can always manage or rotate your keys from the dashboard.", }, ]; diff --git a/apps/dashboard/app/new-2/hooks/use-key-creation-step.tsx b/apps/dashboard/app/new-2/hooks/use-key-creation-step.tsx index c9604bd48b..e897882864 100644 --- a/apps/dashboard/app/new-2/hooks/use-key-creation-step.tsx +++ b/apps/dashboard/app/new-2/hooks/use-key-creation-step.tsx @@ -108,8 +108,6 @@ export const useKeyCreationStep = (): OnboardingStep => { const isFormReady = Boolean(isValidWorkspaceId && apiNameValue); const isLoading = createApiAndKey.isLoading; - const validFieldCount = apiNameValue ? 1 : 0; - return { name: "API key", icon: , @@ -215,9 +213,7 @@ export const useKeyCreationStep = (): OnboardingStep => {
), - kind: "required" as const, - validFieldCount, - requiredFieldCount: 1, + kind: "non-required" as const, buttonText: isLoading ? "Creating API & Key..." : "Create API & Key", description: "Setup your API with an initial key and advanced configurations", onStepNext: () => { diff --git a/apps/dashboard/app/new-2/page.tsx b/apps/dashboard/app/new-2/page.tsx index 7e85b42b1e..8d979bc016 100644 --- a/apps/dashboard/app/new-2/page.tsx +++ b/apps/dashboard/app/new-2/page.tsx @@ -1,7 +1,9 @@ "use client"; -import { StackPerspective2 } from "@unkey/icons"; -import { FormInput } from "@unkey/ui"; -import { Suspense, useState } from "react"; +import { ConfirmPopover } from "@/components/confirmation-popover"; +import { CircleInfo, Key2, StackPerspective2 } from "@unkey/icons"; +import { Code, CopyButton, FormInput, VisibleButton } from "@unkey/ui"; +import { Suspense, useRef, useState } from "react"; +import { SecretKey } from "../(app)/apis/[apiId]/_components/create-key/components/secret-key"; import { type OnboardingStep, OnboardingWizard } from "./components/onboarding-wizard"; import { stepInfos } from "./constants"; import { useKeyCreationStep } from "./hooks/use-key-creation-step"; @@ -81,6 +83,7 @@ function OnboardingFallback() { function OnboardingContent() { const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); const workspaceStep = useWorkspaceStep(); const keyCreationStep = useKeyCreationStep(); @@ -88,12 +91,17 @@ function OnboardingContent() { workspaceStep, keyCreationStep, { - name: "Dashboard", - icon: , - body:
Dashboard setup content
, + name: "API key", + icon: , + body: ( + + ), kind: "non-required" as const, - description: "Next: you'll create your first API key", - buttonText: "Continue", + description: "You're all set! Your workspace and API key are ready", + buttonText: "Continue to dashboard", + onStepNext: () => { + setIsConfirmOpen(true); + }, }, ]; @@ -144,3 +152,89 @@ function OnboardingContent() {
); } + +type OnboardingSuccessStepProps = { + isConfirmOpen: boolean; + setIsConfirmOpen: (open: boolean) => void; +}; + +const OnboardingSuccessStep = ({ isConfirmOpen, setIsConfirmOpen }: OnboardingSuccessStepProps) => { + const [showKeyInSnippet, setShowKeyInSnippet] = useState(false); + const anchorRef = useRef(null); + + const keyData = { key: "key_data" }; + const apiId = "api_id"; + 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}" + }'`; + + return ( + <> +
+ + Run this command to verify your new API key against the API ID. This ensures your key is + ready for authenticated requests. + +
+
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)} + +
+
+ setIsConfirmOpen(false)} + triggerRef={anchorRef} + title="You won't see this secret key again!" + description="Make sure to copy your secret key before closing. It cannot be retrieved later." + confirmButtonText="Close anyway" + cancelButtonText="Dismiss" + variant="warning" + popoverProps={{ + side: "right", + align: "end", + sideOffset: 5, + alignOffset: 30, + onOpenAutoFocus: (e) => e.preventDefault(), + }} + /> + + ); +}; From 30937eb468c6df1a2074f653265a87a523c4aa63 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 9 Jul 2025 16:41:30 +0300 Subject: [PATCH 07/19] feat: add control for skippiong --- .../components/onboarding-success-step.tsx | 117 ++++++++++++++++++ .../new-2/components/onboarding-wizard.tsx | 31 ++--- apps/dashboard/app/new-2/constants.ts | 4 + .../app/new-2/hooks/use-key-creation-step.tsx | 22 +++- .../app/new-2/hooks/use-workspace-step.tsx | 6 +- apps/dashboard/app/new-2/page.tsx | 105 +--------------- 6 files changed, 153 insertions(+), 132 deletions(-) create mode 100644 apps/dashboard/app/new-2/components/onboarding-success-step.tsx diff --git a/apps/dashboard/app/new-2/components/onboarding-success-step.tsx b/apps/dashboard/app/new-2/components/onboarding-success-step.tsx new file mode 100644 index 0000000000..e2184871ef --- /dev/null +++ b/apps/dashboard/app/new-2/components/onboarding-success-step.tsx @@ -0,0 +1,117 @@ +import { SecretKey } from "@/app/(app)/apis/[apiId]/_components/create-key/components/secret-key"; +import { ConfirmPopover } from "@/components/confirmation-popover"; +import { CircleInfo, TriangleWarning } from "@unkey/icons"; +import { Code, CopyButton, VisibleButton } from "@unkey/ui"; +import { useRouter, useSearchParams } from "next/navigation"; +import { useRef, useState } from "react"; +import { API_ID_PARAM, KEY_PARAM } from "../constants"; + +type OnboardingSuccessStepProps = { + isConfirmOpen: boolean; + setIsConfirmOpen: (open: boolean) => void; +}; + +export const OnboardingSuccessStep = ({ + isConfirmOpen, + setIsConfirmOpen, +}: OnboardingSuccessStepProps) => { + const router = useRouter(); + const [showKeyInSnippet, setShowKeyInSnippet] = useState(false); + const anchorRef = useRef(null); + const searchParams = useSearchParams(); + + const apiId = searchParams?.get(API_ID_PARAM); + const key = searchParams?.get(KEY_PARAM); + + if (!apiId || !key) { + return ( +
+
+ +
+
+ Error: Missing API or key information. Please go back + and create your API key again to continue with the setup process. +
+
+ ); + } + + const split = 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": "${key}", + "apiId": "${apiId}" + }'`; + + return ( + <> +
+ + Run this command to verify your new API key against the API ID. This ensures your key is + ready for authenticated requests. + +
+
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(key, maskedKey)} + +
+
+ { + setIsConfirmOpen(false); + + router.push("/apis"); + }} + triggerRef={anchorRef} + title="You won't see this secret key again!" + description="Make sure to copy your secret key before closing. It cannot be retrieved later." + confirmButtonText="Close anyway" + cancelButtonText="Dismiss" + variant="warning" + popoverProps={{ + side: "right", + align: "end", + sideOffset: 5, + alignOffset: 30, + onOpenAutoFocus: (e) => e.preventDefault(), + }} + /> + + ); +}; diff --git a/apps/dashboard/app/new-2/components/onboarding-wizard.tsx b/apps/dashboard/app/new-2/components/onboarding-wizard.tsx index 211d594d3c..fae31e2014 100644 --- a/apps/dashboard/app/new-2/components/onboarding-wizard.tsx +++ b/apps/dashboard/app/new-2/components/onboarding-wizard.tsx @@ -14,6 +14,8 @@ export type OnboardingStep = { onStepNext?: (currentStep: number) => void; /** Callback fired when user clicks back button */ onStepBack?: (currentStep: number) => void; + /** Callback fired when user clicks skip button (only for non-required steps) */ + onStepSkip?: (currentStep: number) => void; /** Description text shown below the main button */ description: string; /** Text displayed on the primary action button */ @@ -50,7 +52,6 @@ const CIRCLE_PROGRESS_STROKE_WIDTH = 1.5; export const OnboardingWizard = ({ steps, onComplete, onStepChange }: OnboardingWizardProps) => { const [currentStepIndex, setCurrentStepIndex] = useState(0); const previousLoadingRef = useRef(false); - const [shouldAdvance, setShouldAdvance] = useState(false); if (steps.length === 0) { throw new Error("OnboardingWizard requires at least one step"); @@ -61,27 +62,18 @@ export const OnboardingWizard = ({ steps, onComplete, onStepChange }: Onboarding const isLastStep = currentStepIndex === steps.length - 1; const isLoading = currentStep.isLoading || false; - // Auto-advance logic + // Auto-advance when loading ends useEffect(() => { - if (shouldAdvance) { - setShouldAdvance(false); - + if (previousLoadingRef.current && !isLoading) { + // Loading just ended, advance to next step if (isLastStep) { onComplete?.(); } else { setCurrentStepIndex(currentStepIndex + 1); } } - }, [shouldAdvance, isLastStep, currentStepIndex, onComplete]); - - // Handle loading state changes - useEffect(() => { - if (previousLoadingRef.current && !isLoading) { - // Loading just ended, trigger advance - setShouldAdvance(true); - } previousLoadingRef.current = isLoading; - }, [isLoading]); + }, [isLoading, isLastStep, currentStepIndex, onComplete]); useEffect(() => { onStepChange?.(currentStepIndex); @@ -99,12 +91,8 @@ export const OnboardingWizard = ({ steps, onComplete, onStepChange }: Onboarding return; } + // Only trigger the callback, don't advance automatically currentStep.onStepNext?.(currentStepIndex); - - // If not loading, advance immediately - if (!isLoading) { - setShouldAdvance(true); - } }; const handleSkip = () => { @@ -112,9 +100,8 @@ export const OnboardingWizard = ({ steps, onComplete, onStepChange }: Onboarding return; } - // For non-required steps, skip to next step - currentStep.onStepNext?.(currentStepIndex); - setShouldAdvance(true); + // Only trigger the callback, let parent handle navigation + currentStep.onStepSkip?.(currentStepIndex); }; const isNextButtonDisabled = () => { diff --git a/apps/dashboard/app/new-2/constants.ts b/apps/dashboard/app/new-2/constants.ts index 8f3d42d5c5..d881c9afd3 100644 --- a/apps/dashboard/app/new-2/constants.ts +++ b/apps/dashboard/app/new-2/constants.ts @@ -1,3 +1,7 @@ +export const WORKSPACE_ID_PARAM = "workspaceId"; +export const KEY_PARAM = "key"; +export const API_ID_PARAM = "apiId"; + export type StepInfo = { title: string; description: string; diff --git a/apps/dashboard/app/new-2/hooks/use-key-creation-step.tsx b/apps/dashboard/app/new-2/hooks/use-key-creation-step.tsx index e897882864..56c37d53c1 100644 --- a/apps/dashboard/app/new-2/hooks/use-key-creation-step.tsx +++ b/apps/dashboard/app/new-2/hooks/use-key-creation-step.tsx @@ -20,12 +20,13 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { CalendarClock, ChartPie, Code, Gauge, Key2, StackPerspective2 } from "@unkey/icons"; import { FormInput } 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, 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({ @@ -39,14 +40,20 @@ const extendedFormSchema = formSchema.and( export const useKeyCreationStep = (): OnboardingStep => { const formRef = useRef(null); + const router = useRouter(); const searchParams = useSearchParams(); + const [isPending, startTransition] = useTransition(); const workspaceId = searchParams?.get("workspaceId") || ""; 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!"); + // Add apiId and keyId to URL parameters + 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); @@ -106,7 +113,7 @@ export const useKeyCreationStep = (): OnboardingStep => { const apiNameValue = watch("apiName"); const isFormReady = Boolean(isValidWorkspaceId && apiNameValue); - const isLoading = createApiAndKey.isLoading; + const isLoading = createApiAndKey.isLoading || isPending; return { name: "API key", @@ -216,6 +223,9 @@ export const useKeyCreationStep = (): OnboardingStep => { kind: "non-required" as const, buttonText: isLoading ? "Creating API & Key..." : "Create API & Key", description: "Setup your API with an initial key and advanced configurations", + onStepSkip: () => { + router.push("/apis"); + }, onStepNext: () => { if (!isValidWorkspaceId) { toast.error("Invalid workspace ID. Please go back and create a new workspace."); diff --git a/apps/dashboard/app/new-2/hooks/use-workspace-step.tsx b/apps/dashboard/app/new-2/hooks/use-workspace-step.tsx index ea1ddc95bf..624f4100b9 100644 --- a/apps/dashboard/app/new-2/hooks/use-workspace-step.tsx +++ b/apps/dashboard/app/new-2/hooks/use-workspace-step.tsx @@ -10,6 +10,7 @@ import { useRef, useTransition } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import type { OnboardingStep } from "../components/onboarding-wizard"; +import { WORKSPACE_ID_PARAM } from "../constants"; const workspaceSchema = z.object({ workspaceName: z @@ -38,9 +39,6 @@ export const useWorkspaceStep = (): OnboardingStep => { const form = useForm({ resolver: zodResolver(workspaceSchema), - defaultValues: { - workspaceName: searchParams?.get("workspaceName") || "", - }, mode: "onChange", }); @@ -68,7 +66,7 @@ export const useWorkspaceStep = (): OnboardingStep => { if (!workspaceIdRef.current) { throw new Error("WorkspaceId cannot be null"); } - params.set("workspaceId", workspaceIdRef.current); + params.set(WORKSPACE_ID_PARAM, workspaceIdRef.current); router.push(`?${params.toString()}`); }); }); diff --git a/apps/dashboard/app/new-2/page.tsx b/apps/dashboard/app/new-2/page.tsx index 8d979bc016..5f3131fcb8 100644 --- a/apps/dashboard/app/new-2/page.tsx +++ b/apps/dashboard/app/new-2/page.tsx @@ -1,9 +1,8 @@ "use client"; -import { ConfirmPopover } from "@/components/confirmation-popover"; -import { CircleInfo, Key2, StackPerspective2 } from "@unkey/icons"; -import { Code, CopyButton, FormInput, VisibleButton } from "@unkey/ui"; -import { Suspense, useRef, useState } from "react"; -import { SecretKey } from "../(app)/apis/[apiId]/_components/create-key/components/secret-key"; +import { Key2, StackPerspective2 } from "@unkey/icons"; +import { FormInput } from "@unkey/ui"; +import { Suspense, useState } from "react"; +import { OnboardingSuccessStep } from "./components/onboarding-success-step"; import { type OnboardingStep, OnboardingWizard } from "./components/onboarding-wizard"; import { stepInfos } from "./constants"; import { useKeyCreationStep } from "./hooks/use-key-creation-step"; @@ -105,10 +104,6 @@ function OnboardingContent() { }, ]; - const handleComplete = () => { - console.info("Onboarding completed!"); - }; - const handleStepChange = (newStepIndex: number) => { setCurrentStepIndex(newStepIndex); }; @@ -142,99 +137,9 @@ function OnboardingContent() {
{/* Form part */}
- +
); } - -type OnboardingSuccessStepProps = { - isConfirmOpen: boolean; - setIsConfirmOpen: (open: boolean) => void; -}; - -const OnboardingSuccessStep = ({ isConfirmOpen, setIsConfirmOpen }: OnboardingSuccessStepProps) => { - const [showKeyInSnippet, setShowKeyInSnippet] = useState(false); - const anchorRef = useRef(null); - - const keyData = { key: "key_data" }; - const apiId = "api_id"; - 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}" - }'`; - - return ( - <> -
- - Run this command to verify your new API key against the API ID. This ensures your key is - ready for authenticated requests. - -
-
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)} - -
-
- setIsConfirmOpen(false)} - triggerRef={anchorRef} - title="You won't see this secret key again!" - description="Make sure to copy your secret key before closing. It cannot be retrieved later." - confirmButtonText="Close anyway" - cancelButtonText="Dismiss" - variant="warning" - popoverProps={{ - side: "right", - align: "end", - sideOffset: 5, - alignOffset: 30, - onOpenAutoFocus: (e) => e.preventDefault(), - }} - /> - - ); -}; From 64aaed0894bbed8520b9c33007644f43d3f41418 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Wed, 9 Jul 2025 17:56:54 +0300 Subject: [PATCH 08/19] refactor: replace new onboarding --- apps/dashboard/app/new-2/page.tsx | 145 -------- .../components/circle-progress.tsx | 0 .../components/expandable-settings.tsx | 15 +- .../components/onboarding-success-step.tsx | 0 .../components/onboarding-wizard.tsx | 21 +- .../dashboard/app/{new-2 => new}/constants.ts | 0 apps/dashboard/app/new/create-api.tsx | 123 ------- apps/dashboard/app/new/create-ratelimit.tsx | 103 ------ apps/dashboard/app/new/create-workspace.tsx | 149 -------- .../hooks/use-key-creation-step.tsx | 60 ++-- .../hooks/use-workspace-step.tsx | 31 +- apps/dashboard/app/new/keys.tsx | 277 --------------- apps/dashboard/app/new/page.tsx | 334 +++++++----------- 13 files changed, 223 insertions(+), 1035 deletions(-) delete mode 100644 apps/dashboard/app/new-2/page.tsx rename apps/dashboard/app/{new-2 => new}/components/circle-progress.tsx (100%) rename apps/dashboard/app/{new-2 => new}/components/expandable-settings.tsx (86%) rename apps/dashboard/app/{new-2 => new}/components/onboarding-success-step.tsx (100%) rename apps/dashboard/app/{new-2 => new}/components/onboarding-wizard.tsx (93%) rename apps/dashboard/app/{new-2 => new}/constants.ts (100%) delete mode 100644 apps/dashboard/app/new/create-api.tsx delete mode 100644 apps/dashboard/app/new/create-ratelimit.tsx delete mode 100644 apps/dashboard/app/new/create-workspace.tsx rename apps/dashboard/app/{new-2 => new}/hooks/use-key-creation-step.tsx (84%) rename apps/dashboard/app/{new-2 => new}/hooks/use-workspace-step.tsx (91%) delete mode 100644 apps/dashboard/app/new/keys.tsx diff --git a/apps/dashboard/app/new-2/page.tsx b/apps/dashboard/app/new-2/page.tsx deleted file mode 100644 index 5f3131fcb8..0000000000 --- a/apps/dashboard/app/new-2/page.tsx +++ /dev/null @@ -1,145 +0,0 @@ -"use client"; -import { Key2, StackPerspective2 } from "@unkey/icons"; -import { FormInput } from "@unkey/ui"; -import { Suspense, useState } from "react"; -import { OnboardingSuccessStep } from "./components/onboarding-success-step"; -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 [isConfirmOpen, setIsConfirmOpen] = useState(false); - const workspaceStep = useWorkspaceStep(); - const keyCreationStep = useKeyCreationStep(); - - const steps: OnboardingStep[] = [ - workspaceStep, - keyCreationStep, - { - name: "API key", - icon: , - body: ( - - ), - kind: "non-required" as const, - description: "You're all set! Your workspace and API key are ready", - buttonText: "Continue to dashboard", - onStepNext: () => { - setIsConfirmOpen(true); - }, - }, - ]; - - 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..9c9acac89a 100644 --- a/apps/dashboard/app/new-2/components/expandable-settings.tsx +++ b/apps/dashboard/app/new/components/expandable-settings.tsx @@ -1,4 +1,5 @@ import { Switch } from "@/components/ui/switch"; + import { CircleInfo } from "@unkey/icons"; import { InfoTooltip } from "@unkey/ui"; import { useState } from "react"; @@ -12,6 +13,7 @@ type ExpandableSettingsProps = { defaultChecked?: boolean; onCheckedChange?: (checked: boolean) => void; disabled?: boolean; + disabledTooltip?: string; }; export const ExpandableSettings = ({ @@ -22,6 +24,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 +35,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 03c4142557..0000000000 --- a/apps/dashboard/app/new/create-workspace.tsx +++ /dev/null @@ -1,149 +0,0 @@ -"use client"; - -import { toast } from "@/components/ui/toaster"; -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 } 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 84% 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 56c37d53c1..72f99bb496 100644 --- a/apps/dashboard/app/new-2/hooks/use-key-creation-step.tsx +++ b/apps/dashboard/app/new/hooks/use-key-creation-step.tsx @@ -21,7 +21,7 @@ import { CalendarClock, ChartPie, Code, Gauge, Key2, StackPerspective2 } from "@ import { FormInput } from "@unkey/ui"; import { addDays } from "date-fns"; import { useRouter, useSearchParams } from "next/navigation"; -import { useRef, useTransition } from "react"; +import { useRef, useState, useTransition } from "react"; import { FormProvider, useForm } from "react-hook-form"; import { z } from "zod"; import { ExpandableSettings } from "../components/expandable-settings"; @@ -39,6 +39,7 @@ const extendedFormSchema = formSchema.and( ); export const useKeyCreationStep = (): OnboardingStep => { + const [apiCreated, setApiCreated] = useState(false); const formRef = useRef(null); const router = useRouter(); const searchParams = useSearchParams(); @@ -47,7 +48,7 @@ export const useKeyCreationStep = (): OnboardingStep => { const createApiAndKey = trpc.workspace.onboarding.useMutation({ onSuccess: (data) => { - // Add apiId and keyId to URL parameters + setApiCreated(true); startTransition(() => { const params = new URLSearchParams(searchParams?.toString()); params.set(API_ID_PARAM, data.apiId); @@ -74,7 +75,6 @@ export const useKeyCreationStep = (): OnboardingStep => { shouldUnregister: true, defaultValues: { ...getDefaultValues(), - apiName: "", }, }); @@ -115,6 +115,12 @@ export const useKeyCreationStep = (): OnboardingStep => { const isFormReady = Boolean(isValidWorkspaceId && apiNameValue); const isLoading = createApiAndKey.isLoading || isPending; + 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", icon: , @@ -130,7 +136,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={!isValidWorkspaceId || isLoading || apiCreated} />
@@ -143,7 +149,8 @@ export const useKeyCreationStep = (): OnboardingStep => {
} title="General Setup" description="Configure basic API key settings like prefix, byte length, and External ID" @@ -152,7 +159,8 @@ export const useKeyCreationStep = (): OnboardingStep => { } title="Ratelimit" description="Set request limits per time window to control API usage frequency" @@ -166,7 +174,8 @@ export const useKeyCreationStep = (): OnboardingStep => { } title="Credits" description="Set usage limits based on credits or quota to control consumption" @@ -180,7 +189,8 @@ export const useKeyCreationStep = (): OnboardingStep => { } title="Expiration" description="Set when this API key should automatically expire and become invalid" @@ -198,7 +208,8 @@ export const useKeyCreationStep = (): OnboardingStep => { } title="Metadata" description="Add custom key-value pairs to store additional information with your API key" @@ -221,24 +232,25 @@ export const useKeyCreationStep = (): OnboardingStep => {
), kind: "non-required" as const, - buttonText: isLoading ? "Creating API & Key..." : "Create API & Key", - description: "Setup your API with an initial key and advanced configurations", + 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: () => { - 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"); - }, + onStepNext: apiCreated + ? undefined + : () => { + if (!isValidWorkspaceId) { + toast.error("Invalid workspace ID. Please go back and create a new workspace."); + return; + } + 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 91% rename from apps/dashboard/app/new-2/hooks/use-workspace-step.tsx rename to apps/dashboard/app/new/hooks/use-workspace-step.tsx index 624f4100b9..7c65b6934e 100644 --- a/apps/dashboard/app/new-2/hooks/use-workspace-step.tsx +++ b/apps/dashboard/app/new/hooks/use-workspace-step.tsx @@ -6,7 +6,7 @@ import { zodResolver } from "@hookform/resolvers/zod"; import { StackPerspective2 } from "@unkey/icons"; import { Button, FormInput } from "@unkey/ui"; import { useRouter, useSearchParams } from "next/navigation"; -import { useRef, useTransition } from "react"; +import { useRef, useState, useTransition } from "react"; import { useForm } from "react-hook-form"; import { z } from "zod"; import type { OnboardingStep } from "../components/onboarding-wizard"; @@ -31,6 +31,7 @@ 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(); @@ -79,6 +80,7 @@ export const useWorkspaceStep = (): OnboardingStep => { const createWorkspace = trpc.workspace.create.useMutation({ onSuccess: async ({ workspace, organizationId }) => { workspaceIdRef.current = workspace.id; + setWorkspaceCreated(true); switchOrgMutation.mutate(organizationId); }, onError: (error) => { @@ -110,6 +112,10 @@ export const useWorkspaceStep = (): OnboardingStep => { }); const onSubmit = async (data: WorkspaceFormData) => { + if (workspaceCreated) { + // Workspace already created, just proceed + return; + } createWorkspace.mutateAsync({ name: data.workspaceName }); }; @@ -172,7 +178,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..758ea141e5 100644 --- a/apps/dashboard/app/new/page.tsx +++ b/apps/dashboard/app/new/page.tsx @@ -1,210 +1,148 @@ -import { PageHeader } from "@/components/dashboard/page-header"; -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"; - -export const dynamic = "force-dynamic"; - -type Props = { - searchParams: { - workspaceId?: string; - apiId?: string; - ratelimitNamespaceId?: string; - product?: "keys" | "ratelimit"; - }; -}; - -export default async function (props: Props) { - // 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. -
-
+"use client"; +import { Key2, StackPerspective2 } from "@unkey/icons"; +import { FormInput } from "@unkey/ui"; +import { Suspense, useState } from "react"; +import { OnboardingSuccessStep } from "./components/onboarding-success-step"; +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
-
-
-
- -
-

I want to ratelimit something

-

- Global low latency ratelimiting for your application. -

-
    -
  1. Low latency
  2. -
  3. Globally consistent
  4. -
  5. Powerful analytics
  6. -
-
- - - +
+
+ 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={() => {}} + /> +
- ); - } - - 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 {" "} - , - ]} - /> +
+ ); +} - +function OnboardingContent() { + const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + const workspaceStep = useWorkspaceStep(); + const keyCreationStep = useKeyCreationStep(); + + const steps: OnboardingStep[] = [ + workspaceStep, + keyCreationStep, + { + name: "API key", + icon: , + body: ( + + ), + kind: "non-required" as const, + description: "You're all set! Your workspace and API key are ready", + buttonText: "Continue to dashboard", + onStepNext: () => { + setIsConfirmOpen(true); + }, + onStepSkip: () => { + setIsConfirmOpen(true); + }, + }, + ]; + + 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 */} +
+ +
+
); } From 30424b80bb658fa74e1dab102f76f5270c685f1e Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 10 Jul 2025 16:32:33 +0300 Subject: [PATCH 09/19] feat: add mising user and help button --- .../app/new/components/onboarding-wizard.tsx | 2 +- apps/dashboard/app/new/page.tsx | 10 +++++- .../navigation/sidebar/app-sidebar/index.tsx | 8 +++-- .../navigation/sidebar/help-button.tsx | 3 +- .../navigation/sidebar/sidebar-mobile.tsx | 9 ++++-- .../navigation/sidebar/user-button.tsx | 32 +++++++++++-------- 6 files changed, 43 insertions(+), 21 deletions(-) diff --git a/apps/dashboard/app/new/components/onboarding-wizard.tsx b/apps/dashboard/app/new/components/onboarding-wizard.tsx index 04f15c59fc..d003fc1168 100644 --- a/apps/dashboard/app/new/components/onboarding-wizard.tsx +++ b/apps/dashboard/app/new/components/onboarding-wizard.tsx @@ -132,7 +132,7 @@ export const OnboardingWizard = ({ steps, onComplete, onStepChange }: Onboarding }; return ( -
+
{/* Navigation part */}
{/* Back button and current step name*/} diff --git a/apps/dashboard/app/new/page.tsx b/apps/dashboard/app/new/page.tsx index 758ea141e5..4c6e1c77de 100644 --- a/apps/dashboard/app/new/page.tsx +++ b/apps/dashboard/app/new/page.tsx @@ -1,4 +1,6 @@ "use client"; +import { HelpButton } from "@/components/navigation/sidebar/help-button"; +import { UserButton } from "@/components/navigation/sidebar/user-button"; import { Key2, StackPerspective2 } from "@unkey/icons"; import { FormInput } from "@unkey/ui"; import { Suspense, useState } from "react"; @@ -114,7 +116,7 @@ function OnboardingContent() { const currentStepInfo = stepInfos[currentStepIndex]; return ( -
+
{/* Unkey Logo */}
Unkey
{/* Spacer */} @@ -143,6 +145,12 @@ function OnboardingContent() {
+
+ +
+
+ +
); } 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..68fc54e231 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 ? (
) : ( @@ -62,11 +67,10 @@ export const UserButton: React.FC = () => { Theme - From 66520bc0b1187bb2fe8abc7cd48fed0c00ae101d Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 10 Jul 2025 16:41:40 +0300 Subject: [PATCH 10/19] feat: prevent unauthenticated user access --- .../app/new/components/onboarding-content.tsx | 83 +++++++++ .../new/components/onboarding-fallback.tsx | 67 ++++++++ .../app/new/components/onboarding-wizard.tsx | 1 + apps/dashboard/app/new/page.tsx | 159 ++---------------- .../navigation/sidebar/user-button.tsx | 6 +- 5 files changed, 162 insertions(+), 154 deletions(-) create mode 100644 apps/dashboard/app/new/components/onboarding-content.tsx create mode 100644 apps/dashboard/app/new/components/onboarding-fallback.tsx diff --git a/apps/dashboard/app/new/components/onboarding-content.tsx b/apps/dashboard/app/new/components/onboarding-content.tsx new file mode 100644 index 0000000000..c2750617d9 --- /dev/null +++ b/apps/dashboard/app/new/components/onboarding-content.tsx @@ -0,0 +1,83 @@ +"use client"; +import { HelpButton } from "@/components/navigation/sidebar/help-button"; +import { UserButton } from "@/components/navigation/sidebar/user-button"; +import { Key2 } from "@unkey/icons"; +import { useState } from "react"; +import { stepInfos } from "../constants"; +import { useKeyCreationStep } from "../hooks/use-key-creation-step"; +import { useWorkspaceStep } from "../hooks/use-workspace-step"; +import { OnboardingSuccessStep } from "./onboarding-success-step"; +import { type OnboardingStep, OnboardingWizard } from "./onboarding-wizard"; + +export function OnboardingContent() { + const [currentStepIndex, setCurrentStepIndex] = useState(0); + const [isConfirmOpen, setIsConfirmOpen] = useState(false); + const workspaceStep = useWorkspaceStep(); + const keyCreationStep = useKeyCreationStep(); + + const steps: OnboardingStep[] = [ + workspaceStep, + keyCreationStep, + { + name: "API key", + icon: , + body: ( + + ), + kind: "non-required" as const, + description: "You're all set! Your workspace and API key are ready", + buttonText: "Continue to dashboard", + onStepNext: () => { + setIsConfirmOpen(true); + }, + onStepSkip: () => { + setIsConfirmOpen(true); + }, + }, + ]; + + 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/components/onboarding-fallback.tsx b/apps/dashboard/app/new/components/onboarding-fallback.tsx new file mode 100644 index 0000000000..0ed3b0ec40 --- /dev/null +++ b/apps/dashboard/app/new/components/onboarding-fallback.tsx @@ -0,0 +1,67 @@ +import { StackPerspective2 } from "@unkey/icons"; +import { FormInput } from "@unkey/ui"; +import { OnboardingWizard } from "./onboarding-wizard"; + +export 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={() => {}} + /> +
+
+
+ ); +} diff --git a/apps/dashboard/app/new/components/onboarding-wizard.tsx b/apps/dashboard/app/new/components/onboarding-wizard.tsx index d003fc1168..e8cccc7bc7 100644 --- a/apps/dashboard/app/new/components/onboarding-wizard.tsx +++ b/apps/dashboard/app/new/components/onboarding-wizard.tsx @@ -1,3 +1,4 @@ +"use client"; import { ChevronLeft, ChevronRight } from "@unkey/icons"; import { Button, Separator } from "@unkey/ui"; import { useEffect, useRef, useState } from "react"; diff --git a/apps/dashboard/app/new/page.tsx b/apps/dashboard/app/new/page.tsx index 4c6e1c77de..a04b54d014 100644 --- a/apps/dashboard/app/new/page.tsx +++ b/apps/dashboard/app/new/page.tsx @@ -1,156 +1,17 @@ -"use client"; -import { HelpButton } from "@/components/navigation/sidebar/help-button"; -import { UserButton } from "@/components/navigation/sidebar/user-button"; -import { Key2, StackPerspective2 } from "@unkey/icons"; -import { FormInput } from "@unkey/ui"; -import { Suspense, useState } from "react"; -import { OnboardingSuccessStep } from "./components/onboarding-success-step"; -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"; +"use server"; +import { getAuth } from "@/lib/auth"; +import { Suspense } from "react"; +import { OnboardingContent } from "./components/onboarding-content"; +import { OnboardingFallback } from "./components/onboarding-fallback"; + +export default async function OnboardingPage() { + // ensure we have an authenticated user + // we don't actually need any user data though + await getAuth(); -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 [isConfirmOpen, setIsConfirmOpen] = useState(false); - const workspaceStep = useWorkspaceStep(); - const keyCreationStep = useKeyCreationStep(); - - const steps: OnboardingStep[] = [ - workspaceStep, - keyCreationStep, - { - name: "API key", - icon: , - body: ( - - ), - kind: "non-required" as const, - description: "You're all set! Your workspace and API key are ready", - buttonText: "Continue to dashboard", - onStepNext: () => { - setIsConfirmOpen(true); - }, - onStepSkip: () => { - setIsConfirmOpen(true); - }, - }, - ]; - - 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/components/navigation/sidebar/user-button.tsx b/apps/dashboard/components/navigation/sidebar/user-button.tsx index 68fc54e231..1d513dc535 100644 --- a/apps/dashboard/components/navigation/sidebar/user-button.tsx +++ b/apps/dashboard/components/navigation/sidebar/user-button.tsx @@ -64,11 +64,7 @@ export const UserButton: React.FC = ({ ) : null}
- + Theme From dfdbf524e14bf2453cd75154a4f1afe2f65d9cf7 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 10 Jul 2025 16:51:50 +0300 Subject: [PATCH 11/19] fix: add use client --- apps/dashboard/app/new/components/expandable-settings.tsx | 1 + apps/dashboard/app/new/components/onboarding-fallback.tsx | 1 + apps/dashboard/app/new/components/onboarding-success-step.tsx | 1 + 3 files changed, 3 insertions(+) diff --git a/apps/dashboard/app/new/components/expandable-settings.tsx b/apps/dashboard/app/new/components/expandable-settings.tsx index 9c9acac89a..25cb28563d 100644 --- a/apps/dashboard/app/new/components/expandable-settings.tsx +++ b/apps/dashboard/app/new/components/expandable-settings.tsx @@ -1,3 +1,4 @@ +"use client"; import { Switch } from "@/components/ui/switch"; import { CircleInfo } from "@unkey/icons"; diff --git a/apps/dashboard/app/new/components/onboarding-fallback.tsx b/apps/dashboard/app/new/components/onboarding-fallback.tsx index 0ed3b0ec40..dea5e0b788 100644 --- a/apps/dashboard/app/new/components/onboarding-fallback.tsx +++ b/apps/dashboard/app/new/components/onboarding-fallback.tsx @@ -1,3 +1,4 @@ +"use client"; import { StackPerspective2 } from "@unkey/icons"; import { FormInput } from "@unkey/ui"; import { OnboardingWizard } from "./onboarding-wizard"; diff --git a/apps/dashboard/app/new/components/onboarding-success-step.tsx b/apps/dashboard/app/new/components/onboarding-success-step.tsx index e2184871ef..50a900f568 100644 --- a/apps/dashboard/app/new/components/onboarding-success-step.tsx +++ b/apps/dashboard/app/new/components/onboarding-success-step.tsx @@ -1,3 +1,4 @@ +"use client"; import { SecretKey } from "@/app/(app)/apis/[apiId]/_components/create-key/components/secret-key"; import { ConfirmPopover } from "@/components/confirmation-popover"; import { CircleInfo, TriangleWarning } from "@unkey/icons"; From c718be49ad51fe9ee5fa3458d918ce87c7b38353 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Fri, 11 Jul 2025 14:00:06 +0300 Subject: [PATCH 12/19] fix: get rid of workspace param --- apps/dashboard/app/new/constants.ts | 1 - .../app/new/hooks/use-key-creation-step.tsx | 22 +++------------- .../app/new/hooks/use-workspace-step.tsx | 25 ++++-------------- apps/dashboard/lib/trpc/routers/key/create.ts | 22 +++++++++------- .../lib/trpc/routers/workspace/onboarding.ts | 26 +++++-------------- 5 files changed, 27 insertions(+), 69 deletions(-) diff --git a/apps/dashboard/app/new/constants.ts b/apps/dashboard/app/new/constants.ts index d881c9afd3..593a7d026c 100644 --- a/apps/dashboard/app/new/constants.ts +++ b/apps/dashboard/app/new/constants.ts @@ -1,4 +1,3 @@ -export const WORKSPACE_ID_PARAM = "workspaceId"; export const KEY_PARAM = "key"; export const API_ID_PARAM = "apiId"; diff --git a/apps/dashboard/app/new/hooks/use-key-creation-step.tsx b/apps/dashboard/app/new/hooks/use-key-creation-step.tsx index 72f99bb496..0ffde675c1 100644 --- a/apps/dashboard/app/new/hooks/use-key-creation-step.tsx +++ b/apps/dashboard/app/new/hooks/use-key-creation-step.tsx @@ -14,11 +14,10 @@ import { formValuesToApiInput, getDefaultValues, } from "@/app/(app)/apis/[apiId]/_components/create-key/create-key.utils"; -import { toast } from "@/components/ui/toaster"; import { trpc } from "@/lib/trpc/client"; import { zodResolver } from "@hookform/resolvers/zod"; import { CalendarClock, ChartPie, Code, Gauge, Key2, StackPerspective2 } from "@unkey/icons"; -import { FormInput } from "@unkey/ui"; +import { FormInput, toast } from "@unkey/ui"; import { addDays } from "date-fns"; import { useRouter, useSearchParams } from "next/navigation"; import { useRef, useState, useTransition } from "react"; @@ -44,7 +43,6 @@ export const useKeyCreationStep = (): OnboardingStep => { const router = useRouter(); const searchParams = useSearchParams(); const [isPending, startTransition] = useTransition(); - const workspaceId = searchParams?.get("workspaceId") || ""; const createApiAndKey = trpc.workspace.onboarding.useMutation({ onSuccess: (data) => { @@ -86,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, }; @@ -108,11 +99,8 @@ 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 isFormReady = Boolean(apiNameValue); const isLoading = createApiAndKey.isLoading || isPending; const tooltipContent = apiCreated @@ -136,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 || apiCreated} + disabled={isLoading || apiCreated} />
@@ -242,10 +230,6 @@ export const useKeyCreationStep = (): OnboardingStep => { onStepNext: apiCreated ? undefined : () => { - if (!isValidWorkspaceId) { - toast.error("Invalid workspace ID. Please go back and create a new workspace."); - return; - } if (isLoading) { return; } diff --git a/apps/dashboard/app/new/hooks/use-workspace-step.tsx b/apps/dashboard/app/new/hooks/use-workspace-step.tsx index 7c65b6934e..efd5941056 100644 --- a/apps/dashboard/app/new/hooks/use-workspace-step.tsx +++ b/apps/dashboard/app/new/hooks/use-workspace-step.tsx @@ -1,16 +1,14 @@ -import { toast } from "@/components/ui/toaster"; 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 { StackPerspective2 } from "@unkey/icons"; -import { Button, FormInput } from "@unkey/ui"; -import { useRouter, useSearchParams } from "next/navigation"; -import { useRef, useState, useTransition } from "react"; +import { Button, FormInput, toast } from "@unkey/ui"; +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"; -import { WORKSPACE_ID_PARAM } from "../constants"; const workspaceSchema = z.object({ workspaceName: z @@ -34,9 +32,6 @@ export const useWorkspaceStep = (): OnboardingStep => { 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), @@ -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(WORKSPACE_ID_PARAM, workspaceIdRef.current); - router.push(`?${params.toString()}`); - }); }); }, onError: (error) => { @@ -78,8 +64,7 @@ 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); }, @@ -126,7 +111,7 @@ export const useWorkspaceStep = (): OnboardingStep => { return !hasError && hasValue; }).length; - const isLoading = createWorkspace.isLoading || isPending; + const isLoading = createWorkspace.isLoading; return { name: "Workspace", diff --git a/apps/dashboard/lib/trpc/routers/key/create.ts b/apps/dashboard/lib/trpc/routers/key/create.ts index d01e7ae75c..26c189701d 100644 --- a/apps/dashboard/lib/trpc/routers/key/create.ts +++ b/apps/dashboard/lib/trpc/routers/key/create.ts @@ -39,7 +39,15 @@ export const createKey = t.procedure try { return await db.transaction(async (tx) => { - return await createKeyCore(input, ctx, tx, keyAuth); + return await createKeyCore( + { + ...input, + keyAuthId: keyAuth.id, + storeEncryptedKeys: keyAuth.storeEncryptedKeys, + }, + ctx, + tx, + ); }); } catch (_err) { throw new TRPCError({ @@ -58,18 +66,12 @@ type CreateKeyContext = { }; }; -type KeyAuth = { - id: string; - storeEncryptedKeys: boolean; -}; - type DatabaseTransaction = Parameters[0]>[0]; export async function createKeyCore( - input: CreateKeyInput, + input: CreateKeyInput & { storeEncryptedKeys: boolean }, ctx: CreateKeyContext, tx: DatabaseTransaction, - keyAuth: KeyAuth, ) { const keyId = newId("key"); const { key, hash, start } = await newKey({ @@ -79,7 +81,7 @@ export async function createKeyCore( await tx.insert(schema.keys).values({ id: keyId, - keyAuthId: keyAuth.id, + keyAuthId: input.keyAuthId, name: input.name, hash, start, @@ -99,7 +101,7 @@ export async function createKeyCore( environment: input.environment, }); - if (keyAuth.storeEncryptedKeys) { + if (input.storeEncryptedKeys) { const { encrypted, keyId: encryptionKeyId } = await vault.encrypt({ keyring: ctx.workspace.id, data: key, diff --git a/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts b/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts index 4aa6348e63..2e56228e8e 100644 --- a/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts +++ b/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts @@ -2,12 +2,11 @@ import { createKeyInputSchema } from "@/app/(app)/apis/[apiId]/_components/creat import { db } from "@/lib/db"; import { TRPCError } from "@trpc/server"; import { z } from "zod"; -import { requireUser, t } from "../../trpc"; +import { requireUser, requireWorkspace, t } from "../../trpc"; 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") @@ -17,9 +16,10 @@ const createWorkspaceWithApiAndKeyInputSchema = z.object({ export const onboardingKeyCreation = t.procedure .use(requireUser) + .use(requireWorkspace) .input(createWorkspaceWithApiAndKeyInputSchema) .mutation(async ({ input, ctx }) => { - const { workspaceId, apiName, ...keyInput } = input; + const { apiName, ...keyInput } = input; try { return await db.transaction(async (tx) => { @@ -28,7 +28,7 @@ export const onboardingKeyCreation = t.procedure .findFirst({ where: (table, { and, eq, isNull }) => and( - eq(table.id, workspaceId), + eq(table.id, ctx.workspace.id), eq(table.orgId, ctx.tenant.id), isNull(table.deletedAtM), ), @@ -48,29 +48,17 @@ export const onboardingKeyCreation = t.procedure }); } - // Create workspace context for API and key creation - const workspaceCtx = { - ...ctx, - workspace: { id: workspaceId }, - }; - // Create API - const apiResult = await createApiCore({ name: apiName }, workspaceCtx, tx); - - // Create key using the keyAuthId from the API - const keyAuth = { - id: apiResult.keyAuthId, - storeEncryptedKeys: false, // Default for new APIs. Can be activated by unkey with a support ticket. - }; + const apiResult = await createApiCore({ name: apiName }, 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. }, - workspaceCtx, + ctx, tx, - keyAuth, ); return { From 8b57c8ab8ecafd8c757125f1a33aa771026dabda Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Fri, 11 Jul 2025 14:14:20 +0300 Subject: [PATCH 13/19] refactor: make shared key secret section --- .../components/key-created-success-dialog.tsx | 58 +++------------ .../components/key-secret-section.tsx | 74 +++++++++++++++++++ .../components/onboarding-success-step.tsx | 74 ++++--------------- 3 files changed, 99 insertions(+), 107 deletions(-) create mode 100644 apps/dashboard/app/(app)/apis/[apiId]/_components/create-key/components/key-secret-section.tsx 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/components/onboarding-success-step.tsx b/apps/dashboard/app/new/components/onboarding-success-step.tsx index 50a900f568..a3c4da5838 100644 --- a/apps/dashboard/app/new/components/onboarding-success-step.tsx +++ b/apps/dashboard/app/new/components/onboarding-success-step.tsx @@ -1,10 +1,9 @@ "use client"; -import { SecretKey } from "@/app/(app)/apis/[apiId]/_components/create-key/components/secret-key"; +import { KeySecretSection } from "@/app/(app)/apis/[apiId]/_components/create-key/components/key-secret-section"; import { ConfirmPopover } from "@/components/confirmation-popover"; -import { CircleInfo, TriangleWarning } from "@unkey/icons"; -import { Code, CopyButton, VisibleButton } from "@unkey/ui"; +import { TriangleWarning } from "@unkey/icons"; import { useRouter, useSearchParams } from "next/navigation"; -import { useRef, useState } from "react"; +import { useRef } from "react"; import { API_ID_PARAM, KEY_PARAM } from "../constants"; type OnboardingSuccessStepProps = { @@ -17,7 +16,6 @@ export const OnboardingSuccessStep = ({ setIsConfirmOpen, }: OnboardingSuccessStepProps) => { const router = useRouter(); - const [showKeyInSnippet, setShowKeyInSnippet] = useState(false); const anchorRef = useRef(null); const searchParams = useSearchParams(); @@ -38,59 +36,19 @@ export const OnboardingSuccessStep = ({ ); } - const split = 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": "${key}", - "apiId": "${apiId}" - }'`; - return ( - <> -
- - Run this command to verify your new API key against the API ID. This ensures your key is - ready for authenticated requests. - -
-
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(key, maskedKey)} - -
-
+
+ + Run this command to verify your new API key against the API ID. This ensures your key is + ready for authenticated requests. + + e.preventDefault(), }} /> - +
); }; From ec906b2d35815aa99f8e4e031c14f140c687e4b8 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Fri, 11 Jul 2025 16:00:22 +0300 Subject: [PATCH 14/19] chore: remove redundant workspace lookup --- .../lib/trpc/routers/workspace/onboarding.ts | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts b/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts index 2e56228e8e..2a1faf618f 100644 --- a/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts +++ b/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts @@ -23,31 +23,6 @@ export const onboardingKeyCreation = t.procedure try { return await db.transaction(async (tx) => { - // Validate that the workspace exists and user has access to it - const workspace = await tx.query.workspaces - .findFirst({ - where: (table, { and, eq, isNull }) => - and( - eq(table.id, ctx.workspace.id), - eq(table.orgId, ctx.tenant.id), - isNull(table.deletedAtM), - ), - }) - .catch((_err) => { - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: - "Failed to validate workspace access. If this issue persists, please contact support@unkey.dev", - }); - }); - - if (!workspace) { - throw new TRPCError({ - code: "NOT_FOUND", - message: "Workspace not found or you don't have access to it", - }); - } - // Create API const apiResult = await createApiCore({ name: apiName }, ctx, tx); From b4aaf1e0d2dff2a5794bb10956f595728f847fbd Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Fri, 11 Jul 2025 16:03:41 +0300 Subject: [PATCH 15/19] fix: coderabbit issue --- apps/dashboard/lib/trpc/routers/key/create.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/apps/dashboard/lib/trpc/routers/key/create.ts b/apps/dashboard/lib/trpc/routers/key/create.ts index 26c189701d..2b625b646b 100644 --- a/apps/dashboard/lib/trpc/routers/key/create.ts +++ b/apps/dashboard/lib/trpc/routers/key/create.ts @@ -21,13 +21,21 @@ export const createKey = t.procedure .use(requireWorkspace) .input(createKeyInputSchema) .mutation(async ({ input, ctx }) => { - const keyAuth = await db.query.keyAuth.findFirst({ - where: (table, { and, eq }) => - and(eq(table.workspaceId, ctx.workspace.id), eq(table.id, input.keyAuthId)), - with: { - api: true, - }, - }); + const keyAuth = await db.query.keyAuth + .findFirst({ + where: (table, { and, eq }) => + and(eq(table.workspaceId, ctx.workspace.id), eq(table.id, input.keyAuthId)), + with: { + api: true, + }, + }) + .catch(() => { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: + "We are unable to find the keyAuth. Please try again or contact support@unkey.dev", + }); + }); if (!keyAuth) { throw new TRPCError({ From e4ea2fb224bfc3399db5465dd5bfcc4f069e7173 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Fri, 11 Jul 2025 16:05:30 +0300 Subject: [PATCH 16/19] chore: rephrase the error message --- apps/dashboard/lib/trpc/routers/key/create.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/dashboard/lib/trpc/routers/key/create.ts b/apps/dashboard/lib/trpc/routers/key/create.ts index 2b625b646b..e851d07a3c 100644 --- a/apps/dashboard/lib/trpc/routers/key/create.ts +++ b/apps/dashboard/lib/trpc/routers/key/create.ts @@ -29,11 +29,11 @@ export const createKey = t.procedure api: true, }, }) - .catch(() => { + .catch((_err) => { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: - "We are unable to find the keyAuth. Please try again or contact support@unkey.dev", + "We were unable to create a key for this API. Please try again or contact support@unkey.dev.", }); }); From a94fefb913cd8cbe8054d9f435ab188ff15cff1b Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Fri, 11 Jul 2025 16:11:21 +0300 Subject: [PATCH 17/19] chore: fmt --- apps/dashboard/app/new/components/onboarding-wizard.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/apps/dashboard/app/new/components/onboarding-wizard.tsx b/apps/dashboard/app/new/components/onboarding-wizard.tsx index 1be221753a..e8cccc7bc7 100644 --- a/apps/dashboard/app/new/components/onboarding-wizard.tsx +++ b/apps/dashboard/app/new/components/onboarding-wizard.tsx @@ -132,8 +132,6 @@ export const OnboardingWizard = ({ steps, onComplete, onStepChange }: Onboarding return false; }; - - return (
{/* Navigation part */} From dda7d1a9f3eeffd031ff38d7e216cff9a008f3ac Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Fri, 11 Jul 2025 16:48:28 +0300 Subject: [PATCH 18/19] fix: import path --- apps/dashboard/app/new/page.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/app/new/page.tsx b/apps/dashboard/app/new/page.tsx index a04b54d014..eeffe49d5c 100644 --- a/apps/dashboard/app/new/page.tsx +++ b/apps/dashboard/app/new/page.tsx @@ -1,5 +1,5 @@ "use server"; -import { getAuth } from "@/lib/auth"; +import { getAuth } from "@/lib/auth/get-auth"; import { Suspense } from "react"; import { OnboardingContent } from "./components/onboarding-content"; import { OnboardingFallback } from "./components/onboarding-fallback"; From d4d0b00674cc819821820f39bb37e3ed89c8cd57 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Fri, 11 Jul 2025 17:02:54 +0300 Subject: [PATCH 19/19] fix: wording --- apps/dashboard/app/new/constants.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/dashboard/app/new/constants.ts b/apps/dashboard/app/new/constants.ts index 593a7d026c..1bd3fe957b 100644 --- a/apps/dashboard/app/new/constants.ts +++ b/apps/dashboard/app/new/constants.ts @@ -9,8 +9,7 @@ export type StepInfo = { 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.", + description: "Customize your workspace name. This is how it’ll appear in your dashboard.", }, { title: "Create Your First API Key",