From 00a3208f92728abb558041db023b917893c25e18 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Tue, 8 Jul 2025 17:41:56 +0300 Subject: [PATCH 1/8] 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 2/8] 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 3/8] 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 4/8] 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 5/8] 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 8c3a3d6b7e35a1ba002e54ee3be6b0cb144a8b5e Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 10 Jul 2025 14:57:53 +0300 Subject: [PATCH 6/8] fix: read workspaceId from ctx --- .../lib/trpc/routers/workspace/onboarding.ts | 50 +++---------------- 1 file changed, 6 insertions(+), 44 deletions(-) diff --git a/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts b/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts index 4aa6348e63..317c32b4ff 100644 --- a/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts +++ b/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts @@ -2,7 +2,7 @@ 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"; @@ -17,61 +17,23 @@ 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) => { - // 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: workspaceId }, - }; - // Create API - const apiResult = await createApiCore({ name: apiName }, workspaceCtx, tx); + const apiResult = await createApiCore({ name: apiName }, ctx, tx); // Create key using the keyAuthId from the API const keyAuth = { - id: apiResult.keyAuthId, + keyAuthId: 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, - ); + const keyResult = await createKeyCore({ ...keyInput, ...keyAuth }, ctx, tx); return { apiId: apiResult.id, From 0c746a1b6ed7fc247e3f27afda3a7f1b8c6eaf5c Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 10 Jul 2025 14:58:19 +0300 Subject: [PATCH 7/8] fix: payload --- apps/dashboard/lib/trpc/routers/key/create.ts | 44 ++++++++++++------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/apps/dashboard/lib/trpc/routers/key/create.ts b/apps/dashboard/lib/trpc/routers/key/create.ts index d01e7ae75c..74d8e2ff84 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((_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.", + }); + }); if (!keyAuth) { throw new TRPCError({ @@ -39,7 +47,15 @@ export const createKey = t.procedure try { return await db.transaction(async (tx) => { - return await createKeyCore(input, ctx, tx, keyAuth); + return await createKeyCore( + { + ...input, + storeEncryptedKeys: keyAuth.storeEncryptedKeys, + keyAuthId: keyAuth.id, + }, + ctx, + tx, + ); }); } catch (_err) { throw new TRPCError({ @@ -58,18 +74,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 +89,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 +109,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, From 9e5fcb5290c0c4481aca86d6010acb9c743d04e7 Mon Sep 17 00:00:00 2001 From: ogzhanolguncu Date: Thu, 10 Jul 2025 15:04:54 +0300 Subject: [PATCH 8/8] fix: error message --- apps/dashboard/lib/trpc/routers/workspace/onboarding.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts b/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts index 317c32b4ff..c97b046679 100644 --- a/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts +++ b/apps/dashboard/lib/trpc/routers/workspace/onboarding.ts @@ -49,7 +49,7 @@ export const onboardingKeyCreation = t.procedure 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", + "We are unable to create the API and key. Please try again or contact support@unkey.dev", }); } });