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:
Théo LAGACHE
2026-06-19 10:25:17 +02:00
co-authored by Claude Sonnet 4.6
parent f07ff1a28c
commit 808e60557b
19 changed files with 347 additions and 307 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ import { useRouter } from "next/navigation";
import { OnboardingProvider } from "@onboardjs/react"; import { OnboardingProvider } from "@onboardjs/react";
import { onboardingSteps } from "@/features/onboarding/onboarding-steps"; import { onboardingSteps } from "@/features/onboarding/onboarding-steps";
import { OnboardingShell } from "@/features/onboarding/onboarding-shell"; import { OnboardingShell } from "@/features/onboarding/onboarding-shell";
import type { OnboardingFlowData } from "@/features/onboarding/onboarding.types"; import type { OnboardingFlowData } from "@/features/onboarding/types";
type Props = { type Props = {
initialStepId: string; initialStepId: string;
@@ -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,
];
+13 -22
View File
@@ -7,24 +7,7 @@ import { OnboardingStepper } from "@/features/onboarding/onboarding-stepper";
import { OnboardingChecklist } from "@/features/onboarding/onboarding-checklist"; import { OnboardingChecklist } from "@/features/onboarding/onboarding-checklist";
import { Heart } from "lucide-react"; import { Heart } from "lucide-react";
import { AuthLogoSection } from "../auth/auth-logo-section"; import { AuthLogoSection } from "../auth/auth-logo-section";
import { STEP_ORDER } from "@/features/onboarding/constants/steps";
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",
];
export const OnboardingShell = () => { export const OnboardingShell = () => {
const { state, previous, skip, next, renderStep } = useOnboarding(); const { state, previous, skip, next, renderStep } = useOnboarding();
@@ -45,10 +28,18 @@ export const OnboardingShell = () => {
const isGoingBack = currentIndex < latestIndex; 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 ( 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 /> <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"> <div className="flex-1 flex flex-col gap-6 p-8">
<OnboardingStepper /> <OnboardingStepper />
<div className="flex-1">{renderStep()}</div> <div className="flex-1">{renderStep()}</div>
@@ -57,7 +48,7 @@ export const OnboardingShell = () => {
type="button" type="button"
variant="ghost" variant="ghost"
onClick={() => previous()} onClick={() => previous()}
disabled={state.isFirstStep || state.isLoading} disabled={!canGoBack || state.isLoading}
> >
Back Back
</Button> </Button>
@@ -86,7 +77,7 @@ export const OnboardingShell = () => {
</div> </div>
</div> </div>
{showSplit && ( {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 /> <OnboardingChecklist />
</div> </div>
)} )}
+53 -71
View File
@@ -1,27 +1,27 @@
import "server-only"; import "server-only";
import { currentUser } from "@/lib/auth/current-user"; 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 { getOrganizationAgents } from "@/db/services/agent";
import { getOrganizationChannels } from "@/db/services/notification-channel"; import { getOrganizationChannels } from "@/db/services/notification-channel";
import { getOrganizationStorageChannels } from "@/db/services/storage-channel"; import { getOrganizationStorageChannels } from "@/db/services/storage-channel";
import type { AgentWith } from "@/db/schema/08_agent"; 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 { env } from "@/env.mjs";
import type { import type {
OnboardingDatabase, OnboardingDatabase,
OnboardingFlowData, OnboardingFlowData,
OnboardingMeta, OnboardingMeta,
} from "@/features/onboarding/onboarding.types"; } from "@/features/onboarding/types";
export type ResolvedOnboardingState = export type ResolvedOnboardingState =
| { redirect: "/dashboard/home" } | { redirect: "/dashboard/home" }
| { stepId: string; flowData: Partial<OnboardingFlowData> }; | { stepId: string; flowData: Partial<OnboardingFlowData> };
export async function resolveOnboardingState(): Promise<ResolvedOnboardingState> { export async function resolveOnboardingState(): Promise<ResolvedOnboardingState> {
const settings = await db.query.setting.findFirst(); const settings = await getSettings();
if (settings?.onboarding) { if (settings?.onboarding) {
return { redirect: "/dashboard/home" }; return { redirect: "/dashboard/home" };
} }
@@ -35,8 +35,7 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
resumeStepId: "login", resumeStepId: "login",
}; };
const anyUser = await db.query.user.findFirst(); meta.hasExistingUsers = await hasUsers();
meta.hasExistingUsers = !!anyUser;
if (env.AUTH_OIDC_CLIENT) { if (env.AUTH_OIDC_CLIENT) {
meta.ssoProviders.push({ meta.ssoProviders.push({
@@ -50,17 +49,20 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
return { stepId: "login", flowData: { meta } }; return { stepId: "login", flowData: { meta } };
} }
const org = await getOrganization({}); const org = await getUserOrganization(user.id);
if (!org) { if (!org) {
meta.resumeStepId = "org-create"; meta.resumeStepId = "preferences";
return { stepId: "org-create", flowData: { meta } }; return { stepId: "preferences", flowData: { meta } };
} }
const orgData = { id: org.id, name: org.name }; const orgData = { id: org.id, name: org.name };
// Query persisted channels const [notifierChannels, storageChannels, agents, project] = await Promise.all([
const notifierChannels = await getOrganizationChannels(org.id); getOrganizationChannels(org.id),
const storageChannels = await getOrganizationStorageChannels(org.id); getOrganizationStorageChannels(org.id),
getOrganizationAgents(org.id),
getOrganizationProject(org.id),
]);
const notifiers = notifierChannels.map((n) => ({ const notifiers = notifierChannels.map((n) => ({
id: n.id, id: n.id,
@@ -78,29 +80,12 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
config: (s.config as Record<string, unknown>) ?? {}, config: (s.config as Record<string, unknown>) ?? {},
})); }));
// Resume at notifier if none configured const defaults = {
if (notifiers.length === 0) { notifierId: settings?.defaultNotificationChannelId ?? undefined,
meta.resumeStepId = "notifier"; storageId: settings?.defaultStorageChannelId ?? undefined,
return { stepId: "notifier", flowData: { meta, org: orgData } };
}
// 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) => const databases = (agents as AgentWith[]).flatMap((a) =>
(a.databases ?? []).map((d) => ({ (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 fullData: Partial<OnboardingFlowData> = {
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;
}
}
// Check project
const project = await db.query.project.findFirst({
where: eq(drizzleDb.schemas.project.organizationId, org.id),
});
if (!project) {
// Agent connected but no project → project-create
// Agent not connected → agent-key
const stepId = agentConnected ? "project-create" : "agent-key";
meta.resumeStepId = stepId;
return {
stepId,
flowData: { meta, org: orgData, notifiers, storages, agents: agentData, databases },
};
}
meta.resumeStepId = "finish";
return {
stepId: "finish",
flowData: {
meta, meta,
org: orgData, org: orgData,
notifiers, notifiers,
storages, storages,
defaults,
agents: agentData, agents: agentData,
databases, databases,
project: { id: project.id, name: project.name, description: "", databaseIds: [] }, ...(project
}, ? { project: { id: project.id, name: project.name, description: "", databaseIds: [] } }
: {}),
}; };
if (notifiers.length === 0) {
meta.resumeStepId = "invite-members";
return { stepId: "invite-members", flowData: fullData };
}
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) {
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: fullData };
}
meta.resumeStepId = "finish";
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 { authClient, signUp, passkey, useSession, signIn } from "@/lib/auth/auth-client";
import { updateAccountAction } from "@/features/onboarding/actions/update-account.action"; import { updateAccountAction } from "@/features/onboarding/actions/update-account.action";
import { generatePasskeyContextAction } from "@/features/onboarding/actions/generate-passkey-context.action"; import { generatePasskeyContextAction } from "@/features/onboarding/actions/generate-passkey-context.action";
import type { OnboardingMeta } from "@/features/onboarding/onboarding.types"; import type { OnboardingMeta } from "@/features/onboarding/types";
import { BaseSchema, WithPasswordSchema } from "@/features/onboarding/schemas/account.schema";
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"),
});
export const StepAccountInfo = () => { export const StepAccountInfo = () => {
const { next, updateContext, state } = useOnboarding(); const { next, updateContext, state } = useOnboarding();
@@ -103,6 +94,7 @@ export const StepAccountInfo = () => {
lastName: values.lastName, lastName: values.lastName,
email: values.email, email: values.email,
}, },
security: { method: "passkey" },
}, },
}); });
await next(); await next();
@@ -2,106 +2,117 @@
import { useState } from "react"; import { useState } from "react";
import { useOnboarding } from "@onboardjs/react"; import { useOnboarding } from "@onboardjs/react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { createAgentAction } from "@/features/agents/agents.action"; 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 = () => { export const StepAgentCreate = () => {
const { next, updateContext, state } = useOnboarding(); const { next, updateContext, state } = useOnboarding();
const defaults = (state?.context.flowData.defaults ?? {}) as OnboardingDefaultsData; 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 orgId = (state?.context.flowData.org as any)?.id as string | undefined;
const [name, setName] = useState(""); 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; if (!name.trim()) return;
setPendingAgents((prev) => [...prev, { tempId: crypto.randomUUID(), name: name.trim() }]); if (!orgId) {
setName(""); toast.error("Missing org ID — cannot create agent");
}; return;
}
const removePending = (tempId: string) => { setAdding(true);
setPendingAgents((prev) => prev.filter((a) => a.tempId !== tempId)); try {
};
const mutation = useMutation({
mutationFn: async () => {
const created: OnboardingAgent[] = [];
for (const pa of pendingAgents) {
const result = await createAgentAction({ const result = await createAgentAction({
organizationId: orgId, organizationId: orgId,
data: { data: { name: name.trim(), description: "" },
name: pa.name,
description: "",
},
}); });
if (!result?.data?.data) { if (!result?.data?.data) {
throw new Error(`Failed to create agent "${pa.name}"`); toast.error(result?.serverError ?? `Failed to create agent "${name.trim()}"`);
return;
} }
created.push({ const newAgent: OnboardingAgent = {
id: result.data.data.id, id: result.data.data.id,
name: result.data.data.name, name: result.data.data.name,
notifierId: defaults.notifierId, notifierId: defaults.notifierId,
storageId: defaults.storageId, storageId: defaults.storageId,
}); };
const updated = [...agents, newAgent];
setName("");
await updateContext({ flowData: { ...state?.context.flowData, agents: updated } });
} finally {
setAdding(false);
} }
const allAgents = [...existingAgents, ...created]; };
await updateContext({
flowData: { ...state?.context.flowData, agents: allAgents },
});
await next();
},
onError: (err: Error) => {
toast.error(err.message);
},
});
const allDisplayed = [ const removeAgent = async (id: string) => {
...existingAgents.map((a) => ({ id: a.id, name: a.name, persisted: true })), const updated = agents.filter((a) => a.id !== id);
...pendingAgents.map((p) => ({ id: p.tempId, name: p.name, persisted: false })), 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 ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div> <div>
<h1 className="text-2xl font-semibold">Create an agent</h1> <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> </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"> <div className="flex gap-2">
<Input <Input
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
placeholder="agent-prod" 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>
</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 <Button
type="button" type="button"
onClick={() => mutation.mutate()} variant="outline"
disabled={mutation.isPending} onClick={addAgent}
disabled={adding || !name.trim()}
> >
{mutation.isPending ? "Creating…" : "Continue"} {adding ? "Adding…" : "Add"}
</Button>
</div>
<Button type="button" onClick={onContinue}>
Continue
</Button> </Button>
</div> </div>
); );
@@ -6,7 +6,7 @@ import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { CodeSnippet } from "@/components/common/code-snippet"; import { CodeSnippet } from "@/components/common/code-snippet";
import { generateEdgeKeyAction } from "@/features/onboarding/actions/generate-edge-key.action"; 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 = () => { export const StepAgentKey = () => {
const { next, state } = useOnboarding(); const { next, state } = useOnboarding();
@@ -5,7 +5,7 @@ import { useOnboarding } from "@onboardjs/react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { getAgentStatusAction } from "@/features/onboarding/actions/get-agent-status.action"; 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 = () => { export const StepAgentWaiting = () => {
const { next, state } = useOnboarding(); const { next, state } = useOnboarding();
@@ -37,7 +37,7 @@ export const StepAgentWaiting = () => {
Checking connectivity every 3 seconds Checking connectivity every 3 seconds
</p> </p>
{agents.length > 0 && ( {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> </div>
); );
@@ -6,7 +6,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { OnboardingDbSettings, OnboardingProjectData } from "@/features/onboarding/onboarding.types"; import { OnboardingDbSettings, OnboardingProjectData } from "@/features/onboarding/types";
export const StepDbSettings = () => { export const StepDbSettings = () => {
const { next, updateContext, state } = useOnboarding(); const { next, updateContext, state } = useOnboarding();
@@ -1,18 +1,41 @@
"use client"; "use client";
import { useState } from "react"; import { useState, useEffect, useRef } from "react";
import { useOnboarding } from "@onboardjs/react"; import { useOnboarding } from "@onboardjs/react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; 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 = () => { export const StepDefaults = () => {
const { next, updateContext, state } = useOnboarding(); const { next, updateContext, state } = useOnboarding();
const notifiers = (state?.context.flowData.notifiers ?? []) as OnboardingChannel[]; const notifiers = (state?.context.flowData.notifiers ?? []) as OnboardingChannel[];
const storages = (state?.context.flowData.storages ?? []) as OnboardingChannel[]; const storages = (state?.context.flowData.storages ?? []) as OnboardingChannel[];
const [notifierId, setNotifierId] = useState<string | undefined>(undefined); const existingDefaults = (state?.context.flowData.defaults ?? {}) as OnboardingDefaultsData;
const [storageId, setStorageId] = useState<string | undefined>(undefined); 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 () => { const onContinue = async () => {
await updateContext({ flowData: { ...state?.context.flowData, defaults: { notifierId, storageId } } }); await updateContext({ flowData: { ...state?.context.flowData, defaults: { notifierId, storageId } } });
@@ -27,7 +50,7 @@ export const StepDefaults = () => {
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Default notifier</Label> <Label>Default notifier</Label>
<Select value={notifierId} onValueChange={setNotifierId} disabled={notifiers.length === 0}> <Select value={notifierId} onValueChange={selectNotifier} disabled={notifiers.length === 0}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={notifiers.length === 0 ? "No notifier connected" : "Choose a notifier"} /> <SelectValue placeholder={notifiers.length === 0 ? "No notifier connected" : "Choose a notifier"} />
</SelectTrigger> </SelectTrigger>
@@ -42,7 +65,7 @@ export const StepDefaults = () => {
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Default storage</Label> <Label>Default storage</Label>
<Select value={storageId} onValueChange={setStorageId} disabled={storages.length === 0}> <Select value={storageId} onValueChange={selectStorage} disabled={storages.length === 0}>
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder={storages.length === 0 ? "No storage connected" : "Choose a storage"} /> <SelectValue placeholder={storages.length === 0 ? "No storage connected" : "Choose a storage"} />
</SelectTrigger> </SelectTrigger>
@@ -6,7 +6,7 @@ import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { X } from "lucide-react"; import { X } from "lucide-react";
import { OnboardingMember } from "@/features/onboarding/onboarding.types"; import { OnboardingMember } from "@/features/onboarding/types";
export const StepInviteMembers = () => { export const StepInviteMembers = () => {
const { next, updateContext, state } = useOnboarding(); const { next, updateContext, state } = useOnboarding();
+10 -6
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useOnboarding } from "@onboardjs/react"; import { useOnboarding } from "@onboardjs/react";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { z } from "zod"; import { z } from "zod";
@@ -19,7 +20,7 @@ import {
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { signIn, useSession } from "@/lib/auth/auth-client"; 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({ const LoginSchema = z.object({
email: z.string().email("Invalid email"), email: z.string().email("Invalid email"),
@@ -30,6 +31,7 @@ type LoginValues = z.infer<typeof LoginSchema>;
export const StepLogin = () => { export const StepLogin = () => {
const { next, state } = useOnboarding(); const { next, state } = useOnboarding();
const router = useRouter();
const meta = state?.context.flowData.meta as OnboardingMeta | undefined; const meta = state?.context.flowData.meta as OnboardingMeta | undefined;
const { data: session } = useSession(); const { data: session } = useSession();
@@ -38,12 +40,14 @@ export const StepLogin = () => {
const hasAnySsoProvider = (meta?.ssoProviders?.length ?? 0) > 0; const hasAnySsoProvider = (meta?.ssoProviders?.length ?? 0) > 0;
const hasAnyAuthMethod = emailPasswordEnabled || passkeyEnabled || hasAnySsoProvider; 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(() => { useEffect(() => {
if (session?.user) { if (session?.user) {
next(); router.push('/welcome');
} }
}, [session?.user?.id, next]); }, [session?.user?.id]);
const form = useZodForm({ schema: LoginSchema }); const form = useZodForm({ schema: LoginSchema });
@@ -51,7 +55,7 @@ export const StepLogin = () => {
mutationFn: async () => { mutationFn: async () => {
const result = await (signIn as any).passkey(); const result = await (signIn as any).passkey();
if (result?.error) throw new Error(result.error.message ?? "Passkey sign in failed"); 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); }, onError: (err: Error) => { toast.error(err.message); },
}); });
@@ -63,7 +67,7 @@ export const StepLogin = () => {
password: values.password, password: values.password,
}); });
if (result.error) throw new Error(result.error.message ?? "Sign in failed"); if (result.error) throw new Error(result.error.message ?? "Sign in failed");
await next(); router.push('/welcome');
}, },
onError: (err: Error) => { toast.error(err.message); }, onError: (err: Error) => { toast.error(err.message); },
}); });
+36 -46
View File
@@ -4,6 +4,7 @@
import { useState } from "react"; import { useState } from "react";
import { useOnboarding } from "@onboardjs/react"; import { useOnboarding } from "@onboardjs/react";
import { ArrowLeft, Check, X } from "lucide-react"; import { ArrowLeft, Check, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
@@ -18,67 +19,42 @@ import {
import { notificationProviders } from "@/features/channel/channels-notification-helper"; import { notificationProviders } from "@/features/channel/channels-notification-helper";
import { renderChannelForm } from "@/features/channel/channels-helpers"; import { renderChannelForm } from "@/features/channel/channels-helpers";
import { NotificationChannelFormSchema } from "@/features/channel/channel-form.schema"; import { NotificationChannelFormSchema } from "@/features/channel/channel-form.schema";
import { OnboardingChannel } from "@/features/onboarding/onboarding.types"; import { OnboardingChannel } from "@/features/onboarding/types";
import { addNotificationChannelAction } from "@/features/channel/notifications/channel.action"; import { addNotificationChannelAction, removeNotificationChannelAction } from "@/features/channel/notifications/channel.action";
type Phase = { kind: "grid" } | { kind: "configuring"; provider: string }; type Phase = { kind: "grid" } | { kind: "configuring"; provider: string };
export const StepNotifier = () => { export const StepNotifier = () => {
const { next, updateContext, state } = useOnboarding(); 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 [phase, setPhase] = useState<Phase>({ kind: "grid" });
const existingNotifiers = (state?.context.flowData.notifiers ?? []) as OnboardingChannel[]; const existingNotifiers = (state?.context.flowData.notifiers ?? []) as OnboardingChannel[];
const [channels, setChannels] = useState<OnboardingChannel[]>(existingNotifiers); const [channels, setChannels] = useState<OnboardingChannel[]>(existingNotifiers);
const [pending, setPending] = useState(false); const [submitting, setSubmitting] = useState(false);
const form = useZodForm({ schema: NotificationChannelFormSchema }); const form = useZodForm({ schema: NotificationChannelFormSchema });
const startConfiguring = (provider: string) => { const startConfiguring = (provider: string) => {
// Don't allow re-configuring a provider that's already been added
if (channels.some((c) => c.provider === provider)) return; if (channels.some((c) => c.provider === provider)) return;
form.reset({ provider, enabled: true, name: "", config: {} } as any); form.reset({ provider, enabled: true, name: "", config: {} } as any);
setPhase({ kind: "configuring", provider }); setPhase({ kind: "configuring", provider });
}; };
const removeChannel = (id: string) => { const removeChannel = async (id: string) => {
setChannels((prev) => prev.filter((c) => c.id !== id)); 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 () => { const onContinue = async () => {
setPending(true); await updateContext({ flowData: { ...state?.context.flowData, notifiers: channels } });
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(); await next();
} finally {
setPending(false);
}
}; };
if (phase.kind === "configuring") { if (phase.kind === "configuring") {
@@ -110,13 +86,22 @@ export const StepNotifier = () => {
form={form} form={form}
className="flex flex-col gap-4" className="flex flex-col gap-4"
onSubmit={async (values: any) => { onSubmit={async (values: any) => {
const details = notificationProviders.find( setSubmitting(true);
(p) => p.value === values.provider 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) => [ setChannels((prev) => [
...prev, ...prev,
{ {
id: crypto.randomUUID(), id: inner.value!.id,
provider: values.provider, provider: values.provider,
label: details?.label ?? values.provider, label: details?.label ?? values.provider,
name: values.name, name: values.name,
@@ -125,6 +110,9 @@ export const StepNotifier = () => {
]); ]);
form.reset({ enabled: true } as any); form.reset({ enabled: true } as any);
setPhase({ kind: "grid" }); setPhase({ kind: "grid" });
} finally {
setSubmitting(false);
}
}} }}
> >
<FormField <FormField
@@ -152,7 +140,9 @@ export const StepNotifier = () => {
)} )}
/> />
{renderChannelForm(phase.provider, form)} {renderChannelForm(phase.provider, form)}
<Button type="submit">Add channel</Button> <Button type="submit" disabled={submitting}>
{submitting ? "Saving…" : "Add channel"}
</Button>
</Form> </Form>
</div> </div>
); );
@@ -232,7 +222,7 @@ export const StepNotifier = () => {
})} })}
</div> </div>
<Button type="button" onClick={onContinue} disabled={pending}> <Button type="button" onClick={onContinue}>
Continue Continue
</Button> </Button>
</div> </div>
@@ -8,7 +8,7 @@ import { authClient } from "@/lib/auth/auth-client";
import type { import type {
OnboardingAccountData, OnboardingAccountData,
OnboardingMeta, OnboardingMeta,
} from "@/features/onboarding/onboarding.types"; } from "@/features/onboarding/types";
import { ThemeKey, ThemeSelector } from "@/components/common/theme-selector"; import { ThemeKey, ThemeSelector } from "@/components/common/theme-selector";
const AVATAR_COLORS = [ const AVATAR_COLORS = [
@@ -51,6 +51,7 @@ export const StepPreferences = () => {
const selectTheme = async (theme: ThemeKey) => { const selectTheme = async (theme: ThemeKey) => {
// Apply immediately to the UI // Apply immediately to the UI
setTheme(theme); setTheme(theme);
//mettre à jour aussi en db!
await updateContext({ await updateContext({
flowData: { flowData: {
...state?.context.flowData, ...state?.context.flowData,
@@ -97,7 +98,7 @@ export const StepPreferences = () => {
// Ajout de shrink-0 pour éviter toute déformation // Ajout de shrink-0 pour éviter toute déformation
"rounded-full overflow-hidden transition-all shrink-0", "rounded-full overflow-hidden transition-all shrink-0",
isSelected 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", : "opacity-70 hover:opacity-100",
)} )}
> >
@@ -10,7 +10,7 @@ import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { createProjectAction, updateProjectAction } from "@/features/projects/projects.action"; 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 = () => { export const StepProjectCreate = () => {
const { next, updateContext, state } = useOnboarding(); const { next, updateContext, state } = useOnboarding();
@@ -1,13 +1,19 @@
"use client"; "use client";
import { useEffect } from "react";
import { useOnboarding } from "@onboardjs/react"; import { useOnboarding } from "@onboardjs/react";
import { KeyRound, ShieldCheck } from "lucide-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 = () => { export const StepSecurity = () => {
const { next, updateContext, state } = useOnboarding(); const { next, updateContext, state } = useOnboarding();
const meta = state?.context.flowData.meta as OnboardingMeta | undefined; const meta = state?.context.flowData.meta as OnboardingMeta | undefined;
const passkeyEnabled = meta?.passkeyEnabled ?? false; const passkeyEnabled = meta?.passkeyEnabled ?? false;
const alreadySecured = !!state?.context.flowData.security;
useEffect(() => {
if (alreadySecured) next();
}, [alreadySecured]);
const choose = async (method: "passkey" | "two-factor") => { const choose = async (method: "passkey" | "two-factor") => {
await updateContext({ flowData: { ...state?.context.flowData, security: { method } } }); await updateContext({ flowData: { ...state?.context.flowData, security: { method } } });
+35 -41
View File
@@ -4,6 +4,7 @@
import { useState } from "react"; import { useState } from "react";
import { useOnboarding } from "@onboardjs/react"; import { useOnboarding } from "@onboardjs/react";
import { ArrowLeft, Check, X } from "lucide-react"; import { ArrowLeft, Check, X } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { import {
@@ -18,17 +19,18 @@ import {
import { storageProviders } from "@/features/channel/channels-storage-helper"; import { storageProviders } from "@/features/channel/channels-storage-helper";
import { renderChannelForm } from "@/features/channel/channels-helpers"; import { renderChannelForm } from "@/features/channel/channels-helpers";
import { StorageChannelFormSchema } from "@/features/channel/channel-form.schema"; import { StorageChannelFormSchema } from "@/features/channel/channel-form.schema";
import { OnboardingChannel } from "@/features/onboarding/onboarding.types"; import { OnboardingChannel } from "@/features/onboarding/types";
import { addStorageChannelAction } from "@/features/channel/storages/channel.action"; import { addStorageChannelAction, removeStorageChannelAction } from "@/features/channel/storages/channel.action";
type Phase = { kind: "grid" } | { kind: "configuring"; provider: string }; type Phase = { kind: "grid" } | { kind: "configuring"; provider: string };
export const StepStorage = () => { export const StepStorage = () => {
const { next, updateContext, state } = useOnboarding(); 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 [phase, setPhase] = useState<Phase>({ kind: "grid" });
const existingStorages = (state?.context.flowData.storages ?? []) as OnboardingChannel[]; const existingStorages = (state?.context.flowData.storages ?? []) as OnboardingChannel[];
const [channels, setChannels] = useState<OnboardingChannel[]>(existingStorages); const [channels, setChannels] = useState<OnboardingChannel[]>(existingStorages);
const [pending, setPending] = useState(false); const [submitting, setSubmitting] = useState(false);
const form = useZodForm({ schema: StorageChannelFormSchema }); const form = useZodForm({ schema: StorageChannelFormSchema });
@@ -38,45 +40,21 @@ export const StepStorage = () => {
setPhase({ kind: "configuring", provider }); setPhase({ kind: "configuring", provider });
}; };
const removeChannel = (id: string) => { const removeChannel = async (id: string) => {
setChannels((prev) => prev.filter((c) => c.id !== id)); 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 () => { const onContinue = async () => {
setPending(true); await updateContext({ flowData: { ...state?.context.flowData, storages: channels } });
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(); await next();
} finally {
setPending(false);
}
}; };
if (phase.kind === "configuring") { if (phase.kind === "configuring") {
@@ -108,10 +86,21 @@ export const StepStorage = () => {
form={form} form={form}
className="flex flex-col gap-4" className="flex flex-col gap-4"
onSubmit={async (values: any) => { onSubmit={async (values: any) => {
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) => [ setChannels((prev) => [
...prev, ...prev,
{ {
id: crypto.randomUUID(), id: inner.value!.id,
provider: values.provider, provider: values.provider,
label: providerDetails?.label ?? values.provider, label: providerDetails?.label ?? values.provider,
name: values.name, name: values.name,
@@ -120,6 +109,9 @@ export const StepStorage = () => {
]); ]);
form.reset({ enabled: true } as any); form.reset({ enabled: true } as any);
setPhase({ kind: "grid" }); setPhase({ kind: "grid" });
} finally {
setSubmitting(false);
}
}} }}
> >
<FormField <FormField
@@ -147,7 +139,9 @@ export const StepStorage = () => {
)} )}
/> />
{renderChannelForm(phase.provider, form)} {renderChannelForm(phase.provider, form)}
<Button type="submit">Add storage</Button> <Button type="submit" disabled={submitting}>
{submitting ? "Saving…" : "Add storage"}
</Button>
</Form> </Form>
</div> </div>
); );
@@ -229,7 +223,7 @@ export const StepStorage = () => {
})} })}
</div> </div>
<Button type="button" onClick={onContinue} disabled={pending}> <Button type="button" onClick={onContinue}>
Continue Continue
</Button> </Button>
</div> </div>