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
@@ -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,
|
||||||
|
];
|
||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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); },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 } } });
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user