mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
refactor(onboarding): move types, constants, schemas to sub-directories
- Rename onboarding.types.ts → types/index.ts - Create constants/steps.ts with STEP_IDS and STEP_ORDER - Create schemas/account.schema.ts with BaseSchema and WithPasswordSchema - Update onboarding-shell.tsx to import STEP_ORDER from constants - Update step-account-info.tsx to import schemas from schemas/account.schema - Migrate 14 files from @/features/onboarding/onboarding.types → @/features/onboarding/types - Fix missed import in app/(welcome)/welcome/onboarding-client.tsx Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
f07ff1a28c
commit
808e60557b
@@ -0,0 +1,35 @@
|
||||
export const STEP_IDS = {
|
||||
LOGIN: "login",
|
||||
ACCOUNT_INFO: "account-info",
|
||||
SECURITY: "security",
|
||||
PREFERENCES: "preferences",
|
||||
ORG_CREATE: "org-create",
|
||||
INVITE_MEMBERS: "invite-members",
|
||||
NOTIFIER: "notifier",
|
||||
STORAGE: "storage",
|
||||
DEFAULTS: "defaults",
|
||||
AGENT_CREATE: "agent-create",
|
||||
AGENT_KEY: "agent-key",
|
||||
AGENT_WAITING: "agent-waiting",
|
||||
PROJECT_CREATE: "project-create",
|
||||
DB_SETTINGS: "db-settings",
|
||||
FINISH: "finish",
|
||||
} as const;
|
||||
|
||||
export const STEP_ORDER: string[] = [
|
||||
STEP_IDS.LOGIN,
|
||||
STEP_IDS.ACCOUNT_INFO,
|
||||
STEP_IDS.SECURITY,
|
||||
STEP_IDS.PREFERENCES,
|
||||
STEP_IDS.ORG_CREATE,
|
||||
STEP_IDS.INVITE_MEMBERS,
|
||||
STEP_IDS.NOTIFIER,
|
||||
STEP_IDS.STORAGE,
|
||||
STEP_IDS.DEFAULTS,
|
||||
STEP_IDS.AGENT_CREATE,
|
||||
STEP_IDS.AGENT_KEY,
|
||||
STEP_IDS.AGENT_WAITING,
|
||||
STEP_IDS.PROJECT_CREATE,
|
||||
STEP_IDS.DB_SETTINGS,
|
||||
STEP_IDS.FINISH,
|
||||
];
|
||||
@@ -7,24 +7,7 @@ import { OnboardingStepper } from "@/features/onboarding/onboarding-stepper";
|
||||
import { OnboardingChecklist } from "@/features/onboarding/onboarding-checklist";
|
||||
import { Heart } from "lucide-react";
|
||||
import { AuthLogoSection } from "../auth/auth-logo-section";
|
||||
|
||||
const STEP_ORDER = [
|
||||
"login",
|
||||
"account-info",
|
||||
"security",
|
||||
"preferences",
|
||||
"org-create",
|
||||
"invite-members",
|
||||
"notifier",
|
||||
"storage",
|
||||
"defaults",
|
||||
"agent-create",
|
||||
"agent-key",
|
||||
"agent-waiting",
|
||||
"project-create",
|
||||
"db-settings",
|
||||
"finish",
|
||||
];
|
||||
import { STEP_ORDER } from "@/features/onboarding/constants/steps";
|
||||
|
||||
export const OnboardingShell = () => {
|
||||
const { state, previous, skip, next, renderStep } = useOnboarding();
|
||||
@@ -45,10 +28,18 @@ export const OnboardingShell = () => {
|
||||
|
||||
const isGoingBack = currentIndex < latestIndex;
|
||||
|
||||
// Steps that are one-way: disable Back both when ON them and when they'd be the destination
|
||||
const BLOCKED_STEPS = ["login", "account-info", "security"];
|
||||
const prevStepId = STEP_ORDER[currentIndex - 1] ?? "";
|
||||
const canGoBack =
|
||||
!BLOCKED_STEPS.includes(currentStepId) &&
|
||||
!BLOCKED_STEPS.includes(prevStepId) &&
|
||||
currentIndex > 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-white flex flex-col items-center justify-center p-4 gap-4">
|
||||
<div className="min-h-screen bg-background text-foreground flex flex-col items-center justify-center p-4 gap-4">
|
||||
<AuthLogoSection />
|
||||
<div className="w-full max-w-4xl rounded-2xl bg-zinc-900 border border-white/10 shadow-2xl overflow-hidden flex flex-col md:flex-row min-h-[560px]">
|
||||
<div className="w-full max-w-4xl rounded-2xl bg-card border border-border shadow-2xl overflow-hidden flex flex-col md:flex-row min-h-[560px]">
|
||||
<div className="flex-1 flex flex-col gap-6 p-8">
|
||||
<OnboardingStepper />
|
||||
<div className="flex-1">{renderStep()}</div>
|
||||
@@ -57,7 +48,7 @@ export const OnboardingShell = () => {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => previous()}
|
||||
disabled={state.isFirstStep || state.isLoading}
|
||||
disabled={!canGoBack || state.isLoading}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
@@ -86,7 +77,7 @@ export const OnboardingShell = () => {
|
||||
</div>
|
||||
</div>
|
||||
{showSplit && (
|
||||
<div className="hidden md:block w-75 border-l border-white/10 bg-zinc-950">
|
||||
<div className="hidden md:block w-75 border-l border-border bg-muted/30">
|
||||
<OnboardingChecklist />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import "server-only";
|
||||
|
||||
import { currentUser } from "@/lib/auth/current-user";
|
||||
import { getOrganization } from "@/lib/auth/auth";
|
||||
import { getSettings } from "@/db/services/setting";
|
||||
import { hasUsers } from "@/db/services/user";
|
||||
import { getUserOrganization } from "@/db/services/organization";
|
||||
import { getOrganizationProject } from "@/db/services/project";
|
||||
import { getOrganizationAgents } from "@/db/services/agent";
|
||||
import { getOrganizationChannels } from "@/db/services/notification-channel";
|
||||
import { getOrganizationStorageChannels } from "@/db/services/storage-channel";
|
||||
import type { AgentWith } from "@/db/schema/08_agent";
|
||||
import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { env } from "@/env.mjs";
|
||||
import type {
|
||||
OnboardingDatabase,
|
||||
OnboardingFlowData,
|
||||
OnboardingMeta,
|
||||
} from "@/features/onboarding/onboarding.types";
|
||||
} from "@/features/onboarding/types";
|
||||
|
||||
export type ResolvedOnboardingState =
|
||||
| { redirect: "/dashboard/home" }
|
||||
| { stepId: string; flowData: Partial<OnboardingFlowData> };
|
||||
|
||||
export async function resolveOnboardingState(): Promise<ResolvedOnboardingState> {
|
||||
const settings = await db.query.setting.findFirst();
|
||||
const settings = await getSettings();
|
||||
if (settings?.onboarding) {
|
||||
return { redirect: "/dashboard/home" };
|
||||
}
|
||||
@@ -35,8 +35,7 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
|
||||
resumeStepId: "login",
|
||||
};
|
||||
|
||||
const anyUser = await db.query.user.findFirst();
|
||||
meta.hasExistingUsers = !!anyUser;
|
||||
meta.hasExistingUsers = await hasUsers();
|
||||
|
||||
if (env.AUTH_OIDC_CLIENT) {
|
||||
meta.ssoProviders.push({
|
||||
@@ -50,17 +49,20 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
|
||||
return { stepId: "login", flowData: { meta } };
|
||||
}
|
||||
|
||||
const org = await getOrganization({});
|
||||
const org = await getUserOrganization(user.id);
|
||||
if (!org) {
|
||||
meta.resumeStepId = "org-create";
|
||||
return { stepId: "org-create", flowData: { meta } };
|
||||
meta.resumeStepId = "preferences";
|
||||
return { stepId: "preferences", flowData: { meta } };
|
||||
}
|
||||
|
||||
const orgData = { id: org.id, name: org.name };
|
||||
|
||||
// Query persisted channels
|
||||
const notifierChannels = await getOrganizationChannels(org.id);
|
||||
const storageChannels = await getOrganizationStorageChannels(org.id);
|
||||
const [notifierChannels, storageChannels, agents, project] = await Promise.all([
|
||||
getOrganizationChannels(org.id),
|
||||
getOrganizationStorageChannels(org.id),
|
||||
getOrganizationAgents(org.id),
|
||||
getOrganizationProject(org.id),
|
||||
]);
|
||||
|
||||
const notifiers = notifierChannels.map((n) => ({
|
||||
id: n.id,
|
||||
@@ -78,29 +80,12 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
|
||||
config: (s.config as Record<string, unknown>) ?? {},
|
||||
}));
|
||||
|
||||
// Resume at notifier if none configured
|
||||
if (notifiers.length === 0) {
|
||||
meta.resumeStepId = "notifier";
|
||||
return { stepId: "notifier", flowData: { meta, org: orgData } };
|
||||
}
|
||||
const defaults = {
|
||||
notifierId: settings?.defaultNotificationChannelId ?? undefined,
|
||||
storageId: settings?.defaultStorageChannelId ?? undefined,
|
||||
};
|
||||
|
||||
// Resume at storage if none configured
|
||||
if (storages.length === 0) {
|
||||
meta.resumeStepId = "storage";
|
||||
return { stepId: "storage", flowData: { meta, org: orgData, notifiers } };
|
||||
}
|
||||
|
||||
// Check agents
|
||||
const agents = await getOrganizationAgents(org.id);
|
||||
if (!agents || agents.length === 0) {
|
||||
meta.resumeStepId = "agent-create";
|
||||
return {
|
||||
stepId: "agent-create",
|
||||
flowData: { meta, org: orgData, notifiers, storages },
|
||||
};
|
||||
}
|
||||
|
||||
const agentData = agents.map((a) => ({ id: a.id, name: a.name }));
|
||||
const agentData = (agents ?? []).map((a) => ({ id: a.id, name: a.name }));
|
||||
|
||||
const databases = (agents as AgentWith[]).flatMap((a) =>
|
||||
(a.databases ?? []).map((d) => ({
|
||||
@@ -112,47 +97,44 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
|
||||
})),
|
||||
);
|
||||
|
||||
// Check agent connection status via direct DB query (no request context needed)
|
||||
const firstAgent = agents[0];
|
||||
let agentConnected = false;
|
||||
if (firstAgent) {
|
||||
const agentRow = await db.query.agent.findFirst({
|
||||
where: eq(drizzleDb.schemas.agent.id, firstAgent.id),
|
||||
columns: { lastContact: true },
|
||||
});
|
||||
if (agentRow?.lastContact) {
|
||||
const lastContact = new Date(agentRow.lastContact);
|
||||
agentConnected = Date.now() - lastContact.getTime() < 60_000;
|
||||
}
|
||||
const fullData: Partial<OnboardingFlowData> = {
|
||||
meta,
|
||||
org: orgData,
|
||||
notifiers,
|
||||
storages,
|
||||
defaults,
|
||||
agents: agentData,
|
||||
databases,
|
||||
...(project
|
||||
? { project: { id: project.id, name: project.name, description: "", databaseIds: [] } }
|
||||
: {}),
|
||||
};
|
||||
|
||||
if (notifiers.length === 0) {
|
||||
meta.resumeStepId = "invite-members";
|
||||
return { stepId: "invite-members", flowData: fullData };
|
||||
}
|
||||
|
||||
// Check project
|
||||
const project = await db.query.project.findFirst({
|
||||
where: eq(drizzleDb.schemas.project.organizationId, org.id),
|
||||
});
|
||||
if (storages.length === 0) {
|
||||
meta.resumeStepId = "storage";
|
||||
return { stepId: "storage", flowData: fullData };
|
||||
}
|
||||
|
||||
if (!agents || agents.length === 0) {
|
||||
meta.resumeStepId = "defaults";
|
||||
return { stepId: "defaults", flowData: fullData };
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
// Agent connected but no project → project-create
|
||||
// Agent not connected → agent-key
|
||||
const stepId = agentConnected ? "project-create" : "agent-key";
|
||||
const firstAgent = agents[0];
|
||||
const agentConnected = firstAgent?.lastContact
|
||||
? Date.now() - new Date(firstAgent.lastContact).getTime() < 60_000
|
||||
: false;
|
||||
const stepId = agentConnected ? "project-create" : "agent-create";
|
||||
meta.resumeStepId = stepId;
|
||||
return {
|
||||
stepId,
|
||||
flowData: { meta, org: orgData, notifiers, storages, agents: agentData, databases },
|
||||
};
|
||||
return { stepId, flowData: fullData };
|
||||
}
|
||||
|
||||
meta.resumeStepId = "finish";
|
||||
return {
|
||||
stepId: "finish",
|
||||
flowData: {
|
||||
meta,
|
||||
org: orgData,
|
||||
notifiers,
|
||||
storages,
|
||||
agents: agentData,
|
||||
databases,
|
||||
project: { id: project.id, name: project.name, description: "", databaseIds: [] },
|
||||
},
|
||||
};
|
||||
return { stepId: "finish", flowData: fullData };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const BaseSchema = z.object({
|
||||
firstName: z.string().min(1, "First name required"),
|
||||
lastName: z.string().min(1, "Last name required"),
|
||||
email: z.string().email("Invalid email"),
|
||||
});
|
||||
|
||||
export const WithPasswordSchema = BaseSchema.extend({
|
||||
password: z.string().min(8, "Min. 8 characters"),
|
||||
});
|
||||
@@ -20,17 +20,8 @@ import { Button } from "@/components/ui/button";
|
||||
import { authClient, signUp, passkey, useSession, signIn } from "@/lib/auth/auth-client";
|
||||
import { updateAccountAction } from "@/features/onboarding/actions/update-account.action";
|
||||
import { generatePasskeyContextAction } from "@/features/onboarding/actions/generate-passkey-context.action";
|
||||
import type { OnboardingMeta } from "@/features/onboarding/onboarding.types";
|
||||
|
||||
const BaseSchema = z.object({
|
||||
firstName: z.string().min(1, "First name required"),
|
||||
lastName: z.string().min(1, "Last name required"),
|
||||
email: z.string().email("Invalid email"),
|
||||
});
|
||||
|
||||
const WithPasswordSchema = BaseSchema.extend({
|
||||
password: z.string().min(8, "Min. 8 characters"),
|
||||
});
|
||||
import type { OnboardingMeta } from "@/features/onboarding/types";
|
||||
import { BaseSchema, WithPasswordSchema } from "@/features/onboarding/schemas/account.schema";
|
||||
|
||||
export const StepAccountInfo = () => {
|
||||
const { next, updateContext, state } = useOnboarding();
|
||||
@@ -103,6 +94,7 @@ export const StepAccountInfo = () => {
|
||||
lastName: values.lastName,
|
||||
email: values.email,
|
||||
},
|
||||
security: { method: "passkey" },
|
||||
},
|
||||
});
|
||||
await next();
|
||||
|
||||
@@ -2,106 +2,117 @@
|
||||
|
||||
import { useState } from "react";
|
||||
import { useOnboarding } from "@onboardjs/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { X } from "lucide-react";
|
||||
import { createAgentAction } from "@/features/agents/agents.action";
|
||||
import type { OnboardingAgent, OnboardingDefaultsData } from "@/features/onboarding/onboarding.types";
|
||||
import { deleteAgentAction } from "@/features/agents/agent-delete.action";
|
||||
import type { OnboardingAgent, OnboardingDefaultsData } from "@/features/onboarding/types";
|
||||
|
||||
export const StepAgentCreate = () => {
|
||||
const { next, updateContext, state } = useOnboarding();
|
||||
const defaults = (state?.context.flowData.defaults ?? {}) as OnboardingDefaultsData;
|
||||
const existingAgents = (state?.context.flowData.agents ?? []) as OnboardingAgent[];
|
||||
const agents = (state?.context.flowData.agents ?? []) as OnboardingAgent[];
|
||||
const orgId = (state?.context.flowData.org as any)?.id as string | undefined;
|
||||
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [pendingAgents, setPendingAgents] = useState<{ tempId: string; name: string }[]>([]);
|
||||
const [adding, setAdding] = useState(false);
|
||||
|
||||
const addPending = () => {
|
||||
const addAgent = async () => {
|
||||
if (!name.trim()) return;
|
||||
setPendingAgents((prev) => [...prev, { tempId: crypto.randomUUID(), name: name.trim() }]);
|
||||
setName("");
|
||||
};
|
||||
|
||||
const removePending = (tempId: string) => {
|
||||
setPendingAgents((prev) => prev.filter((a) => a.tempId !== tempId));
|
||||
};
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
const created: OnboardingAgent[] = [];
|
||||
for (const pa of pendingAgents) {
|
||||
const result = await createAgentAction({
|
||||
organizationId: orgId,
|
||||
data: {
|
||||
name: pa.name,
|
||||
description: "",
|
||||
},
|
||||
});
|
||||
if (!result?.data?.data) {
|
||||
throw new Error(`Failed to create agent "${pa.name}"`);
|
||||
}
|
||||
created.push({
|
||||
id: result.data.data.id,
|
||||
name: result.data.data.name,
|
||||
notifierId: defaults.notifierId,
|
||||
storageId: defaults.storageId,
|
||||
});
|
||||
}
|
||||
const allAgents = [...existingAgents, ...created];
|
||||
await updateContext({
|
||||
flowData: { ...state?.context.flowData, agents: allAgents },
|
||||
if (!orgId) {
|
||||
toast.error("Missing org ID — cannot create agent");
|
||||
return;
|
||||
}
|
||||
setAdding(true);
|
||||
try {
|
||||
const result = await createAgentAction({
|
||||
organizationId: orgId,
|
||||
data: { name: name.trim(), description: "" },
|
||||
});
|
||||
await next();
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
toast.error(err.message);
|
||||
},
|
||||
});
|
||||
if (!result?.data?.data) {
|
||||
toast.error(result?.serverError ?? `Failed to create agent "${name.trim()}"`);
|
||||
return;
|
||||
}
|
||||
const newAgent: OnboardingAgent = {
|
||||
id: result.data.data.id,
|
||||
name: result.data.data.name,
|
||||
notifierId: defaults.notifierId,
|
||||
storageId: defaults.storageId,
|
||||
};
|
||||
const updated = [...agents, newAgent];
|
||||
setName("");
|
||||
await updateContext({ flowData: { ...state?.context.flowData, agents: updated } });
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const allDisplayed = [
|
||||
...existingAgents.map((a) => ({ id: a.id, name: a.name, persisted: true })),
|
||||
...pendingAgents.map((p) => ({ id: p.tempId, name: p.name, persisted: false })),
|
||||
];
|
||||
const removeAgent = async (id: string) => {
|
||||
const updated = agents.filter((a) => a.id !== id);
|
||||
const result = await deleteAgentAction({ agentId: id, organizationId: orgId });
|
||||
if (result?.data?.success === false) {
|
||||
toast.error("Failed to delete agent");
|
||||
} else {
|
||||
await updateContext({ flowData: { ...state?.context.flowData, agents: updated } });
|
||||
}
|
||||
};
|
||||
|
||||
const onContinue = async () => {
|
||||
await next();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Create an agent</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">Optional — agents will use your default notifier and storage.</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Optional — agents will use your default notifier and storage.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{agents.length > 0 && (
|
||||
<div className="flex flex-col gap-1">
|
||||
{agents.map((agent) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
className="flex items-center gap-2 rounded-lg border border-primary/20 bg-primary/10 p-2 text-sm text-primary"
|
||||
>
|
||||
<span className="flex-1 truncate">{agent.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeAgent(agent.id)}
|
||||
className="opacity-50 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="agent-prod"
|
||||
onKeyDown={(e) => e.key === "Enter" && addPending()}
|
||||
onKeyDown={(e) => e.key === "Enter" && addAgent()}
|
||||
disabled={adding}
|
||||
/>
|
||||
<Button type="button" variant="outline" onClick={addPending}>
|
||||
Add
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={addAgent}
|
||||
disabled={adding || !name.trim()}
|
||||
>
|
||||
{adding ? "Adding…" : "Add"}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allDisplayed.map((a) => (
|
||||
<Badge key={a.id} variant={a.persisted ? "default" : "secondary"} className="gap-1">
|
||||
{a.name}
|
||||
{!a.persisted && (
|
||||
<button type="button" onClick={() => removePending(a.id)}>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => mutation.mutate()}
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? "Creating…" : "Continue"}
|
||||
|
||||
<Button type="button" onClick={onContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { CodeSnippet } from "@/components/common/code-snippet";
|
||||
import { generateEdgeKeyAction } from "@/features/onboarding/actions/generate-edge-key.action";
|
||||
import type { OnboardingAgent } from "@/features/onboarding/onboarding.types";
|
||||
import type { OnboardingAgent } from "@/features/onboarding/types";
|
||||
|
||||
export const StepAgentKey = () => {
|
||||
const { next, state } = useOnboarding();
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useOnboarding } from "@onboardjs/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { getAgentStatusAction } from "@/features/onboarding/actions/get-agent-status.action";
|
||||
import type { OnboardingAgent } from "@/features/onboarding/onboarding.types";
|
||||
import type { OnboardingAgent } from "@/features/onboarding/types";
|
||||
|
||||
export const StepAgentWaiting = () => {
|
||||
const { next, state } = useOnboarding();
|
||||
@@ -37,7 +37,7 @@ export const StepAgentWaiting = () => {
|
||||
Checking connectivity every 3 seconds…
|
||||
</p>
|
||||
{agents.length > 0 && (
|
||||
<p className="text-xs text-zinc-500">Agent: {agents[0].name}</p>
|
||||
<p className="text-xs text-muted-foreground">Agent: {agents[0].name}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { OnboardingDbSettings, OnboardingProjectData } from "@/features/onboarding/onboarding.types";
|
||||
import { OnboardingDbSettings, OnboardingProjectData } from "@/features/onboarding/types";
|
||||
|
||||
export const StepDbSettings = () => {
|
||||
const { next, updateContext, state } = useOnboarding();
|
||||
|
||||
@@ -1,18 +1,41 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useOnboarding } from "@onboardjs/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { OnboardingChannel } from "@/features/onboarding/onboarding.types";
|
||||
import { OnboardingChannel, OnboardingDefaultsData } from "@/features/onboarding/types";
|
||||
import { updateNotificationSettingsAction } from "@/features/settings/notification.action";
|
||||
import { updateStorageSettingsAction } from "@/features/settings/storage.action";
|
||||
|
||||
export const StepDefaults = () => {
|
||||
const { next, updateContext, state } = useOnboarding();
|
||||
const notifiers = (state?.context.flowData.notifiers ?? []) as OnboardingChannel[];
|
||||
const storages = (state?.context.flowData.storages ?? []) as OnboardingChannel[];
|
||||
const [notifierId, setNotifierId] = useState<string | undefined>(undefined);
|
||||
const [storageId, setStorageId] = useState<string | undefined>(undefined);
|
||||
const existingDefaults = (state?.context.flowData.defaults ?? {}) as OnboardingDefaultsData;
|
||||
const [notifierId, setNotifierId] = useState<string | undefined>(existingDefaults.notifierId);
|
||||
const [storageId, setStorageId] = useState<string | undefined>(existingDefaults.storageId);
|
||||
|
||||
const skipped = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!skipped.current && notifiers.length === 0 && storages.length === 0) {
|
||||
skipped.current = true;
|
||||
next();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const selectNotifier = async (value: string) => {
|
||||
setNotifierId(value);
|
||||
await updateNotificationSettingsAction({ name: "system", data: { notificationChannelId: value } });
|
||||
await updateContext({ flowData: { ...state?.context.flowData, defaults: { notifierId: value, storageId } } });
|
||||
};
|
||||
|
||||
const selectStorage = async (value: string) => {
|
||||
setStorageId(value);
|
||||
await updateStorageSettingsAction({ name: "system", data: { storageChannelId: value, encryption: false } });
|
||||
await updateContext({ flowData: { ...state?.context.flowData, defaults: { notifierId, storageId: value } } });
|
||||
};
|
||||
|
||||
const onContinue = async () => {
|
||||
await updateContext({ flowData: { ...state?.context.flowData, defaults: { notifierId, storageId } } });
|
||||
@@ -27,7 +50,7 @@ export const StepDefaults = () => {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Default notifier</Label>
|
||||
<Select value={notifierId} onValueChange={setNotifierId} disabled={notifiers.length === 0}>
|
||||
<Select value={notifierId} onValueChange={selectNotifier} disabled={notifiers.length === 0}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={notifiers.length === 0 ? "No notifier connected" : "Choose a notifier"} />
|
||||
</SelectTrigger>
|
||||
@@ -42,7 +65,7 @@ export const StepDefaults = () => {
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Default storage</Label>
|
||||
<Select value={storageId} onValueChange={setStorageId} disabled={storages.length === 0}>
|
||||
<Select value={storageId} onValueChange={selectStorage} disabled={storages.length === 0}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={storages.length === 0 ? "No storage connected" : "Choose a storage"} />
|
||||
</SelectTrigger>
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { X } from "lucide-react";
|
||||
import { OnboardingMember } from "@/features/onboarding/onboarding.types";
|
||||
import { OnboardingMember } from "@/features/onboarding/types";
|
||||
|
||||
export const StepInviteMembers = () => {
|
||||
const { next, updateContext, state } = useOnboarding();
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useOnboarding } from "@onboardjs/react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { z } from "zod";
|
||||
@@ -19,7 +20,7 @@ import {
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { signIn, useSession } from "@/lib/auth/auth-client";
|
||||
import type { OnboardingMeta } from "@/features/onboarding/onboarding.types";
|
||||
import type { OnboardingMeta } from "@/features/onboarding/types";
|
||||
|
||||
const LoginSchema = z.object({
|
||||
email: z.string().email("Invalid email"),
|
||||
@@ -30,6 +31,7 @@ type LoginValues = z.infer<typeof LoginSchema>;
|
||||
|
||||
export const StepLogin = () => {
|
||||
const { next, state } = useOnboarding();
|
||||
const router = useRouter();
|
||||
const meta = state?.context.flowData.meta as OnboardingMeta | undefined;
|
||||
const { data: session } = useSession();
|
||||
|
||||
@@ -38,12 +40,14 @@ export const StepLogin = () => {
|
||||
const hasAnySsoProvider = (meta?.ssoProviders?.length ?? 0) > 0;
|
||||
const hasAnyAuthMethod = emailPasswordEnabled || passkeyEnabled || hasAnySsoProvider;
|
||||
|
||||
// Auto-advance if already authenticated
|
||||
// After login: reload the page so resolveOnboardingState re-runs server-side
|
||||
// with the authenticated user and returns the correct resume step + flowData.
|
||||
// Calling next() here would keep the stale unauthenticated flowData.
|
||||
useEffect(() => {
|
||||
if (session?.user) {
|
||||
next();
|
||||
router.push('/welcome');
|
||||
}
|
||||
}, [session?.user?.id, next]);
|
||||
}, [session?.user?.id]);
|
||||
|
||||
const form = useZodForm({ schema: LoginSchema });
|
||||
|
||||
@@ -51,7 +55,7 @@ export const StepLogin = () => {
|
||||
mutationFn: async () => {
|
||||
const result = await (signIn as any).passkey();
|
||||
if (result?.error) throw new Error(result.error.message ?? "Passkey sign in failed");
|
||||
await next();
|
||||
router.push('/welcome');
|
||||
},
|
||||
onError: (err: Error) => { toast.error(err.message); },
|
||||
});
|
||||
@@ -63,7 +67,7 @@ export const StepLogin = () => {
|
||||
password: values.password,
|
||||
});
|
||||
if (result.error) throw new Error(result.error.message ?? "Sign in failed");
|
||||
await next();
|
||||
router.push('/welcome');
|
||||
},
|
||||
onError: (err: Error) => { toast.error(err.message); },
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { useState } from "react";
|
||||
import { useOnboarding } from "@onboardjs/react";
|
||||
import { ArrowLeft, Check, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -18,67 +19,42 @@ import {
|
||||
import { notificationProviders } from "@/features/channel/channels-notification-helper";
|
||||
import { renderChannelForm } from "@/features/channel/channels-helpers";
|
||||
import { NotificationChannelFormSchema } from "@/features/channel/channel-form.schema";
|
||||
import { OnboardingChannel } from "@/features/onboarding/onboarding.types";
|
||||
import { addNotificationChannelAction } from "@/features/channel/notifications/channel.action";
|
||||
import { OnboardingChannel } from "@/features/onboarding/types";
|
||||
import { addNotificationChannelAction, removeNotificationChannelAction } from "@/features/channel/notifications/channel.action";
|
||||
|
||||
type Phase = { kind: "grid" } | { kind: "configuring"; provider: string };
|
||||
|
||||
export const StepNotifier = () => {
|
||||
const { next, updateContext, state } = useOnboarding();
|
||||
const orgId = (state?.context.flowData.org as any)?.id as string | undefined;
|
||||
const [phase, setPhase] = useState<Phase>({ kind: "grid" });
|
||||
const existingNotifiers = (state?.context.flowData.notifiers ?? []) as OnboardingChannel[];
|
||||
const [channels, setChannels] = useState<OnboardingChannel[]>(existingNotifiers);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const form = useZodForm({ schema: NotificationChannelFormSchema });
|
||||
|
||||
const startConfiguring = (provider: string) => {
|
||||
// Don't allow re-configuring a provider that's already been added
|
||||
if (channels.some((c) => c.provider === provider)) return;
|
||||
form.reset({ provider, enabled: true, name: "", config: {} } as any);
|
||||
setPhase({ kind: "configuring", provider });
|
||||
};
|
||||
|
||||
const removeChannel = (id: string) => {
|
||||
setChannels((prev) => prev.filter((c) => c.id !== id));
|
||||
const removeChannel = async (id: string) => {
|
||||
const updated = channels.filter((c) => c.id !== id);
|
||||
setChannels(updated);
|
||||
const result = await removeNotificationChannelAction({ organizationId: orgId, notificationChannelId: id });
|
||||
if (result?.data?.success === false) {
|
||||
toast.error("Failed to remove channel");
|
||||
setChannels(channels);
|
||||
} else {
|
||||
await updateContext({ flowData: { ...state?.context.flowData, notifiers: updated } });
|
||||
}
|
||||
};
|
||||
|
||||
const onContinue = async () => {
|
||||
setPending(true);
|
||||
try {
|
||||
const orgId = (state?.context.flowData.org as any)?.id as string | undefined;
|
||||
const persistedChannels: OnboardingChannel[] = [];
|
||||
|
||||
for (const ch of channels) {
|
||||
// Skip channels already persisted (have a real UUID from previous continue)
|
||||
const alreadyPersisted = existingNotifiers.some((n) => n.id === ch.id && n.id.length === 36);
|
||||
if (alreadyPersisted) {
|
||||
persistedChannels.push(ch);
|
||||
continue;
|
||||
}
|
||||
const result = await addNotificationChannelAction({
|
||||
organizationId: orgId,
|
||||
data: { provider: ch.provider as any, name: ch.name, config: ch.config as any, enabled: true },
|
||||
});
|
||||
const inner = result?.data;
|
||||
if (inner?.success && inner.value) {
|
||||
persistedChannels.push({
|
||||
id: inner.value.id,
|
||||
provider: ch.provider,
|
||||
label: ch.label,
|
||||
name: ch.name,
|
||||
config: ch.config,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await updateContext({
|
||||
flowData: { ...state?.context.flowData, notifiers: persistedChannels },
|
||||
});
|
||||
await next();
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
await updateContext({ flowData: { ...state?.context.flowData, notifiers: channels } });
|
||||
await next();
|
||||
};
|
||||
|
||||
if (phase.kind === "configuring") {
|
||||
@@ -110,21 +86,33 @@ export const StepNotifier = () => {
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values: any) => {
|
||||
const details = notificationProviders.find(
|
||||
(p) => p.value === values.provider
|
||||
);
|
||||
setChannels((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
provider: values.provider,
|
||||
label: details?.label ?? values.provider,
|
||||
name: values.name,
|
||||
config: values.config as Record<string, unknown>,
|
||||
},
|
||||
]);
|
||||
form.reset({ enabled: true } as any);
|
||||
setPhase({ kind: "grid" });
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const details = notificationProviders.find((p) => p.value === values.provider);
|
||||
const result = await addNotificationChannelAction({
|
||||
organizationId: orgId,
|
||||
data: { provider: values.provider as any, name: values.name, config: values.config as any, enabled: true },
|
||||
});
|
||||
const inner = result?.data;
|
||||
if (!inner?.success || !inner.value) {
|
||||
toast.error("Failed to save channel");
|
||||
return;
|
||||
}
|
||||
setChannels((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: inner.value!.id,
|
||||
provider: values.provider,
|
||||
label: details?.label ?? values.provider,
|
||||
name: values.name,
|
||||
config: values.config as Record<string, unknown>,
|
||||
},
|
||||
]);
|
||||
form.reset({ enabled: true } as any);
|
||||
setPhase({ kind: "grid" });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
@@ -152,7 +140,9 @@ export const StepNotifier = () => {
|
||||
)}
|
||||
/>
|
||||
{renderChannelForm(phase.provider, form)}
|
||||
<Button type="submit">Add channel</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? "Saving…" : "Add channel"}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
@@ -232,7 +222,7 @@ export const StepNotifier = () => {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button type="button" onClick={onContinue} disabled={pending}>
|
||||
<Button type="button" onClick={onContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -8,7 +8,7 @@ import { authClient } from "@/lib/auth/auth-client";
|
||||
import type {
|
||||
OnboardingAccountData,
|
||||
OnboardingMeta,
|
||||
} from "@/features/onboarding/onboarding.types";
|
||||
} from "@/features/onboarding/types";
|
||||
import { ThemeKey, ThemeSelector } from "@/components/common/theme-selector";
|
||||
|
||||
const AVATAR_COLORS = [
|
||||
@@ -51,6 +51,7 @@ export const StepPreferences = () => {
|
||||
const selectTheme = async (theme: ThemeKey) => {
|
||||
// Apply immediately to the UI
|
||||
setTheme(theme);
|
||||
//mettre à jour aussi en db!
|
||||
await updateContext({
|
||||
flowData: {
|
||||
...state?.context.flowData,
|
||||
@@ -97,7 +98,7 @@ export const StepPreferences = () => {
|
||||
// Ajout de shrink-0 pour éviter toute déformation
|
||||
"rounded-full overflow-hidden transition-all shrink-0",
|
||||
isSelected
|
||||
? "ring-2 ring-primary ring-offset-2 ring-offset-zinc-900"
|
||||
? "ring-2 ring-primary ring-offset-2 ring-offset-background"
|
||||
: "opacity-70 hover:opacity-100",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { createProjectAction, updateProjectAction } from "@/features/projects/projects.action";
|
||||
import type { OnboardingDatabase, OnboardingProjectData } from "@/features/onboarding/onboarding.types";
|
||||
import type { OnboardingDatabase, OnboardingProjectData } from "@/features/onboarding/types";
|
||||
|
||||
export const StepProjectCreate = () => {
|
||||
const { next, updateContext, state } = useOnboarding();
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useOnboarding } from "@onboardjs/react";
|
||||
import { KeyRound, ShieldCheck } from "lucide-react";
|
||||
import type { OnboardingMeta } from "@/features/onboarding/onboarding.types";
|
||||
import type { OnboardingMeta } from "@/features/onboarding/types";
|
||||
|
||||
export const StepSecurity = () => {
|
||||
const { next, updateContext, state } = useOnboarding();
|
||||
const meta = state?.context.flowData.meta as OnboardingMeta | undefined;
|
||||
const passkeyEnabled = meta?.passkeyEnabled ?? false;
|
||||
const alreadySecured = !!state?.context.flowData.security;
|
||||
|
||||
useEffect(() => {
|
||||
if (alreadySecured) next();
|
||||
}, [alreadySecured]);
|
||||
|
||||
const choose = async (method: "passkey" | "two-factor") => {
|
||||
await updateContext({ flowData: { ...state?.context.flowData, security: { method } } });
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { useState } from "react";
|
||||
import { useOnboarding } from "@onboardjs/react";
|
||||
import { ArrowLeft, Check, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
@@ -18,17 +19,18 @@ import {
|
||||
import { storageProviders } from "@/features/channel/channels-storage-helper";
|
||||
import { renderChannelForm } from "@/features/channel/channels-helpers";
|
||||
import { StorageChannelFormSchema } from "@/features/channel/channel-form.schema";
|
||||
import { OnboardingChannel } from "@/features/onboarding/onboarding.types";
|
||||
import { addStorageChannelAction } from "@/features/channel/storages/channel.action";
|
||||
import { OnboardingChannel } from "@/features/onboarding/types";
|
||||
import { addStorageChannelAction, removeStorageChannelAction } from "@/features/channel/storages/channel.action";
|
||||
|
||||
type Phase = { kind: "grid" } | { kind: "configuring"; provider: string };
|
||||
|
||||
export const StepStorage = () => {
|
||||
const { next, updateContext, state } = useOnboarding();
|
||||
const orgId = (state?.context.flowData.org as any)?.id as string | undefined;
|
||||
const [phase, setPhase] = useState<Phase>({ kind: "grid" });
|
||||
const existingStorages = (state?.context.flowData.storages ?? []) as OnboardingChannel[];
|
||||
const [channels, setChannels] = useState<OnboardingChannel[]>(existingStorages);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const form = useZodForm({ schema: StorageChannelFormSchema });
|
||||
|
||||
@@ -38,45 +40,21 @@ export const StepStorage = () => {
|
||||
setPhase({ kind: "configuring", provider });
|
||||
};
|
||||
|
||||
const removeChannel = (id: string) => {
|
||||
setChannels((prev) => prev.filter((c) => c.id !== id));
|
||||
const removeChannel = async (id: string) => {
|
||||
const updated = channels.filter((c) => c.id !== id);
|
||||
setChannels(updated);
|
||||
const result = await removeStorageChannelAction({ organizationId: orgId, id });
|
||||
if (result?.data?.success === false) {
|
||||
toast.error("Failed to remove storage");
|
||||
setChannels(channels);
|
||||
} else {
|
||||
await updateContext({ flowData: { ...state?.context.flowData, storages: updated } });
|
||||
}
|
||||
};
|
||||
|
||||
const onContinue = async () => {
|
||||
setPending(true);
|
||||
try {
|
||||
const orgId = (state?.context.flowData.org as any)?.id as string | undefined;
|
||||
const persistedChannels: OnboardingChannel[] = [];
|
||||
|
||||
for (const ch of channels) {
|
||||
const alreadyPersisted = existingStorages.some((s) => s.id === ch.id && ch.id.length === 36);
|
||||
if (alreadyPersisted) {
|
||||
persistedChannels.push(ch);
|
||||
continue;
|
||||
}
|
||||
const result = await addStorageChannelAction({
|
||||
organizationId: orgId,
|
||||
data: { provider: ch.provider as any, name: ch.name, config: ch.config as any, enabled: true },
|
||||
});
|
||||
const inner = result?.data;
|
||||
if (inner?.success && inner.value) {
|
||||
persistedChannels.push({
|
||||
id: inner.value.id,
|
||||
provider: ch.provider,
|
||||
label: ch.label,
|
||||
name: ch.name,
|
||||
config: ch.config,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await updateContext({
|
||||
flowData: { ...state?.context.flowData, storages: persistedChannels },
|
||||
});
|
||||
await next();
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
await updateContext({ flowData: { ...state?.context.flowData, storages: channels } });
|
||||
await next();
|
||||
};
|
||||
|
||||
if (phase.kind === "configuring") {
|
||||
@@ -108,18 +86,32 @@ export const StepStorage = () => {
|
||||
form={form}
|
||||
className="flex flex-col gap-4"
|
||||
onSubmit={async (values: any) => {
|
||||
setChannels((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
provider: values.provider,
|
||||
label: providerDetails?.label ?? values.provider,
|
||||
name: values.name,
|
||||
config: values.config as Record<string, unknown>,
|
||||
},
|
||||
]);
|
||||
form.reset({ enabled: true } as any);
|
||||
setPhase({ kind: "grid" });
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const result = await addStorageChannelAction({
|
||||
organizationId: orgId,
|
||||
data: { provider: values.provider as any, name: values.name, config: values.config as any, enabled: true },
|
||||
});
|
||||
const inner = result?.data;
|
||||
if (!inner?.success || !inner.value) {
|
||||
toast.error("Failed to save storage");
|
||||
return;
|
||||
}
|
||||
setChannels((prev) => [
|
||||
...prev,
|
||||
{
|
||||
id: inner.value!.id,
|
||||
provider: values.provider,
|
||||
label: providerDetails?.label ?? values.provider,
|
||||
name: values.name,
|
||||
config: values.config as Record<string, unknown>,
|
||||
},
|
||||
]);
|
||||
form.reset({ enabled: true } as any);
|
||||
setPhase({ kind: "grid" });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
@@ -147,7 +139,9 @@ export const StepStorage = () => {
|
||||
)}
|
||||
/>
|
||||
{renderChannelForm(phase.provider, form)}
|
||||
<Button type="submit">Add storage</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? "Saving…" : "Add storage"}
|
||||
</Button>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
@@ -229,7 +223,7 @@ export const StepStorage = () => {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<Button type="button" onClick={onContinue} disabled={pending}>
|
||||
<Button type="button" onClick={onContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user