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
@@ -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 { 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>
)}
+53 -71
View File
@@ -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();
+10 -6
View File
@@ -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); },
});
+48 -58
View File
@@ -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 } } });
+47 -53
View File
@@ -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>