Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ export const ExpandableSettings = ({
style={{ left: `${14 + 4}px` }}
/>
{/* Content */}
<div className="py-6 px-10">
<div className="py-6 px-10 text-start">
{typeof children === "function" ? children(isEnabled) : children}
</div>
</div>
Expand Down
66 changes: 48 additions & 18 deletions apps/dashboard/app/new-2/components/onboarding-wizard.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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 */
Expand Down Expand Up @@ -47,37 +49,55 @@ const CIRCLE_PROGRESS_STROKE_WIDTH = 1.5;

export const OnboardingWizard = ({ steps, onComplete, onStepChange }: OnboardingWizardProps) => {
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const previousLoadingRef = useRef<boolean>(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) {
if (!isFirstStep && !isLoading) {
currentStep.onStepBack?.(currentStepIndex);
setCurrentStepIndex(currentStepIndex - 1);
}
};

const handleNext = () => {
if (isLastStep) {
currentStep.onStepNext?.(currentStepIndex);
onComplete?.();
} else {
currentStep.onStepNext?.(currentStepIndex);
setCurrentStepIndex(currentStepIndex + 1);
if (isLoading) {
return;
}

// Only trigger the callback, don't advance automatically
currentStep.onStepNext?.(currentStepIndex);
};

const handleSkip = () => {
if (isLoading) {
return;
}

// For non-required steps, skip to next step
if (isLastStep) {
onComplete?.();
Expand All @@ -86,6 +106,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 (
<div className="border-gray-5 border rounded-2xl flex flex-col h-auto max-h-[400px] sm:max-h-[500px] md:max-h-[600px] lg:max-h-[700px] xl:max-h-[800px]">
{/* Navigation part */}
Expand All @@ -96,7 +128,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}
>
<div className="flex items-center gap-1">
<ChevronLeft size="sm-regular" className="text-gray-12 !w-3 !h-3 flex-shrink-0" />
Expand Down Expand Up @@ -134,6 +166,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}
>
<div className="flex items-center gap-1">
<span className="font-medium text-gray-12 text-xs">Skip step</span>
Expand Down Expand Up @@ -161,11 +194,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}
</Button>
Expand Down
134 changes: 108 additions & 26 deletions apps/dashboard/app/new-2/hooks/use-key-creation-step.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,74 @@
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,
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 { 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 { 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<HTMLFormElement>(null);
const searchParams = useSearchParams();
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!");
},
onError: (error) => {
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}`);
}
},
});

const methods = useForm<FormValues & { apiName: string }>({
resolver: zodResolver(extendedFormSchema),
mode: "onChange",
shouldFocusError: true,
shouldUnregister: true,
defaultValues: getDefaultValues(),
defaultValues: {
...getDefaultValues(),
apiName: "",
},
});

const {
Expand All @@ -40,16 +77,39 @@ export const useKeyCreationStep = (): OnboardingStep => {
watch,
formState: { errors },
} = methods;
const onSubmit = async (data: FormValues) => {
console.info("DATA", data);

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 {
} 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 = {
workspaceId,
apiName: data.apiName,
...keyInputWithoutAuthId,
};

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(isValidWorkspaceId && apiNameValue);
const isLoading = createApiAndKey.isLoading;

const validFieldCount = apiNameValue ? 1 : 0;

return {
name: "API key",
icon: <StackPerspective2 size="sm-regular" className="text-gray-11" />,
Expand All @@ -61,20 +121,24 @@ export const useKeyCreationStep = (): OnboardingStep => {
<FormInput
{...register("apiName")}
error={errors.apiName?.message}
placeholder="Enter API key name"
description="This is just a human readable name for you and not visible to anyone else"
label="API key name"
optional
placeholder="Enter API name"
description="Choose a name for your API that helps you identify it"
label="API name"
className="w-full"
disabled={!isValidWorkspaceId || isLoading}
/>

<div className="mt-8" />

<div className="text-gray-11 text-[13px] leading-6">
Fine-tune your API key by enabling additional options
</div>

<div className="mt-5" />

<div className="flex flex-col gap-3 w-full">
<ExpandableSettings
disabled={!apiNameValue}
disabled={!isFormReady || isLoading}
icon={<Key2 className="text-gray-9 flex-shrink-0" size="sm-regular" />}
title="General Setup"
description="Configure basic API key settings like prefix, byte length, and External ID"
Expand All @@ -83,7 +147,7 @@ export const useKeyCreationStep = (): OnboardingStep => {
</ExpandableSettings>

<ExpandableSettings
disabled={!apiNameValue}
disabled={!isFormReady || isLoading}
icon={<Gauge className="text-gray-9 flex-shrink-0" size="sm-regular" />}
title="Ratelimit"
description="Set request limits per time window to control API usage frequency"
Expand All @@ -97,7 +161,7 @@ export const useKeyCreationStep = (): OnboardingStep => {
</ExpandableSettings>

<ExpandableSettings
disabled={!apiNameValue}
disabled={!isFormReady || isLoading}
icon={<ChartPie className="text-gray-9 flex-shrink-0" size="sm-regular" />}
title="Credits"
description="Set usage limits based on credits or quota to control consumption"
Expand All @@ -111,27 +175,35 @@ export const useKeyCreationStep = (): OnboardingStep => {
</ExpandableSettings>

<ExpandableSettings
disabled={!apiNameValue}
disabled={!isFormReady || isLoading}
icon={<CalendarClock className="text-gray-9 flex-shrink-0" size="sm-regular" />}
title="Expiration"
description="Set when this API key should automatically expire and become invalid"
defaultChecked={methods.watch("expiration.enabled")}
onCheckedChange={(checked) => {
methods.setValue("expiration.enabled", checked);
const currentExpiryDate = methods.getValues("expiration.data");
if (checked && !currentExpiryDate) {
methods.setValue("expiration.data", addDays(new Date(), 1));
}
methods.trigger("expiration");
}}
>
{(enabled) => <ExpirationSetup overrideEnabled={enabled} />}
</ExpandableSettings>

<ExpandableSettings
disabled={!apiNameValue}
disabled={!isFormReady || isLoading}
icon={<Code className="text-gray-9 flex-shrink-0" size="sm-regular" />}
title="Metadata"
description="Add custom key-value pairs to store additional information with your API key"
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");
}}
>
Expand All @@ -143,14 +215,24 @@ export const useKeyCreationStep = (): OnboardingStep => {
</FormProvider>
</div>
),
kind: "non-required" as const,
buttonText: "Continue",
description: "Setup your API key with extended configurations",
kind: "required" as const,
validFieldCount,
requiredFieldCount: 1,
buttonText: isLoading ? "Creating API & Key..." : "Create API & Key",
description: "Setup your API with an initial key and advanced configurations",
onStepNext: () => {
if (!isValidWorkspaceId) {
toast.error("Invalid workspace ID. Please go back and create a new workspace.");
return;
}
if (isLoading) {
return;
}
formRef.current?.requestSubmit();
},
onStepBack: () => {
console.info("Going back from workspace step");
console.info("Going back from API key creation step");
},
isLoading,
};
};
Loading