feat/onboarding (#337)

* feat(onboarding): add cookie helper, types and mock data

* feat(onboarding): add stepper, contextual preview and shell

* feat(onboarding): add sso-gate step

* feat(onboarding): add account-info step with real signup

* feat(onboarding): add security step

* feat(onboarding): add preferences step

* fix(onboarding): validate avatar upload size/type and handle FileReader errors

* feat(onboarding): add org-create step

* feat(onboarding): add invite-members step

* feat(onboarding): add notifier step reusing real provider list

* feat(onboarding): add storage step reusing real provider list

* feat(onboarding): add defaults step

* feat(onboarding): add agent-create step

* feat(onboarding): add agent-waiting step with simulated ping

* feat(onboarding): add project-create step

* feat(onboarding): add db-settings step with per-db repeat

* feat(onboarding): add finish step with confetti

* feat(onboarding): wire full step graph

* feat(onboarding): add /welcome route

* feat(onboarding): gate landing, auth and dashboard routes behind onboarding cookie

* fix(onboarding): call next() in finish step so onFlowComplete fires and cookie is written

* docs: add onboarding channel config + style refactor spec

* docs: add conditional step skip when no agents to spec

* docs(onboarding): add implementation plan for channel config flow and style refactor

* feat(onboarding): extend OnboardingChannel with name and config fields

* feat(onboarding): add two-phase config flow and org-combobox style to notifier step

* fix(onboarding): prevent duplicate notifier providers and guard Continue button

* feat(onboarding): add two-phase config flow and org-combobox style to storage step

* style(onboarding): restyle sso-gate provider buttons to org-combobox card style

* style(onboarding): restyle security method choice to org-combobox card style

* style(onboarding): restyle theme toggles in preferences step to org-combobox card style

* style(onboarding): restyle DB toggle buttons in project-create step to org-combobox card style

* feat(onboarding): skip agent-waiting/project-create/db-settings when no agents created

* feat(onboarding): update type system — meta, firstName/lastName, org.id, databases

* feat(onboarding): add settings.onboarding column + markOnboardingDoneAction

* feat(onboarding): add resolveOnboardingState server function

* fix(onboarding): remove unused imports from onboarding-state.ts

* style(onboarding): add branding to shell, fix progress bar full width

* feat(onboarding): replace preview panel with stepper checklist

* feat(onboarding): add step-login replacing sso-gate

* fix(onboarding): step-login — conform to global mutation pattern, handle SSO errors

* feat(onboarding): step-account-info — firstName/lastName, conditional password, findOrCreate

* feat(onboarding): avatar API route (next/og) + AvatarPicker in preferences step

* feat(onboarding): step-security reads passkeyEnabled from flowData.meta

* feat(onboarding): step-org-create calls real createOrganizationAction

* fix(onboarding): step-org-create — simplify error handling, throw on empty name

* feat(onboarding): notifier + storage steps call real actions + findOrCreate on back

* feat(onboarding): step-agent-create calls real createAgentAction + findOrCreate

* feat(onboarding): step-agent-waiting polls real agent lastContact via useQuery

* feat(onboarding): add step-agent-key with edgeKey generation via server action

* feat(onboarding): step-project-create uses real action + real DBs from flowData

* feat(onboarding): wire steps — add login + agent-key, remove sso-gate

* feat(onboarding): page.tsx uses resolveOnboardingState — server-driven resume

* chore(onboarding): delete mock/cookie/preview/sso-gate files — replaced by real implementation

* feat(onboarding): add emailPasswordEnabled to meta, remove logoDataUrl from org

* feat(onboarding): DB-driven resume — query notifiers/storages, check agent lastContact

* feat(onboarding): agent-waiting step is now skippable

* feat(onboarding): login — auto-advance if session, callbackURL on SSO, hide email/pw when disabled, empty state

* fix(onboarding): step-login — add next to useEffect deps, default emailPasswordEnabled to false

* fix(onboarding): verify session after passkey registration, fallback to signIn.passkey()

* feat(onboarding): theme applied dynamically via setTheme, avatar saved to user.image

* feat(onboarding): remove org logo upload — orgs have no logo

* feat(onboarding): show Next button in shell when navigating backwards

* docs: add onboarding refactor design spec

* docs: add onboarding refactor implementation plan

* refactor(onboarding): extract useCreateAgent and useDeleteAgent hooks

* refactor(onboarding): extract useCreateOrg and useCreateProject hooks

* fix(onboarding): trim project name, standardize error narrowing in org/project hooks

* fix(onboarding): use narrowed createData.value in use-create-org

* refactor(onboarding): extract useUpdateAccount hook

* refactor(onboarding): extract useAddNotifier and useRemoveNotifier hooks

* refactor(onboarding): extract useAddStorage and useRemoveStorage hooks

* refactor(onboarding): extract useAgentStatus and useGenerateEdgeKey hooks

* refactor(onboarding): extract useMarkOnboardingDone hook, remove .gitkeep files

* docs: add onboarding theme persistence & progress bar fix spec

* docs: add onboarding theme & progress bar implementation plan

* fix(onboarding): persist theme to db on selection in StepPreferences

* fix(onboarding): recalculate progress bar from full STEP_ORDER position

* fix(theme): sync user theme from DB on all routes, not only dashboard

* fix(theme): use current theme as fallback in preferences, prevent session override of localStorage

* fix(onboarding): correct resume steps and skip agent-waiting flash when agent is connected

* fix(onboarding): properly resolve onboarding state, remove redirect trap in step-defaults, block waiting flash

* fix(onboarding): resume at db-settings instead of finish if no database exists for the project

* fix(onboarding): redirect to agent-create if project exists but no agent is found

* fix(onboarding): allow going from agent-create to project-create if a project already exists

* fix(onboarding): skip intermediate agent steps when going back from project-create to agent-create

* fix(onboarding): skip default step when going back from agent-create if no notifiers and storages exist

* feat(onboarding): allow user to choose between passkey and password during registration if both are enabled

* feat(onboarding): replace tabs with single form and multiple buttons for auth choice

* style(onboarding): improve empty auth state UI with lucide icon and better layout

* fix(onboarding): completely hide back button on the first step instead of disabling it

* fix(onboarding): skip default step if no notifiers and storages exist when advancing from storage step

* fix(onboarding): correctly show select placeholder when default value does not exist in the list

* refactor(onboarding): remove duplicate generate-edge-key action and use core utility directly

* feat(onboarding): compute edgeKey directly on the server side to avoid lazy loading

* fix(onboarding): skip db-settings step if no database is found on the agent

* fix(onboarding): do not skip db settings if agent is connected but no databases found

* refactor(onboarding): skip db settings entirely if no database is selected in project create

* feat(onboarding): auto-save database selections on project create instantly

* style(onboarding): replace button opacity flash with specific spinner animation on db select

* feat(onboarding): extend OnboardingDbSettings with retention, schedule, and policy fields

* feat(onboarding): add applyOnboardingDbSettingsAction server action

* feat(onboarding): add BackupScheduleSelector component

* feat(onboarding): rewrite step-db-settings with three-level phase navigation and full policy persistence

* fix(onboarding): wire applyMutation.mutateAsync everywhere and stabilize list keys

* docs(sdd): append hotfix summary for applyMutation and list keys

* fix(onboarding): address final review findings - atomicity, scheduling guard, apply-all recovery, client validation

* add: onboarding

* fix(migration): merge

* fix(migration): apply for onboarding

* fix(settings): make avatar selector, dicebear picker and tab bar responsive

* chore: checkpoint before features reorganization

* Task 2: Add migration script for features directory reorganization

* refactor(agents): reorganize into actions/components/schemas/hooks

* refactor(auth): reorganize into actions/components/schemas

* refactor(channel): reorganize into components/schemas, nest notifications+storages under components

* refactor(database): reorganize into actions/components/schemas

* refactor(layout): move all components to components/

* refactor(logs): reorganize into components/types

* refactor(migration): reorganize into actions/components

* refactor(notifications): reorganize into components/types/utils

* refactor(organizations): reorganize into actions/components/schemas/hooks/utils

* refactor(profile): reorganize into actions/components/schemas

* refactor(projects): reorganize into actions/components/schemas

* refactor(settings): reorganize into actions/components/schemas/hooks

* refactor(statistics): reorganize into components/utils

* refactor(storages): reorganize into types/utils

* refactor(theme): move all components to components/

* refactor(updates): reorganize into components/hooks

* refactor(upload): move action to actions/

* refactor(users): reorganize into actions/components/schemas

* fix: update broken imports after features reorganization (double-quote relative imports, channel subdir absolute paths)

* fix: .gitignore

* fix: onboarding

* fix: removed dead files

* feat: backfill onboarding flag for instances with existing data

* fix: persist onboarding=true on boot when SKIP_ONBOARDING is set

* feat: remove avatarMode/dicebearStyle from DB schema and types

* feat: simplify resolveAvatarUrl — custom image first, gravatar fallback

* feat: allow avatar upload regardless of mode

* feat: remove avatarMode from layout and profile prop chains

* feat: remove avatar mode admin tab and supporting files

* feat: remove avatar mode selector from onboarding defaults step

* fix: remove preferences onboarding step and fix routing

* fix: remove stale preferences entry from onboarding checklist

* feat: add avatar reset button and explanatory tooltip on profile page

* fix: move avatar tooltip to top-right badge overlay on avatar

* fix: use small destructive button for avatar removal

* fix

* Update step-defaults.tsx

* fix: default dev password for keycloak refer to env

* fix

* fix(migration): refactoring

* fix: api agent helpers.ts

* Update .env.example

---------

Co-authored-by: Théo LAGACHE <theo.lagache@soluce-technologies.com>
Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
This commit is contained in:
Théo LAGACHE
2026-06-27 17:08:38 +02:00
committed by GitHub
co-authored by Théo LAGACHE charles-gauthereau
parent 40d3228db6
commit 40247efddd
451 changed files with 15904 additions and 5054 deletions
@@ -0,0 +1,108 @@
"use server";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import { eq } from "drizzle-orm";
import { userAction } from "@/lib/safe-actions/actions";
import { ApplyDbSettingsSchema } from "@/features/onboarding/schemas/db-settings.schema";
export const applyOnboardingDbSettingsAction = userAction
.schema(ApplyDbSettingsSchema)
.action(async ({ parsedInput }) => {
const {
databaseId,
section,
retention,
backupMethod,
backupCron,
notificationPolicies,
storagePolicies,
} = parsedInput;
const applyRetention = async () => {
if (!retention) return;
const existing = await db
.select()
.from(drizzleDb.schemas.retentionPolicy)
.where(eq(drizzleDb.schemas.retentionPolicy.databaseId, databaseId))
.limit(1);
const values = {
type: retention.type ?? "gfs",
count: retention.count,
days: retention.days,
gfsDaily: retention.gfs.daily,
gfsWeekly: retention.gfs.weekly,
gfsMonthly: retention.gfs.monthly,
gfsYearly: retention.gfs.yearly,
};
if (existing.length > 0) {
await db
.update(drizzleDb.schemas.retentionPolicy)
.set({ ...values, updatedAt: new Date() })
.where(eq(drizzleDb.schemas.retentionPolicy.databaseId, databaseId));
} else {
await db
.insert(drizzleDb.schemas.retentionPolicy)
.values({ databaseId, ...values });
}
};
const applyScheduling = async () => {
if (backupMethod === undefined) return;
const cronValue =
backupMethod === "manual" ? null : (backupCron ?? "0 0 * * *");
await db
.update(drizzleDb.schemas.database)
.set({ backupPolicy: cronValue, updatedAt: new Date() })
.where(eq(drizzleDb.schemas.database.id, databaseId));
};
const applyNotifications = async () => {
if (!notificationPolicies) return;
await db.transaction(async (tx) => {
await tx
.delete(drizzleDb.schemas.alertPolicy)
.where(eq(drizzleDb.schemas.alertPolicy.databaseId, databaseId));
if (notificationPolicies.length > 0) {
await tx.insert(drizzleDb.schemas.alertPolicy).values(
notificationPolicies.map((p) => ({
databaseId,
notificationChannelId: p.channelId,
eventKinds: p.eventKinds as any,
enabled: p.enabled,
})),
);
}
});
};
const applyStorage = async () => {
if (!storagePolicies) return;
await db.transaction(async (tx) => {
await tx
.delete(drizzleDb.schemas.storagePolicy)
.where(eq(drizzleDb.schemas.storagePolicy.databaseId, databaseId));
if (storagePolicies.length > 0) {
await tx.insert(drizzleDb.schemas.storagePolicy).values(
storagePolicies.map((p) => ({
databaseId,
storageChannelId: p.channelId,
enabled: p.enabled,
})),
);
}
});
};
if (section === "retention" || section === "all") await applyRetention();
if (section === "scheduling" || section === "all") await applyScheduling();
if (section === "notifications" || section === "all")
await applyNotifications();
if (section === "storage" || section === "all") await applyStorage();
return { success: true };
});
@@ -0,0 +1,11 @@
"use server";
import { z } from "zod";
import { action } from "@/lib/safe-actions/actions";
import { signPasskeyContext } from "@/lib/auth/passkey-context";
export const generatePasskeyContextAction = action
.schema(z.object({ name: z.string().min(1), email: z.email() }))
.action(async ({ parsedInput }) => {
return signPasskeyContext(parsedInput.name, parsedInput.email);
});
@@ -0,0 +1,17 @@
"use server";
import { userAction } from "@/lib/safe-actions/actions";
import { z } from "zod";
import { getAgentAction } from "@/features/agents/actions/agents.action";
export const getAgentStatusAction = userAction
.schema(z.object({ agentId: z.string() }))
.action(async ({ parsedInput }) => {
const result = await getAgentAction(parsedInput.agentId);
if (!result?.data?.data) return { connected: false };
const agent = result.data.data;
const lastContact = agent.lastContact ? new Date(agent.lastContact) : null;
const connected =
lastContact !== null && Date.now() - lastContact.getTime() < 60_000;
return { connected };
});
@@ -0,0 +1,21 @@
"use server";
import { userAction } from "@/lib/safe-actions/actions";
import { z } from "zod";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import { eq } from "drizzle-orm";
export const markOnboardingDoneAction = userAction
.schema(z.object({}))
.action(async () => {
const settings = await db.query.setting.findFirst();
if (!settings) {
throw new Error("Settings not found");
}
await db
.update(drizzleDb.schemas.setting)
.set({ onboarding: true })
.where(eq(drizzleDb.schemas.setting.id, settings.id));
return { done: true };
});
@@ -0,0 +1,24 @@
"use server";
import { userAction } from "@/lib/safe-actions/actions";
import { z } from "zod";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import { eq } from "drizzle-orm";
export const updateAccountAction = userAction
.schema(
z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
}),
)
.action(async ({ parsedInput, ctx }) => {
const name = `${parsedInput.firstName} ${parsedInput.lastName}`.trim();
const [updated] = await db
.update(drizzleDb.schemas.user)
.set({ name, updatedAt: new Date() })
.where(eq(drizzleDb.schemas.user.id, ctx.user.id))
.returning();
return { user: updated };
});
@@ -0,0 +1,163 @@
"use client";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { cn } from "@/lib/utils";
import { isValidCronPart } from "@/utils/cron";
export type BackupScheduleValue = {
method: "manual" | "automatic";
cron?: string;
};
const PRESETS = [
{ label: "Hourly", sub: "Every hour", cron: "0 * * * *" },
{ label: "Every 6h", sub: "4× per day", cron: "0 */6 * * *" },
{ label: "Every 12h", sub: "2× per day", cron: "0 */12 * * *"},
{ label: "Daily", sub: "Every day at midnight",cron: "0 0 * * *" },
{ label: "Weekly", sub: "Every Sunday", cron: "0 0 * * 0" },
{ label: "Monthly", sub: "1st of each month", cron: "0 0 1 * *" },
] as const;
type PresetCron = (typeof PRESETS)[number]["cron"];
function isPresetCron(cron: string | undefined): cron is PresetCron {
return PRESETS.some((p) => p.cron === cron);
}
function validateCron(expr: string): boolean {
const parts = expr.trim().split(/\s+/);
if (parts.length !== 5) return false;
const types = ["minute", "hour", "day-of-month", "month", "day-of-week"] as const;
return parts.every((p, i) => isValidCronPart(types[i], p));
}
type Props = {
value: BackupScheduleValue;
onChange: (value: BackupScheduleValue) => void;
};
export const BackupScheduleSelector = ({ value, onChange }: Props) => {
const isCustom = !!value.cron && !isPresetCron(value.cron);
const [customInput, setCustomInput] = useState(
isCustom ? (value.cron ?? "") : "",
);
const [customError, setCustomError] = useState<string | null>(null);
const handleMethodChange = (method: "manual" | "automatic") => {
onChange({
method,
cron: method === "manual" ? undefined : (value.cron ?? "0 0 * * *"),
});
};
const handlePresetClick = (cron: PresetCron) => {
setCustomError(null);
onChange({ ...value, cron });
};
const handleCustomChange = (raw: string) => {
setCustomInput(raw);
if (validateCron(raw)) {
setCustomError(null);
onChange({ ...value, cron: raw.trim() });
} else {
setCustomError("Invalid cron expression");
}
};
const handleCustomFocus = () => {
if (!customInput && value.cron && isPresetCron(value.cron)) {
setCustomInput(value.cron);
}
};
return (
<div className="flex flex-col gap-4">
<RadioGroup
value={value.method}
onValueChange={(m) => handleMethodChange(m as "manual" | "automatic")}
className="grid grid-cols-2 gap-3"
>
{(
[
{ id: "manual", label: "Manual", desc: "Trigger backups manually" },
{ id: "automatic", label: "Automatic", desc: "Scheduled via cron" },
] as const
).map((opt) => (
<Label
key={opt.id}
htmlFor={opt.id}
className={cn(
"flex items-center gap-3 rounded-lg border p-3 cursor-pointer transition-colors",
value.method === opt.id
? "border-primary bg-primary/5"
: "hover:bg-muted/50",
)}
>
<RadioGroupItem value={opt.id} id={opt.id} />
<div>
<p className="font-medium text-sm">{opt.label}</p>
<p className="text-xs text-muted-foreground">{opt.desc}</p>
</div>
</Label>
))}
</RadioGroup>
{value.method === "automatic" && (
<div className="flex flex-col gap-3">
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-widest">
Frequency
</Label>
<div className="grid grid-cols-3 gap-2">
{PRESETS.map((p) => (
<button
key={p.cron}
type="button"
onClick={() => handlePresetClick(p.cron)}
className={cn(
"flex flex-col items-start rounded-lg border px-3 py-2 text-left text-sm transition-all",
value.cron === p.cron && !customError
? "border-primary bg-primary/5 text-primary"
: "border-border hover:bg-accent/50 hover:border-primary/20",
)}
>
<span className="font-medium">{p.label}</span>
<span className="text-[11px] text-muted-foreground">{p.sub}</span>
</button>
))}
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">
Custom expression
</Label>
<Input
placeholder="e.g. 0 */4 * * *"
value={customInput}
onFocus={handleCustomFocus}
onChange={(e) => handleCustomChange(e.target.value)}
className={cn(
"font-mono text-sm",
isCustom && !customError && "border-primary",
customError && "border-destructive",
)}
/>
{customError ? (
<p className="text-xs text-destructive">{customError}</p>
) : isCustom ? (
<p className="text-xs text-muted-foreground font-mono">{value.cron}</p>
) : null}
</div>
<div className="rounded-md bg-muted/50 px-3 py-2 text-xs font-mono text-muted-foreground">
{value.cron ?? "0 0 * * *"}
</div>
</div>
)}
</div>
);
};
@@ -0,0 +1,134 @@
"use client";
import {
ArrowLeft,
Bell,
Check,
Clock,
Copy,
Database,
HardDrive,
Shield,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import type {
OnboardingDatabase,
SectionKind,
} from "@/features/onboarding/types";
const SECTIONS: {
kind: SectionKind;
label: string;
description: string;
icon: React.ReactNode;
}[] = [
{
kind: "retention",
label: "Retention Policy",
description: "Configure how long your data is retained",
icon: <Shield className="size-4 text-muted-foreground" />,
},
{
kind: "scheduling",
label: "Scheduling",
description: "Set up backup schedules for your database",
icon: <Clock className="size-4 text-muted-foreground" />,
},
{
kind: "notifications",
label: "Notifications",
description: "Get notified about backup status and issues",
icon: <Bell className="size-4 text-muted-foreground" />,
},
{
kind: "storage",
label: "Storage",
description: "Choose where to store your backups",
icon: <HardDrive className="size-4 text-muted-foreground" />,
},
];
type DbDetailProps = {
db: OnboardingDatabase | undefined;
dbId: string;
isSectionConfigured: (section: SectionKind) => boolean;
isMultiDb: boolean;
hasAnyConfigured: boolean;
isApplyingToAll: boolean;
onSelectSection: (section: SectionKind) => void;
onApplyToAll: () => Promise<void>;
onBack: () => void;
};
export const DbDetail = ({
db,
dbId,
isSectionConfigured,
isMultiDb,
hasAnyConfigured,
isApplyingToAll,
onSelectSection,
onApplyToAll,
onBack,
}: DbDetailProps) => (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3 p-3 bg-secondary/30 rounded-lg border border-border">
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
<Database className="size-4" />
</div>
<p className="flex-1 text-sm font-medium capitalize">
{db?.name ?? dbId}{" "}
<span className="text-muted-foreground font-normal">
({db?.engine})
</span>
</p>
<Button type="button" variant="ghost" size="sm" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" />
Back
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{SECTIONS.map(({ kind, label, description, icon }) => (
<button
key={kind}
type="button"
onClick={() => onSelectSection(kind)}
className="flex items-center gap-3 rounded-lg border p-3 text-sm transition-all text-left hover:bg-accent/50 hover:border-primary/20"
>
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
{icon}
</div>
<div className="flex-col gap-1">
<div className="flex flex-col gap-1">
<span className="font-medium">{label}</span>
<span className="text-muted-foreground text-xs">
{description}
</span>
</div>
</div>
{isSectionConfigured(kind) && (
<div className="size-5 rounded-full bg-primary flex items-center justify-center ml-auto shrink-0">
<Check
className="size-3 text-primary-foreground"
strokeWidth={3}
/>
</div>
)}
</button>
))}
</div>
{isMultiDb && hasAnyConfigured && (
<Button
type="button"
variant="outline"
disabled={isApplyingToAll}
onClick={onApplyToAll}
>
<Copy className="size-4 mr-2" />
{isApplyingToAll ? "Applying…" : "Apply to all databases"}
</Button>
)}
</div>
);
@@ -0,0 +1,60 @@
"use client";
import { Check, ChevronRight, Database } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import type { OnboardingDatabase } from "@/features/onboarding/types";
type DbGridProps = {
databaseIds: string[];
getDb: (id: string) => OnboardingDatabase | undefined;
isDbConfigured: (id: string) => boolean;
onSelectDb: (id: string) => void;
onContinue: () => void;
};
export const DbGrid = ({
databaseIds,
getDb,
isDbConfigured,
onSelectDb,
onContinue,
}: DbGridProps) => (
<div className="flex flex-col gap-4">
<div>
<h1 className="text-2xl font-semibold">Configure databases</h1>
<p className="text-sm text-muted-foreground mt-1">
Optional configure backup policies for each database.
</p>
</div>
<div className="flex flex-col gap-2 max-h-52 sm:max-h-72 md:max-h-96 lg:max-h-[28rem] overflow-y-auto scrollbar-hide">
{databaseIds.map((dbId) => {
const db = getDb(dbId);
return (
<button
key={dbId}
type="button"
onClick={() => onSelectDb(dbId)}
className="flex items-center gap-3 rounded-lg border p-3 text-sm transition-all text-left hover:bg-accent/50 hover:border-primary/20"
>
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
<Database className="size-4 text-muted-foreground" />
</div>
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium">{db?.name ?? dbId}</span>
<span className="text-xs text-muted-foreground capitalize">{db?.engine}</span>
</div>
{isDbConfigured(dbId) && (
<Badge variant="secondary" className="text-xs shrink-0">
<Check className="size-3 mr-1" />
Configured
</Badge>
)}
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
</button>
);
})}
</div>
<Button type="button" onClick={onContinue}>Continue</Button>
</div>
);
@@ -0,0 +1,141 @@
"use client";
import { ArrowLeft } from "lucide-react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { RetentionSection } from "@/features/onboarding/components/db-settings/retention-section";
import { SchedulingSection } from "@/features/onboarding/components/db-settings/scheduling-section";
import { NotificationsSection } from "@/features/onboarding/components/db-settings/notifications-section";
import { StorageSection } from "@/features/onboarding/components/db-settings/storage-section";
import type {
OnboardingChannel,
OnboardingDatabase,
OnboardingDbSettings,
SectionKind,
} from "@/features/onboarding/types";
import type { useApplyDbSettings } from "@/features/onboarding/hooks/use-apply-db-settings";
const SECTION_LABELS: Record<SectionKind, string> = {
retention: "Retention Policy",
scheduling: "Scheduling",
notifications: "Notifications",
storage: "Storage",
};
type DbSectionProps = {
dbId: string;
db: OnboardingDatabase | undefined;
section: SectionKind;
settings: OnboardingDbSettings;
applyMutation: ReturnType<typeof useApplyDbSettings>;
notifiers: OnboardingChannel[];
storages: OnboardingChannel[];
onBack: () => void;
onSaved: () => void;
updateDbSettings: (
dbId: string,
patch: Partial<OnboardingDbSettings>,
) => Promise<void>;
};
export const DbSection = ({
dbId,
db,
section,
settings,
applyMutation,
notifiers,
storages,
onBack,
onSaved,
updateDbSettings,
}: DbSectionProps) => (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3 p-3 bg-secondary/30 rounded-lg border border-border">
<p className="flex-1 text-sm font-medium">
{SECTION_LABELS[section]}{" "}
<span className="text-muted-foreground font-normal">
{db?.name ?? dbId}
</span>
</p>
<Button type="button" variant="ghost" size="sm" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" />
Back
</Button>
</div>
{section === "retention" && (
<RetentionSection
initial={settings.retention}
isPending={applyMutation.isPending}
onSave={async (retention) => {
await applyMutation.mutateAsync({
databaseId: dbId,
section: "retention",
retention,
});
await updateDbSettings(dbId, { retention });
toast.success("Retention policy saved.");
onSaved();
}}
/>
)}
{section === "scheduling" && (
<SchedulingSection
initial={{
backupMethod: settings.backupMethod,
backupCron: settings.backupCron,
}}
isPending={applyMutation.isPending}
onSave={async (backupMethod, backupCron) => {
await applyMutation.mutateAsync({
databaseId: dbId,
section: "scheduling",
backupMethod,
backupCron,
});
await updateDbSettings(dbId, { backupMethod, backupCron });
toast.success("Schedule saved.");
onSaved();
}}
/>
)}
{section === "notifications" && (
<NotificationsSection
initial={settings.notificationPolicies ?? []}
notifiers={notifiers}
isPending={applyMutation.isPending}
onSave={async (notificationPolicies) => {
await applyMutation.mutateAsync({
databaseId: dbId,
section: "notifications",
notificationPolicies,
});
await updateDbSettings(dbId, { notificationPolicies });
toast.success("Notification policies saved.");
onSaved();
}}
/>
)}
{section === "storage" && (
<StorageSection
initial={settings.storagePolicies ?? []}
storages={storages}
isPending={applyMutation.isPending}
onSave={async (storagePolicies) => {
await applyMutation.mutateAsync({
databaseId: dbId,
section: "storage",
storagePolicies,
});
await updateDbSettings(dbId, { storagePolicies });
toast.success("Storage policies saved.");
onSaved();
}}
/>
)}
</div>
);
@@ -0,0 +1,35 @@
"use client";
import { ChannelPoliciesForm } from "@/features/database/components/channels-policy-form";
import type { EventKind, OnboardingChannel, OnboardingNotificationPolicy } from "@/features/onboarding/types";
import type { PolicyType } from "@/features/database/schemas/channels-policy.schema";
type NotificationsSectionProps = {
initial: OnboardingNotificationPolicy[];
notifiers: OnboardingChannel[];
onSave: (policies: OnboardingNotificationPolicy[]) => Promise<void>;
isPending: boolean;
};
export const NotificationsSection = ({ initial, notifiers, onSave, isPending }: NotificationsSectionProps) => (
<ChannelPoliciesForm
channels={notifiers}
defaultPolicies={initial as PolicyType[]}
kind="notification"
isPending={isPending}
onSave={async (policies: PolicyType[]) =>
onSave(
policies.map((p) => ({
channelId: p.channelId,
eventKinds: (p.eventKinds ?? []) as EventKind[],
enabled: p.enabled,
})),
)
}
noChannelsMessage={
<p className="text-xs text-muted-foreground">
Go back and configure notifiers in the &quot;Connect a notifier&quot; step first.
</p>
}
/>
);
@@ -0,0 +1,31 @@
"use client";
import { BackupRetentionSettingsForm } from "@/features/database/components/retention-policy-form";
import { DEFAULT_RETENTION } from "@/features/onboarding/constants/db-settings";
import type { OnboardingDbSettings } from "@/features/onboarding/types";
type RetentionSectionProps = {
initial: OnboardingDbSettings["retention"];
onSave: (value: NonNullable<OnboardingDbSettings["retention"]>) => Promise<void>;
isPending: boolean;
};
export const RetentionSection = ({ initial, onSave, isPending }: RetentionSectionProps) => (
<BackupRetentionSettingsForm
defaultValues={initial ?? DEFAULT_RETENTION}
isPending={isPending}
onSave={async (values) =>
onSave({
type: values.type,
count: values.count ?? DEFAULT_RETENTION.count,
days: values.days ?? DEFAULT_RETENTION.days,
gfs: {
daily: values.gfs?.daily ?? DEFAULT_RETENTION.gfs.daily,
weekly: values.gfs?.weekly ?? DEFAULT_RETENTION.gfs.weekly,
monthly: values.gfs?.monthly ?? DEFAULT_RETENTION.gfs.monthly,
yearly: values.gfs?.yearly ?? DEFAULT_RETENTION.gfs.yearly,
},
})
}
/>
);
@@ -0,0 +1,43 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
BackupScheduleSelector,
type BackupScheduleValue,
} from "@/features/onboarding/components/backup-schedule-selector";
import { DEFAULT_SCHEDULE } from "@/features/onboarding/constants/db-settings";
import type { OnboardingDbSettings } from "@/features/onboarding/types";
type SchedulingSectionProps = {
initial: Pick<OnboardingDbSettings, "backupMethod" | "backupCron">;
onSave: (method: "manual" | "automatic", cron?: string) => Promise<void>;
isPending: boolean;
};
export const SchedulingSection = ({
initial,
onSave,
isPending,
}: SchedulingSectionProps) => {
const [schedule, setSchedule] = useState<BackupScheduleValue>({
method: initial.backupMethod ?? DEFAULT_SCHEDULE.method,
cron: initial.backupCron ?? DEFAULT_SCHEDULE.cron,
});
return (
<div className="flex flex-col gap-6">
<BackupScheduleSelector value={schedule} onChange={setSchedule} />
<div className="flex gap-2 pt-2">
<Button
type="button"
disabled={isPending}
onClick={() => onSave(schedule.method, schedule.cron)}
className="ml-auto"
>
{isPending ? "Saving…" : "Save"}
</Button>
</div>
</div>
);
};
@@ -0,0 +1,34 @@
"use client";
import { ChannelPoliciesForm } from "@/features/database/components/channels-policy-form";
import type { OnboardingChannel, OnboardingStoragePolicy } from "@/features/onboarding/types";
import type { PolicyType } from "@/features/database/schemas/channels-policy.schema";
type StorageSectionProps = {
initial: OnboardingStoragePolicy[];
storages: OnboardingChannel[];
onSave: (policies: OnboardingStoragePolicy[]) => Promise<void>;
isPending: boolean;
};
export const StorageSection = ({ initial, storages, onSave, isPending }: StorageSectionProps) => (
<ChannelPoliciesForm
channels={storages}
defaultPolicies={initial}
kind="storage"
isPending={isPending}
onSave={async (policies: PolicyType[]) =>
onSave(
policies.map((p) => ({
channelId: p.channelId,
enabled: p.enabled,
})),
)
}
noChannelsMessage={
<p className="text-xs text-muted-foreground">
Go back and configure storages in the &quot;Connect an external storage&quot; step first.
</p>
}
/>
);
@@ -0,0 +1,15 @@
import type { OnboardingDbSettings } from "@/features/onboarding/types";
import type { BackupScheduleValue } from "@/features/onboarding/components/backup-schedule-selector";
export const DEFAULT_RETENTION: NonNullable<OnboardingDbSettings["retention"]> =
{
type: "gfs",
count: 7,
days: 30,
gfs: { daily: 7, weekly: 4, monthly: 12, yearly: 3 },
};
export const DEFAULT_SCHEDULE: BackupScheduleValue = {
method: "automatic",
cron: "0 0 * * *",
};
@@ -0,0 +1,33 @@
export const STEP_IDS = {
LOGIN: "login",
ACCOUNT_INFO: "account-info",
SECURITY: "security",
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.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,
];
@@ -0,0 +1,55 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { useOnboarding } from "@onboardjs/react";
import { toast } from "sonner";
import { addNotificationChannelAction } from "@/features/channel/components/notifications/channel.action";
import type { OnboardingChannel } from "@/features/onboarding/types";
type NotifierInput = {
provider: string;
name: string;
config: Record<string, unknown>;
label: string;
};
export const useAddNotifier = () => {
const { state, updateContext } = useOnboarding();
return useMutation({
mutationFn: async ({ provider, name, config, label }: NotifierInput) => {
const orgId = (state?.context.flowData.org as any)?.id as
| string
| undefined;
const result = await addNotificationChannelAction({
organizationId: orgId,
data: {
provider: provider as any,
name,
config: config as any,
enabled: true,
},
});
const inner = result?.data;
if (!inner?.success || !inner.value)
throw new Error("Failed to save channel");
const channel: OnboardingChannel = {
id: inner.value.id,
provider,
label,
name,
config,
};
const notifiers = [
...((state?.context.flowData.notifiers ?? []) as OnboardingChannel[]),
channel,
];
await updateContext({
flowData: { ...state?.context.flowData, notifiers },
});
return channel;
},
onError: (err: Error) => toast.error(err.message),
});
};
@@ -0,0 +1,56 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { useOnboarding } from "@onboardjs/react";
import { toast } from "sonner";
import { addStorageChannelAction } from "@/features/channel/components/storages/channel.action";
import type { OnboardingChannel } from "@/features/onboarding/types";
type StorageInput = {
provider: string;
name: string;
config: Record<string, unknown>;
label: string;
};
export const useAddStorage = () => {
const { state, updateContext } = useOnboarding();
return useMutation({
mutationFn: async ({ provider, name, config, label }: StorageInput) => {
const orgId = (state?.context.flowData.org as any)?.id as
| string
| undefined;
const result = await addStorageChannelAction({
organizationId: orgId,
data: {
provider: provider as any,
name,
config: config as any,
enabled: true,
},
});
const inner = result?.data;
if (!inner?.success || !inner.value)
throw new Error("Failed to save storage");
const channel: OnboardingChannel = {
id: inner.value.id,
provider,
label,
name,
config,
organizationId: orgId ?? null,
};
const storages = [
...((state?.context.flowData.storages ?? []) as OnboardingChannel[]),
channel,
];
await updateContext({
flowData: { ...state?.context.flowData, storages },
});
return channel;
},
onError: (err: Error) => toast.error(err.message),
});
};
@@ -0,0 +1,23 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { useOnboarding } from "@onboardjs/react";
import { getAgentStatusAction } from "@/features/onboarding/actions/get-agent-status.action";
import type { OnboardingAgent } from "@/features/onboarding/types";
export const useAgentStatus = () => {
const { state } = useOnboarding();
const agents = (state?.context.flowData.agents ?? []) as OnboardingAgent[];
const firstAgentId = agents[0]?.id;
return useQuery({
queryKey: ["onboarding-agent-status", firstAgentId],
queryFn: async () => {
if (!firstAgentId) return { connected: false };
const result = await getAgentStatusAction({ agentId: firstAgentId });
return result?.data ?? { connected: false };
},
refetchInterval: 3_000,
enabled: !!firstAgentId,
});
};
@@ -0,0 +1,12 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { applyOnboardingDbSettingsAction } from "@/features/onboarding/actions/apply-db-settings.action";
export const useApplyDbSettings = () =>
useMutation({
mutationFn: (args: Parameters<typeof applyOnboardingDbSettingsAction>[0]) =>
applyOnboardingDbSettingsAction(args),
onError: () => toast.error("Failed to save settings."),
});
@@ -0,0 +1,46 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { useOnboarding } from "@onboardjs/react";
import { toast } from "sonner";
import { createAgentAction } from "@/features/agents/actions/agents.action";
import type { OnboardingAgent, OnboardingDefaultsData } from "@/features/onboarding/types";
import { generateEdgeKey } from "@/utils/edge_key";
import { getServerUrl } from "@/utils/get-server-url";
export const useCreateAgent = () => {
const { state, updateContext } = useOnboarding();
return useMutation({
mutationFn: async (name: string) => {
const orgId = (state?.context.flowData.org as any)?.id as string | undefined;
if (!orgId) throw new Error("Missing org ID — cannot create agent");
const defaults = (state?.context.flowData.defaults ?? {}) as OnboardingDefaultsData;
const result = await createAgentAction({
organizationId: orgId,
data: { name, description: "" },
});
if (!result?.data?.data) {
throw new Error(result?.serverError ?? `Failed to create agent "${name}"`);
}
const edgeKey = await generateEdgeKey(getServerUrl(), result.data.data.id);
const newAgent: OnboardingAgent = {
id: result.data.data.id,
name: result.data.data.name,
edgeKey,
notifierId: defaults.notifierId,
storageId: defaults.storageId,
};
const agents = [
...((state?.context.flowData.agents ?? []) as OnboardingAgent[]),
newAgent,
];
await updateContext({ flowData: { ...state?.context.flowData, agents } });
return newAgent;
},
onError: (err: Error) => toast.error(err.message),
});
};
@@ -0,0 +1,63 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { useOnboarding } from "@onboardjs/react";
import { toast } from "sonner";
import {
createOrganizationAction,
updateOrganizationAction,
} from "@/features/organizations/actions/organization.action";
import { slugify } from "@/utils/slugify";
export const useCreateOrg = () => {
const { state, updateContext, next } = useOnboarding();
return useMutation({
mutationFn: async (name: string) => {
const trimmed = name.trim();
if (!trimmed) throw new Error("Organisation name is required");
const existingOrg = state?.context.flowData.org as
| { id: string; name: string }
| undefined;
if (existingOrg) {
const result = await updateOrganizationAction({
organizationId: existingOrg.id,
data: { name: trimmed, slug: slugify(trimmed), users: [] },
});
const updateData = result?.data;
if (!updateData?.success) {
throw new Error(
updateData?.actionError?.message ?? "Failed to update organisation",
);
}
await updateContext({
flowData: {
...state?.context.flowData,
org: { id: existingOrg.id, name: trimmed },
},
});
} else {
const result = await createOrganizationAction({ name: trimmed });
const createData = result?.data;
if (!createData?.success) {
throw new Error(
createData?.actionError?.message ?? "Failed to create organisation",
);
}
const org = createData.value;
if (!org) throw new Error("Failed to create organisation");
await updateContext({
flowData: {
...state?.context.flowData,
org: { id: org.id, name: org.name },
},
});
}
await next();
},
onError: (err: Error) => toast.error(err.message),
});
};
@@ -0,0 +1,62 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { useOnboarding } from "@onboardjs/react";
import { toast } from "sonner";
import {
createProjectAction,
updateProjectAction,
} from "@/features/projects/actions/projects.action";
import type { OnboardingProjectData } from "@/features/onboarding/types";
type ProjectInput = { name: string; databaseIds: string[] };
export const useCreateProject = () => {
const { state, updateContext } = useOnboarding();
return useMutation({
mutationFn: async ({ name, databaseIds }: ProjectInput) => {
const trimmed = name.trim();
if (!trimmed) throw new Error("Project name is required");
const orgId = (state?.context.flowData.org as any)?.id as string | undefined;
if (!orgId) throw new Error("No organisation ID found");
const existingProject = state?.context.flowData.project as OnboardingProjectData | undefined;
if (existingProject?.id) {
const result = await updateProjectAction({
data: { name: trimmed, databases: databaseIds },
organizationId: orgId,
projectId: existingProject.id,
});
const updateData = result?.data;
if (!updateData?.success) {
throw new Error(updateData?.actionError?.message ?? "Failed to update project");
}
await updateContext({
flowData: {
...state?.context.flowData,
project: { id: existingProject.id, name: trimmed, databaseIds },
},
});
} else {
const result = await createProjectAction({
data: { name: trimmed, databases: databaseIds },
organizationId: orgId,
});
const createData = result?.data;
if (!createData?.success) {
throw new Error(createData?.actionError?.message ?? "Failed to create project");
}
const project = createData.value;
if (!project) throw new Error("Failed to create project");
await updateContext({
flowData: {
...state?.context.flowData,
project: { id: project.id, name: project.name, databaseIds },
},
});
}
},
onError: (err: Error) => toast.error(err.message),
});
};
@@ -0,0 +1,25 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { useOnboarding } from "@onboardjs/react";
import { toast } from "sonner";
import { deleteAgentAction } from "@/features/agents/actions/agent-delete.action";
import type { OnboardingAgent } from "@/features/onboarding/types";
export const useDeleteAgent = () => {
const { state, updateContext } = useOnboarding();
return useMutation({
mutationFn: async (agentId: string) => {
const orgId = (state?.context.flowData.org as any)?.id as string | undefined;
const result = await deleteAgentAction({ agentId, organizationId: orgId });
if (result?.data?.success === false) throw new Error("Failed to delete agent");
const agents = (
(state?.context.flowData.agents ?? []) as OnboardingAgent[]
).filter((a) => a.id !== agentId);
await updateContext({ flowData: { ...state?.context.flowData, agents } });
},
onError: (err: Error) => toast.error(err.message),
});
};
@@ -0,0 +1,14 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { markOnboardingDoneAction } from "@/features/onboarding/actions/onboarding-mark-done.action";
export const useMarkOnboardingDone = () => {
return useMutation({
mutationFn: async () => {
const result = await markOnboardingDoneAction({});
if (!result?.data) throw new Error("Failed to mark onboarding done");
return result.data;
},
});
};
@@ -0,0 +1,32 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { useOnboarding } from "@onboardjs/react";
import { toast } from "sonner";
import { removeNotificationChannelAction } from "@/features/channel/components/notifications/channel.action";
import type { OnboardingChannel } from "@/features/onboarding/types";
export const useRemoveNotifier = () => {
const { state, updateContext } = useOnboarding();
return useMutation({
mutationFn: async (id: string) => {
const orgId = (state?.context.flowData.org as any)?.id as
| string
| undefined;
const result = await removeNotificationChannelAction({
organizationId: orgId,
notificationChannelId: id,
});
if (result?.data?.success === false)
throw new Error("Failed to remove channel");
const notifiers = (
(state?.context.flowData.notifiers ?? []) as OnboardingChannel[]
).filter((c) => c.id !== id);
await updateContext({
flowData: { ...state?.context.flowData, notifiers },
});
},
onError: (err: Error) => toast.error(err.message),
});
};
@@ -0,0 +1,32 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { useOnboarding } from "@onboardjs/react";
import { toast } from "sonner";
import { removeStorageChannelAction } from "@/features/channel/components/storages/channel.action";
import type { OnboardingChannel } from "@/features/onboarding/types";
export const useRemoveStorage = () => {
const { state, updateContext } = useOnboarding();
return useMutation({
mutationFn: async (id: string) => {
const orgId = (state?.context.flowData.org as any)?.id as
| string
| undefined;
const result = await removeStorageChannelAction({
organizationId: orgId,
id,
});
if (result?.data?.success === false)
throw new Error("Failed to remove storage");
const storages = (
(state?.context.flowData.storages ?? []) as OnboardingChannel[]
).filter((c) => c.id !== id);
await updateContext({
flowData: { ...state?.context.flowData, storages },
});
},
onError: (err: Error) => toast.error(err.message),
});
};
@@ -0,0 +1,115 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { useOnboarding } from "@onboardjs/react";
import { toast } from "sonner";
import { z } from "zod";
import { authClient, signUp, passkey, 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 { WithPasswordSchema } from "@/features/onboarding/schemas/account.schema";
type AccountInput = z.infer<typeof WithPasswordSchema> & {
method?: "passkey" | "password";
};
export const useUpdateAccount = (refetchSession: () => Promise<any>) => {
const { state, updateContext, next } = useOnboarding();
return useMutation({
mutationFn: async (values: AccountInput) => {
const existingAccount = state?.context.flowData.account;
const isUpdateMode = !!existingAccount;
const selectedMethod = values.method ?? "password";
if (isUpdateMode) {
const result = await updateAccountAction({
firstName: values.firstName,
lastName: values.lastName,
});
if (!result?.data) throw new Error("Failed to update account");
await updateContext({
flowData: {
...state?.context.flowData,
account: {
firstName: values.firstName,
lastName: values.lastName,
email: values.email,
},
},
});
await next();
return;
}
if (selectedMethod === "passkey") {
const name = `${values.firstName} ${values.lastName}`;
const ctxResult = await generatePasskeyContextAction({
name,
email: values.email,
});
const context = ctxResult?.data;
if (!context) throw new Error("Failed to generate passkey context");
const result = await passkey.addPasskey({
name: values.email,
context,
});
if (result?.error) {
throw new Error(
result.error.message ?? "Passkey registration failed",
);
}
await refetchSession();
const { data: freshSession } = await authClient.getSession();
if (!freshSession?.user) {
const signInResult = await (signIn as any).passkey();
if (signInResult?.error) {
throw new Error(
"Registration succeeded but sign-in failed. Please reload and sign in.",
);
}
}
await updateContext({
flowData: {
...state?.context.flowData,
account: {
firstName: values.firstName,
lastName: values.lastName,
email: values.email,
},
security: { method: "passkey" },
},
});
await next();
return;
}
await signUp.email(
{
name: `${values.firstName} ${values.lastName}`,
email: values.email,
password: values.password ?? "",
} as any,
{
onSuccess: async () => {
await updateContext({
flowData: {
...state?.context.flowData,
account: {
firstName: values.firstName,
lastName: values.lastName,
email: values.email,
},
},
});
await next();
},
onError: (error) => {
toast.error(error.error.message);
},
},
);
},
onError: (err: Error) => toast.error(err.message),
});
};
@@ -0,0 +1,8 @@
import "server-only";
import { db } from "@/db";
export async function isOnboardingDone(): Promise<boolean> {
const settings = await db.query.setting.findFirst();
return settings?.onboarding ?? false;
}
@@ -0,0 +1,78 @@
"use client";
import { useOnboarding } from "@onboardjs/react";
import { CheckCircle2, Circle } from "lucide-react";
import { cn } from "@/lib/utils";
const CHECKLIST_STEPS = [
{ id: "login", label: "Sign in" },
{ id: "account-info", label: "Your account" },
{ id: "security", label: "Security" },
{ id: "org-create", label: "Organisation" },
// { id: "invite-members", label: "Team members" },
{ id: "notifier", label: "Notifications" },
{ id: "storage", label: "Storage" },
{ id: "defaults", label: "Defaults" },
{ id: "agent-create", label: "Agent setup" },
{ id: "agent-key", label: "Agent key" },
{ id: "agent-waiting", label: "Agent connection" },
{ id: "project-create", label: "Project" },
{ id: "db-settings", label: "Database settings" },
{ id: "finish", label: "Done" },
] as const;
export const OnboardingChecklist = () => {
const { state } = useOnboarding();
if (!state) return null;
const currentId = state.currentStep?.id ?? "";
const currentIndex = CHECKLIST_STEPS.findIndex((s) => s.id === currentId);
return (
<div className="flex flex-col gap-0 p-6 h-full">
<div className="flex flex-col relative">
{CHECKLIST_STEPS.map((step, i) => {
const isCompleted = i < currentIndex;
const isCurrent = i === currentIndex;
const isLast = i === CHECKLIST_STEPS.length - 1;
return (
<div key={step.id} className="flex gap-3">
<div className="flex flex-col items-center">
{isCompleted ? (
<CheckCircle2 className="size-4 text-green-500 shrink-0 mt-0.5" />
) : (
<Circle
className={cn(
"size-4 shrink-0 mt-0.5",
isCurrent ? "text-primary" : "text-zinc-700",
)}
/>
)}
{!isLast && (
<div
className={cn(
"w-px flex-1 mt-1 mb-1 min-h-[16px]",
isCompleted ? "bg-green-500/40" : "bg-zinc-800",
)}
/>
)}
</div>
<p
className={cn(
"text-sm pb-4",
isCompleted && "text-zinc-400",
isCurrent && "text-white font-medium",
!isCompleted && !isCurrent && "text-zinc-600",
)}
>
{step.label}
</p>
</div>
);
})}
</div>
</div>
);
};
@@ -0,0 +1,124 @@
"use client";
import { useEffect, useState } from "react";
import { useOnboarding } from "@onboardjs/react";
import { Button } from "@/components/ui/button";
import { OnboardingStepper } from "@/features/onboarding/onboarding-stepper";
import { OnboardingChecklist } from "@/features/onboarding/onboarding-checklist";
import { Heart } from "lucide-react";
import { AuthLogoSection } from "../auth/components/auth-logo-section";
import { STEP_ORDER } from "@/features/onboarding/constants/steps";
export const OnboardingShell = () => {
const { state, previous, skip, next, goToStep, renderStep } = useOnboarding();
const currentStepId = String(state?.currentStep?.id ?? "");
const currentIndex = STEP_ORDER.indexOf(currentStepId);
const [latestIndex, setLatestIndex] = useState(currentIndex);
useEffect(() => {
setLatestIndex((prev) => Math.max(prev, currentIndex));
}, [currentIndex]);
if (!state) return null;
const showSplit =
currentStepId !== "agent-waiting" && currentStepId !== "finish";
const isGoingBack = currentIndex < latestIndex;
const BLOCKED_STEPS = ["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-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-card border border-border shadow-2xl overflow-hidden flex flex-col md:flex-row min-h-140">
<div className="flex-1 flex flex-col gap-6 p-8">
<OnboardingStepper />
<div className="flex-1">{renderStep()}</div>
<div className="flex justify-between">
{currentIndex > 0 && (
<Button
type="button"
variant="ghost"
onClick={() => {
let prevId = STEP_ORDER[currentIndex - 1];
if (currentStepId === "project-create") {
prevId = "agent-create";
} else if (currentStepId === "agent-create") {
const notifiers =
(state.context.flowData.notifiers as unknown[]) || [];
const storages =
(state.context.flowData.storages as unknown[]) || [];
if (notifiers.length === 0 && storages.length === 0) {
prevId = "storage";
}
} else if (currentStepId === "finish") {
const agents =
(state.context.flowData.agents as any[]) || [];
if (agents.length === 0) {
prevId = "agent-create";
} else {
const isAgentConnected = agents.some((a) => a.connected);
const databaseIds =
(state.context.flowData.project as any)?.databaseIds ||
[];
if (!isAgentConnected || databaseIds.length === 0) {
prevId = "project-create";
}
}
}
if (prevId) goToStep(prevId);
}}
disabled={!canGoBack || state.isLoading}
>
Back
</Button>
)}
<div className="flex gap-2">
{state.isSkippable && (
<Button
type="button"
variant="ghost"
onClick={() => skip()}
disabled={state.isLoading}
>
Skip
</Button>
)}
{isGoingBack && (
<Button
type="button"
variant="ghost"
onClick={() => next()}
disabled={state.isLoading}
>
Next
</Button>
)}
</div>
</div>
</div>
{showSplit && (
<div className="hidden md:block w-75 border-l border-border bg-muted/30">
<OnboardingChecklist />
</div>
)}
</div>
<footer className="mt-8 text-center text-xs text-muted-foreground flex flex-col gap-1">
<p className="flex items-center justify-center gap-1">
Made with{" "}
<Heart className="size-3 fill-red-500 text-red-500 animate-pulse" />{" "}
by <span className="font-medium text-foreground">Portabase</span>
</p>
</footer>
</div>
);
};
+233
View File
@@ -0,0 +1,233 @@
import "server-only";
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { organization } from "@/db/schema/03_organization";
import { member } from "@/db/schema/04_member";
import { currentUser } from "@/lib/auth/current-user";
import { getSettings } from "@/db/services/setting";
import { hasUsers } from "@/db/services/user";
import {
getUserOrganization,
getUserOwnOrganization,
} from "@/db/services/organization";
import { getOrganizationProject } from "@/db/services/project";
import { getOrganizationAgents } from "@/db/services/agent";
import { getDatabasesSettings } from "@/db/services/database";
import { getOrganizationChannels } from "@/db/services/notification-channel";
import { getOrganizationStorageChannels } from "@/db/services/storage-channel";
import type { AgentWith } from "@/db/schema/08_agent";
import { env } from "@/env.mjs";
import type {
OnboardingDatabase,
OnboardingFlowData,
OnboardingMeta,
} from "@/features/onboarding/types";
import { getOAuthProviders } from "@/lib/auth/oauth";
import { getOidcProviders } from "@/lib/auth/oidc";
import { generateEdgeKey } from "@/utils/edge_key";
import { getServerUrl } from "@/utils/get-server-url";
export type ResolvedOnboardingState =
| { redirect: "/dashboard/home" }
| { stepId: string; flowData: Partial<OnboardingFlowData> };
export async function resolveOnboardingState(): Promise<ResolvedOnboardingState> {
const settings = await getSettings();
if (settings?.onboarding) {
return { redirect: "/dashboard/home" };
}
const meta: OnboardingMeta = {
passkeyEnabled: env.AUTH_PASSKEY_ENABLED === "true",
emailPasswordEnabled: env.AUTH_EMAIL_PASSWORD_ENABLED === "true",
hasExistingUsers: false,
ssoProviders: [],
oauthProviders: [],
defaultUserMode: !!(env.AUTH_DEFAULT_USER && env.AUTH_DEFAULT_PASSWORD),
resumeStepId: "login",
};
meta.hasExistingUsers = await hasUsers();
for (const p of getOidcProviders()) {
meta.ssoProviders.push({
id: p.id,
label: p.title,
icon: p.icon,
description: p.description || undefined,
});
}
for (const p of getOAuthProviders()) {
meta.oauthProviders.push({ id: p.id, label: p.title, icon: p.icon });
}
const user = await currentUser();
if (!user) {
return { stepId: "login", flowData: { meta } };
}
const inDefaultOrg = await getUserOrganization(user.id);
if (!inDefaultOrg) {
const defaultOrg = await db.query.organization.findFirst({
where: eq(organization.slug, "default"),
});
if (defaultOrg) {
await db.insert(member).values({
userId: user.id,
organizationId: defaultOrg.id,
role: "owner",
});
}
}
const org = await getUserOwnOrganization(user.id);
if (!org) {
meta.resumeStepId = "org-create";
return { stepId: "org-create", flowData: { meta } };
}
const orgData = { id: org.id, name: org.name };
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,
provider: n.provider,
label: n.provider,
name: n.name,
config: (n.config as Record<string, unknown>) ?? {},
organizationId: n.organizationId ?? null,
}));
const storages = storageChannels
.filter((s) => s.provider !== "local")
.map((s) => ({
id: s.id,
provider: s.provider,
label: s.provider,
name: s.name,
config: (s.config as Record<string, unknown>) ?? {},
organizationId: s.organizationId ?? null,
}));
const defaults = {
notifierId: settings?.defaultNotificationChannelId ?? undefined,
storageId: settings?.defaultStorageChannelId ?? undefined,
};
const agentData = await Promise.all(
(agents ?? []).map(async (a) => ({
id: a.id,
name: a.name,
edgeKey: await generateEdgeKey(getServerUrl(), a.id),
connected: !!a.lastContact,
})),
);
const databases = (agents as AgentWith[]).flatMap((a) =>
(a.databases ?? []).map((d) => ({
id: d.id,
name: d.name,
engine: (d.dbms === "postgresql"
? "postgres"
: d.dbms) as OnboardingDatabase["engine"],
})),
);
const dbSettings = await getDatabasesSettings(databases.map((d) => d.id));
const fullData: Partial<OnboardingFlowData> = {
meta,
org: orgData,
notifiers,
storages,
defaults,
agents: agentData,
databases,
dbSettings,
...(project
? {
project: {
id: project.id,
name: project.name,
description: "",
databaseIds:
(project as any).databases?.map((db: any) => db.id) ?? [],
},
}
: {}),
};
const hasAgents = agents && agents.length > 0;
const defaultsConfigured =
!!settings?.defaultNotificationChannelId &&
!!settings?.defaultStorageChannelId;
if (project) {
if (!hasAgents) {
if (notifiers.length === 0) {
meta.resumeStepId = "notifier";
return { stepId: "notifier", flowData: fullData };
}
if (storages.length === 0) {
meta.resumeStepId = "storage";
return { stepId: "storage", flowData: fullData };
}
if (defaultsConfigured) {
meta.resumeStepId = "agent-create";
return { stepId: "agent-create", flowData: fullData };
}
meta.resumeStepId = "defaults";
return { stepId: "defaults", flowData: fullData };
}
const agentHasPinged = !!agents[0]?.lastContact;
if (!agentHasPinged) {
meta.resumeStepId = "agent-key";
return { stepId: "agent-key", flowData: fullData };
}
const projectDatabaseIds: string[] =
(project as any).databases?.map((db: any) => db.id) ?? [];
if (projectDatabaseIds.length > 0) {
meta.resumeStepId = "db-settings";
return { stepId: "db-settings", flowData: fullData };
}
meta.resumeStepId = "project-create";
return { stepId: "project-create", flowData: fullData };
}
if (hasAgents) {
const agentHasPinged = !!agents[0]?.lastContact;
const stepId = agentHasPinged ? "project-create" : "agent-key";
meta.resumeStepId = stepId;
return { stepId, flowData: fullData };
}
if (notifiers.length === 0) {
meta.resumeStepId = "notifier";
return { stepId: "notifier", flowData: fullData };
}
if (storages.length === 0) {
meta.resumeStepId = "storage";
return { stepId: "storage", flowData: fullData };
}
if (defaultsConfigured) {
meta.resumeStepId = "agent-create";
return { stepId: "agent-create", flowData: fullData };
}
meta.resumeStepId = "defaults";
return { stepId: "defaults", flowData: fullData };
}
@@ -0,0 +1,31 @@
"use client";
import { useOnboarding } from "@onboardjs/react";
import { Progress } from "@/components/ui/progress";
import { STEP_ORDER } from "@/features/onboarding/constants/steps";
import { useIsMobile } from "@/hooks/use-mobile";
export const OnboardingStepper = () => {
const { state } = useOnboarding();
const mobile = useIsMobile();
if (!state) return null;
const currentId = String(state.currentStep?.id ?? "");
const currentIndex = Math.max(0, STEP_ORDER.indexOf(currentId));
const totalSteps = STEP_ORDER.length;
const stepNumber = currentIndex + 1;
const progress = Math.round((currentIndex / (totalSteps - 1)) * 100);
return (
<div className="flex flex-col gap-2 w-full">
<div className="flex justify-between text-xs text-muted-foreground">
{mobile && (
<span>
Step {stepNumber} of {totalSteps}
</span>
)}
</div>
<Progress value={progress} />
</div>
);
};
@@ -0,0 +1,156 @@
import { OnboardingStep } from "@onboardjs/react";
import { StepLogin } from "@/features/onboarding/steps/step-login";
import { StepAccountInfo } from "@/features/onboarding/steps/step-account-info";
import { StepSecurity } from "@/features/onboarding/steps/step-security";
import { StepOrgCreate } from "@/features/onboarding/steps/step-org-create";
import { StepInviteMembers } from "@/features/onboarding/steps/step-invite-members";
import { StepNotifier } from "@/features/onboarding/steps/step-notifier";
import { StepStorage } from "@/features/onboarding/steps/step-storage";
import { StepDefaults } from "@/features/onboarding/steps/step-defaults";
import { StepAgentCreate } from "@/features/onboarding/steps/step-agent-create";
import { StepAgentKey } from "@/features/onboarding/steps/step-agent-key";
import { StepAgentWaiting } from "@/features/onboarding/steps/step-agent-waiting";
import { StepProjectCreate } from "@/features/onboarding/steps/step-project-create";
import { StepDbSettings } from "@/features/onboarding/steps/step-db-settings";
import { StepFinish } from "@/features/onboarding/steps/step-finish";
export const onboardingSteps: OnboardingStep[] = [
{
id: "login",
component: StepLogin,
isSkippable: false,
nextStep: (ctx: any) =>
ctx.flowData?.meta?.hasExistingUsers ? "org-create" : "account-info",
},
{
id: "account-info",
component: StepAccountInfo,
isSkippable: false,
nextStep: (ctx: any) =>
ctx.flowData?.meta?.passkeyEnabled ? "org-create" : "security",
},
{
id: "security",
component: StepSecurity,
isSkippable: true,
skipToStep: "org-create",
nextStep: "org-create",
},
{
id: "org-create",
component: StepOrgCreate,
isSkippable: false,
//nextStep: "invite-members",
nextStep: "notifier",
},
// {
// id: "invite-members",
// component: StepInviteMembers,
// isSkippable: true,
// skipToStep: "notifier",
// nextStep: "notifier",
// },
{
id: "notifier",
component: StepNotifier,
isSkippable: true,
skipToStep: "storage",
nextStep: "storage",
},
{
id: "storage",
component: StepStorage,
isSkippable: true,
skipToStep: (ctx: any) => {
const notifiers = (ctx.flowData?.notifiers as unknown[]) || [];
const storages = (ctx.flowData?.storages as unknown[]) || [];
return notifiers.length === 0 && storages.length === 0
? "agent-create"
: "defaults";
},
nextStep: (ctx: any) => {
const notifiers = (ctx.flowData?.notifiers as unknown[]) || [];
const storages = (ctx.flowData?.storages as unknown[]) || [];
return notifiers.length === 0 && storages.length === 0
? "agent-create"
: "defaults";
},
},
{
id: "defaults",
component: StepDefaults,
isSkippable: true,
skipToStep: "agent-create",
nextStep: "agent-create",
},
{
id: "agent-create",
component: StepAgentCreate,
isSkippable: true,
skipToStep: (ctx: any) => {
const hasProject = !!ctx.flowData?.project;
return hasProject ? "project-create" : "finish";
},
nextStep: (ctx: any) => {
const agents = ctx.flowData?.agents as unknown[] | undefined;
const hasProject = !!ctx.flowData?.project;
if (agents && agents.length > 0) return "agent-key";
return hasProject ? "project-create" : "finish";
},
},
{
id: "agent-key",
component: StepAgentKey,
isSkippable: false,
nextStep: (ctx: any) => {
const agents = (ctx.flowData?.agents as any[]) || [];
const isConnected = agents.some((a) => a.connected);
return isConnected ? "project-create" : "agent-waiting";
},
},
{
id: "agent-waiting",
component: StepAgentWaiting,
isSkippable: true,
skipToStep: "project-create",
nextStep: "project-create",
},
{
id: "project-create",
component: StepProjectCreate,
isSkippable: true,
skipToStep: (ctx: any) => {
const agents = (ctx.flowData?.agents as any[]) || [];
if (agents.length === 0) return "finish";
const isAgentConnected = agents.some((a) => a.connected);
const databaseIds =
(ctx.flowData?.project?.databaseIds as string[]) || [];
return !isAgentConnected || databaseIds.length === 0
? "finish"
: "db-settings";
},
nextStep: (ctx: any) => {
const agents = (ctx.flowData?.agents as any[]) || [];
if (agents.length === 0) return "finish";
const isAgentConnected = agents.some((a) => a.connected);
const databaseIds =
(ctx.flowData?.project?.databaseIds as string[]) || [];
return !isAgentConnected || databaseIds.length === 0
? "finish"
: "db-settings";
},
},
{
id: "db-settings",
component: StepDbSettings,
isSkippable: true,
skipToStep: "finish",
nextStep: "finish",
},
{
id: "finish",
component: StepFinish,
isSkippable: false,
nextStep: null,
},
];
@@ -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"),
});
@@ -0,0 +1,44 @@
import { z } from "zod";
export const RetentionSchema = z.object({
type: z.enum(["count", "days", "gfs"]).optional(),
count: z.number().min(1).max(100),
days: z.number().min(1).max(3650),
gfs: z.object({
daily: z.number().min(1).max(31),
weekly: z.number().min(0).max(52),
monthly: z.number().min(0).max(120),
yearly: z.number().min(0).max(50),
}),
});
export const EventKindSchema = z.enum([
"error_backup",
"error_restore",
"success_restore",
"success_backup",
"weekly_report",
"error_health_agent",
"error_health_database",
] as const);
export const NotifPolicySchema = z.object({
channelId: z.string().min(1),
eventKinds: z.array(EventKindSchema),
enabled: z.boolean(),
});
export const StoragePolicyInputSchema = z.object({
channelId: z.string().min(1),
enabled: z.boolean(),
});
export const ApplyDbSettingsSchema = z.object({
databaseId: z.string().min(1),
section: z.enum(["retention", "scheduling", "notifications", "storage", "all"]),
retention: RetentionSchema.optional(),
backupMethod: z.enum(["manual", "automatic"]).optional(),
backupCron: z.string().optional(),
notificationPolicies: z.array(NotifPolicySchema).optional(),
storagePolicies: z.array(StoragePolicyInputSchema).optional(),
});
@@ -0,0 +1,190 @@
"use client";
import { useEffect } from "react";
import { useOnboarding } from "@onboardjs/react";
import { useSession } from "@/lib/auth/auth-client";
import { z } from "zod";
import {
useZodForm,
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input";
import { Button } from "@/components/ui/button";
import {
BaseSchema,
WithPasswordSchema,
} from "@/features/onboarding/schemas/account.schema";
import { useUpdateAccount } from "@/features/onboarding/hooks/use-update-account";
import type { OnboardingMeta } from "@/features/onboarding/types";
export const StepAccountInfo = () => {
const { next, state } = useOnboarding();
const { data: session, refetch: refetchSession } = useSession();
const meta = state?.context.flowData.meta as OnboardingMeta | undefined;
const existingAccount = state?.context.flowData.account;
const isUpdateMode = !!existingAccount;
const passkeyEnabled = meta?.passkeyEnabled ?? false;
const emailPasswordEnabled = meta?.emailPasswordEnabled ?? false;
useEffect(() => {
if (session?.user && !isUpdateMode) {
next();
}
}, [session?.user?.id, next, session?.user, isUpdateMode]);
const schema = isUpdateMode
? BaseSchema
: z.object({
...BaseSchema.shape,
password: z.string().optional(),
});
const form = useZodForm({ schema }) as unknown as ReturnType<
typeof useZodForm<typeof WithPasswordSchema>
>;
const mutation = useUpdateAccount(refetchSession);
const onSubmitPassword = async () => {
const valid = await form.trigger();
if (!valid) return;
const values = form.getValues();
if (!values.password || values.password.length < 8) {
form.setError("password", { message: "Min. 8 characters" });
return;
}
mutation.mutateAsync({ ...(values as any), method: "password" });
};
const onSubmitPasskey = async () => {
const valid = await form.trigger(["firstName", "lastName", "email"] as any);
if (!valid) return;
mutation.mutateAsync({ ...(form.getValues() as any), method: "passkey" });
};
return (
<div className="flex flex-col gap-4">
<div>
<h1 className="text-2xl font-semibold">
{isUpdateMode ? "Update your account" : "Create your account"}
</h1>
<p className="text-sm text-muted-foreground mt-1">
This step can&apos;t be skipped.
</p>
</div>
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
if (isUpdateMode) {
mutation.mutateAsync({ ...(values as any) });
} else if (!emailPasswordEnabled) {
mutation.mutateAsync({ ...(values as any), method: "passkey" });
} else {
if (!values.password || (values.password as string).length < 8) {
form.setError("password", { message: "Min. 8 characters" });
return;
}
mutation.mutateAsync({ ...(values as any), method: "password" });
}
}}
>
<div className="grid grid-cols-2 gap-3">
<FormField
control={form.control}
name="firstName"
defaultValue={existingAccount?.firstName ?? ""}
render={({ field }) => (
<FormItem>
<FormLabel>First name</FormLabel>
<FormControl>
<Input placeholder="John" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="lastName"
defaultValue={existingAccount?.lastName ?? ""}
render={({ field }) => (
<FormItem>
<FormLabel>Last name</FormLabel>
<FormControl>
<Input placeholder="Doe" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={form.control}
name="email"
defaultValue={existingAccount?.email ?? ""}
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="john@example.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{!isUpdateMode && emailPasswordEnabled && (
<FormField
control={form.control}
name="password"
defaultValue=""
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput placeholder="Min. 8 characters" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<div className="flex flex-col gap-2 mt-2">
{isUpdateMode ? (
<Button type="submit" disabled={mutation.isPending}>
Update account
</Button>
) : (
<>
{emailPasswordEnabled && (
<Button
type="button"
onClick={onSubmitPassword}
disabled={mutation.isPending}
>
Create account
</Button>
)}
{passkeyEnabled && (
<Button
type="button"
variant={emailPasswordEnabled ? "outline" : "default"}
onClick={onSubmitPasskey}
disabled={mutation.isPending}
>
Create account with passkey
</Button>
)}
</>
)}
</div>
</Form>
</div>
);
};
@@ -0,0 +1,78 @@
"use client";
import { useState } from "react";
import { useOnboarding } from "@onboardjs/react";
import { X } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { useCreateAgent } from "@/features/onboarding/hooks/use-create-agent";
import { useDeleteAgent } from "@/features/onboarding/hooks/use-delete-agent";
import type { OnboardingAgent } from "@/features/onboarding/types";
export const StepAgentCreate = () => {
const { next, state } = useOnboarding();
const agents = (state?.context.flowData.agents ?? []) as OnboardingAgent[];
const [name, setName] = useState("");
const createAgent = useCreateAgent();
const deleteAgent = useDeleteAgent();
const onAdd = () => {
if (!name.trim()) return;
createAgent.mutate(name.trim(), { onSuccess: () => setName("") });
};
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>
</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={() => deleteAgent.mutate(agent.id)}
disabled={deleteAgent.isPending}
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" && onAdd()}
disabled={createAgent.isPending}
/>
<Button
type="button"
variant="outline"
onClick={onAdd}
disabled={createAgent.isPending || !name.trim()}
>
{createAgent.isPending ? "Adding…" : "Add"}
</Button>
</div>
<Button type="button" onClick={() => next()}>
Continue
</Button>
</div>
);
};
@@ -0,0 +1,63 @@
"use client";
import { useState } from "react";
import { useOnboarding } from "@onboardjs/react";
import { Server } from "lucide-react";
import { Button } from "@/components/ui/button";
import { AgentCardKey } from "@/features/agents/components/agent-card-key";
import type { OnboardingAgent } from "@/features/onboarding/types";
import { cn } from "@/lib/utils";
export const StepAgentKey = () => {
const { next, state } = useOnboarding();
const agents = (state?.context.flowData.agents ?? []) as OnboardingAgent[];
const [selectedId, setSelectedId] = useState<string>(agents[0]?.id ?? "");
const selected = agents.find((a) => a.id === selectedId) ?? agents[0];
return (
<div className="flex flex-col gap-4">
<div>
<h1 className="text-2xl font-semibold">Connect your agent</h1>
<p className="text-sm text-muted-foreground mt-1">
Run the command below on your server. The next step waits for the
agent to connect.
</p>
</div>
{agents.length > 1 && (
<div className="flex flex-col gap-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-widest">
Agents
</p>
<div className="flex flex-col gap-1.5 max-h-36 overflow-y-auto scrollbar-hide">
{agents.map((agent) => (
<button
key={agent.id}
type="button"
onClick={() => setSelectedId(agent.id)}
className={cn(
"flex items-center gap-2.5 rounded-lg border px-3 py-2 text-sm transition-all text-left",
selectedId === agent.id
? "border-primary/20 bg-primary/10 text-primary"
: "border-border hover:bg-accent/50 hover:border-primary/20",
)}
>
<Server className="size-3.5 shrink-0" />
<span className="font-medium truncate">{agent.name}</span>
</button>
))}
</div>
</div>
)}
{selected?.edgeKey && (
<AgentCardKey edgeKey={selected.edgeKey} agentName={selected.name} />
)}
<Button type="button" onClick={() => next()}>
I&apos;ve run the command
</Button>
</div>
);
};
@@ -0,0 +1,36 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useOnboarding } from "@onboardjs/react";
import { Loader2 } from "lucide-react";
import { useAgentStatus } from "@/features/onboarding/hooks/use-agent-status";
import type { OnboardingAgent } from "@/features/onboarding/types";
export const StepAgentWaiting = () => {
const { state } = useOnboarding();
const router = useRouter();
const agents = (state?.context.flowData.agents ?? []) as OnboardingAgent[];
const { data, isLoading } = useAgentStatus();
useEffect(() => {
if (data?.connected) {
router.refresh();
}
}, [data?.connected, router]);
if (isLoading || data?.connected) return null;
return (
<div className="flex flex-col items-center justify-center gap-4 h-full text-center">
<Loader2 className="size-10 animate-spin text-primary" />
<h1 className="text-xl font-semibold">Waiting for your agent</h1>
<p className="text-sm text-muted-foreground">
Checking connectivity every 3 seconds
</p>
{agents.length > 0 && (
<p className="text-xs text-muted-foreground">Agent: {agents[0].name}</p>
)}
</div>
);
};
@@ -0,0 +1,180 @@
"use client";
import { useState } from "react";
import { useOnboarding } from "@onboardjs/react";
import { toast } from "sonner";
import { DbGrid } from "@/features/onboarding/components/db-settings/db-grid";
import { DbDetail } from "@/features/onboarding/components/db-settings/db-detail";
import { DbSection } from "@/features/onboarding/components/db-settings/db-section";
import { useApplyDbSettings } from "@/features/onboarding/hooks/use-apply-db-settings";
import type {
OnboardingChannel,
OnboardingDatabase,
OnboardingDbSettings,
OnboardingProjectData,
SectionKind,
} from "@/features/onboarding/types";
type Phase =
| { kind: "grid" }
| { kind: "db"; dbId: string }
| { kind: "section"; dbId: string; section: SectionKind };
export const StepDbSettings = () => {
const { next, updateContext, state } = useOnboarding();
const [phase, setPhase] = useState<Phase>({ kind: "grid" });
const project = (state?.context.flowData.project ?? {
databaseIds: [],
}) as OnboardingProjectData;
const databases = (state?.context.flowData.databases ??
[]) as OnboardingDatabase[];
const notifiers = (state?.context.flowData.notifiers ??
[]) as OnboardingChannel[];
const storages = (state?.context.flowData.storages ??
[]) as OnboardingChannel[];
const dbSettings = (state?.context.flowData.dbSettings ?? {}) as Record<
string,
OnboardingDbSettings
>;
const databaseIds = project.databaseIds;
const applyMutation = useApplyDbSettings();
if (!databaseIds || databaseIds.length === 0) return null;
const getDb = (id: string) => databases.find((d) => d.id === id);
const isSectionConfigured = (dbId: string, section: SectionKind): boolean => {
const s = dbSettings[dbId];
if (!s) return false;
switch (section) {
case "retention":
return !!s.retention;
case "scheduling":
return s.backupMethod === "automatic";
case "notifications":
return (s.notificationPolicies?.length ?? 0) > 0;
case "storage":
return (s.storagePolicies?.length ?? 0) > 0;
}
};
const SECTION_KINDS: SectionKind[] = [
"retention",
"scheduling",
"notifications",
"storage",
];
const isDbConfigured = (dbId: string) =>
SECTION_KINDS.some((k) => isSectionConfigured(dbId, k));
const updateDbSettings = async (
dbId: string,
patch: Partial<OnboardingDbSettings>,
) => {
const updated = {
...dbSettings,
[dbId]: { ...(dbSettings[dbId] ?? {}), ...patch },
};
await updateContext({
flowData: { ...state?.context.flowData, dbSettings: updated },
});
};
const handleApplyToAll = async (dbId: string) => {
const settings = dbSettings[dbId] ?? {};
const otherDbIds = databaseIds.filter((id) => id !== dbId);
const succeededIds: string[] = [];
for (const targetId of otherDbIds) {
try {
await applyMutation.mutateAsync({
databaseId: targetId,
section: "all",
retention: settings.retention,
backupMethod: settings.backupMethod,
backupCron: settings.backupCron,
notificationPolicies: settings.notificationPolicies,
storagePolicies: settings.storagePolicies,
});
succeededIds.push(targetId);
} catch (err) {
console.error("Failed to apply settings to database", targetId, err);
}
}
if (succeededIds.length > 0) {
const updatedSettings = { ...dbSettings };
succeededIds.forEach((id) => {
updatedSettings[id] = { ...(dbSettings[id] ?? {}), ...settings };
});
await updateContext({
flowData: { ...state?.context.flowData, dbSettings: updatedSettings },
});
}
if (succeededIds.length === otherDbIds.length) {
toast.success("Settings applied to all databases.");
setPhase({ kind: "grid" });
} else if (succeededIds.length > 0) {
toast.warning(
`Settings applied to ${succeededIds.length} of ${otherDbIds.length} databases.`,
);
setPhase({ kind: "grid" });
}
};
if (phase.kind === "grid")
return (
<DbGrid
databaseIds={databaseIds}
getDb={getDb}
isDbConfigured={isDbConfigured}
onSelectDb={(dbId) => setPhase({ kind: "db", dbId })}
onContinue={() => next()}
/>
);
if (phase.kind === "db") {
const { dbId } = phase;
return (
<DbDetail
dbId={dbId}
db={getDb(dbId)}
isSectionConfigured={(s) => isSectionConfigured(dbId, s)}
isMultiDb={databaseIds.length > 1}
hasAnyConfigured={SECTION_KINDS.some((k) =>
isSectionConfigured(dbId, k),
)}
isApplyingToAll={applyMutation.isPending}
onSelectSection={(section) =>
setPhase({ kind: "section", dbId, section })
}
onApplyToAll={() => handleApplyToAll(dbId)}
onBack={() => setPhase({ kind: "grid" })}
/>
);
}
if (phase.kind === "section") {
const { dbId, section } = phase;
return (
<DbSection
dbId={dbId}
db={getDb(dbId)}
section={section}
settings={dbSettings[dbId] ?? {}}
applyMutation={applyMutation}
notifiers={notifiers}
storages={storages}
updateDbSettings={updateDbSettings}
onBack={() => setPhase({ kind: "db", dbId })}
onSaved={() => setPhase({ kind: "db", dbId })}
/>
);
}
return null;
};
@@ -0,0 +1,202 @@
"use client";
import { useState } from "react";
import { useOnboarding } from "@onboardjs/react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import type {
OnboardingChannel,
OnboardingDefaultsData,
} from "@/features/onboarding/types";
import { getChannelIcon } from "@/features/channel/components/channels-helpers";
import { updateNotificationSettingsAction } from "@/features/settings/actions/notification.action";
import { updateStorageSettingsAction } from "@/features/settings/actions/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 existingDefaults = (state?.context.flowData.defaults ??
{}) as OnboardingDefaultsData;
const [notifierId, setNotifierId] = useState<string | undefined>(
existingDefaults.notifierId || undefined,
);
const [storageId, setStorageId] = useState<string | undefined>(
existingDefaults.storageId || undefined,
);
const selectNotifier = async (value: string) => {
const prev = notifierId;
setNotifierId(value);
try {
await updateNotificationSettingsAction({
name: "system",
data: { notificationChannelId: value },
});
await updateContext({
flowData: {
...state?.context.flowData,
defaults: { notifierId: value, storageId },
},
});
} catch {
setNotifierId(prev);
toast.error("Failed to save default notifier");
}
};
const selectStorage = async (value: string) => {
const prev = storageId;
setStorageId(value);
try {
await updateStorageSettingsAction({
name: "system",
data: { storageChannelId: value, encryption: false },
});
await updateContext({
flowData: {
...state?.context.flowData,
defaults: { notifierId, storageId: value },
},
});
} catch {
setStorageId(prev);
toast.error("Failed to save default storage");
}
};
const onContinue = async () => {
await updateContext({
flowData: {
...state?.context.flowData,
defaults: { notifierId, storageId },
},
});
await next();
};
const selectedNotifier = notifiers.find((n) => n.id === notifierId);
const selectedStorage = storages.find((s) => s.id === storageId);
return (
<div className="flex flex-col gap-4">
<div>
<h1 className="text-2xl font-semibold">Set your defaults</h1>
<p className="text-sm text-muted-foreground mt-1">
Optional choose the default notifier and storage.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex flex-col gap-2">
<Label>Default notifier</Label>
<Select
value={selectedNotifier ? notifierId : undefined}
onValueChange={selectNotifier}
disabled={notifiers.length === 0}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
notifiers.length === 0
? "No notifier connected"
: "Choose a notifier"
}
>
{selectedNotifier && (
<div className="flex items-center gap-2 min-w-0">
<div className="text-muted-foreground scale-90 shrink-0">
{getChannelIcon(selectedNotifier.provider)}
</div>
<span className="truncate font-medium">
{selectedNotifier.name}
</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{notifiers.map((n) => (
<SelectItem key={n.id} value={n.id}>
<div className="flex items-center gap-2 w-full min-w-0">
<div className="text-muted-foreground scale-90 shrink-0">
{getChannelIcon(n.provider)}
</div>
<span className="font-medium truncate min-w-0">
{n.name}
</span>
<span className="text-xs text-muted-foreground ml-2 capitalize shrink-0">
({n.provider})
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label>Default storage</Label>
<Select
value={selectedStorage ? storageId : undefined}
onValueChange={selectStorage}
disabled={storages.length === 0}
>
<SelectTrigger className="w-full">
<SelectValue
placeholder={
storages.length === 0
? "No storage connected"
: "Choose a storage"
}
>
{selectedStorage && (
<div className="flex items-center gap-2 min-w-0">
<div className="text-muted-foreground scale-90 shrink-0">
{getChannelIcon(selectedStorage.provider)}
</div>
<span className="truncate font-medium">
{selectedStorage.name}
</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{storages.map((s) => (
<SelectItem key={s.id} value={s.id}>
<div className="flex items-center gap-2 w-full min-w-0">
<div className="text-muted-foreground scale-90 shrink-0">
{getChannelIcon(s.provider)}
</div>
<span className="font-medium truncate min-w-0">
{s.name}
</span>
<span className="text-xs text-muted-foreground ml-2 capitalize shrink-0">
({s.provider})
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<Button type="button" onClick={onContinue}>
Continue
</Button>
</div>
);
};
@@ -0,0 +1,42 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import confetti from "canvas-confetti";
import { CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useMarkOnboardingDone } from "@/features/onboarding/hooks/use-mark-onboarding-done";
export const StepFinish = () => {
const router = useRouter();
const fired = useRef(false);
const mutation = useMarkOnboardingDone();
const [isRedirecting, setIsRedirecting] = useState(false);
useEffect(() => {
if (fired.current) return;
fired.current = true;
confetti({ particleCount: 150, spread: 80, origin: { y: 0.6 } });
}, []);
return (
<div className="flex flex-col items-center justify-center gap-4 h-full text-center">
<CheckCircle2 className="size-16 text-green-500" />
<h1 className="text-2xl font-semibold">You&apos;re all set!</h1>
<p className="text-sm text-muted-foreground">
Your workspace is ready to use.
</p>
<Button
type="button"
disabled={mutation.isPending || isRedirecting}
onClick={async () => {
await mutation.mutateAsync();
setIsRedirecting(true);
router.push("/dashboard/home");
}}
>
Go to dashboard
</Button>
</div>
);
};
@@ -0,0 +1,65 @@
"use client";
import { useState } from "react";
import { useOnboarding } from "@onboardjs/react";
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/types";
export const StepInviteMembers = () => {
const { next, updateContext, state } = useOnboarding();
const [email, setEmail] = useState("");
const [members, setMembers] = useState<OnboardingMember[]>([]);
const addMember = () => {
if (!email.trim()) return;
setMembers((prev) => [...prev, { email: email.trim(), role: "member" }]);
setEmail("");
};
const removeMember = (target: string) => {
setMembers((prev) => prev.filter((m) => m.email !== target));
};
const onContinue = async () => {
await updateContext({ flowData: { ...state?.context.flowData, members } });
await next();
};
return (
<div className="flex flex-col gap-4">
<div>
<h1 className="text-2xl font-semibold">Invite your team</h1>
<p className="text-sm text-muted-foreground mt-1">
Optional you can always invite people later.
</p>
</div>
<div className="flex gap-2">
<Input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="teammate@portabase.io"
onKeyDown={(e) => e.key === "Enter" && addMember()}
/>
<Button type="button" variant="outline" onClick={addMember}>
Add
</Button>
</div>
<div className="flex flex-wrap gap-2">
{members.map((member) => (
<Badge key={member.email} variant="secondary" className="gap-1">
{member.email}
<button type="button" onClick={() => removeMember(member.email)}>
<X className="size-3" />
</button>
</Badge>
))}
</div>
<Button type="button" onClick={onContinue}>
Continue
</Button>
</div>
);
};
@@ -0,0 +1,314 @@
"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";
import { toast } from "sonner";
import { Globe, Mail, KeyRound, ShieldAlert } from "lucide-react";
import { Icon } from "@iconify/react";
import Image from "next/image";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input";
import {
useZodForm,
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
} from "@/components/ui/form";
import { signIn, useSession } from "@/lib/auth/auth-client";
import type { OnboardingMeta } from "@/features/onboarding/types";
const LoginSchema = z.object({
email: z.email("Invalid email"),
password: z.string().min(1, "Password required"),
});
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();
const passkeyEnabled = meta?.passkeyEnabled ?? false;
const emailPasswordEnabled = meta?.emailPasswordEnabled ?? false;
const hasAnySsoProvider = (meta?.ssoProviders?.length ?? 0) > 0;
const hasAnyOAuthProvider = (meta?.oauthProviders?.length ?? 0) > 0;
const hasAnyAuthMethod =
emailPasswordEnabled || passkeyEnabled || hasAnySsoProvider || hasAnyOAuthProvider;
useEffect(() => {
if (session?.user) {
router.push("/welcome");
}
}, [session?.user, router]);
const form = useZodForm({ schema: LoginSchema });
const passkeyMutation = useMutation({
mutationFn: async () => {
const result = await (signIn as any).passkey();
if (result?.error)
throw new Error(result.error.message ?? "Passkey sign in failed");
router.push("/welcome");
},
onError: (err: Error) => {
toast.error(err.message);
},
});
const loginMutation = useMutation({
mutationFn: async (values: LoginValues) => {
const result = await signIn.email({
email: values.email,
password: values.password,
});
if (result.error)
throw new Error(result.error.message ?? "Sign in failed");
router.push("/welcome");
},
onError: (err: Error) => {
toast.error(err.message);
},
});
const handleOAuth = async (providerId: string) => {
const result = await signIn.social({
provider: providerId as any,
callbackURL: "/welcome",
});
if (result?.error)
toast.error(result.error.message ?? "OAuth sign in failed");
};
const handleSso = async (providerId: string) => {
const result = await signIn.sso({
providerId,
providerType: "oidc",
callbackURL: "/welcome",
});
if (result?.error)
toast.error(result.error.message ?? "SSO sign in failed");
};
if (!hasAnyAuthMethod) {
return (
<div className="flex flex-col items-center justify-center text-center py-8 gap-4">
<div className="p-4 rounded-full bg-yellow-500/10 mb-2">
<ShieldAlert className="size-8 text-yellow-500" />
</div>
<div>
<h1 className="text-2xl font-semibold">Configuration needed</h1>
<p className="text-sm text-muted-foreground mt-2 max-w-70 mx-auto">
Please enable an authentication method in your environment variables
to continue.
</p>
</div>
<div className="text-left mt-2 rounded-lg border border-yellow-500/30 bg-yellow-500/10 p-4 text-sm text-yellow-600 dark:text-yellow-400 max-w-sm w-full">
<p className="font-medium mb-2">Set at least one of these:</p>
<ul className="space-y-1">
<li>
{" "}
<code className="font-mono text-xs">
AUTH_EMAIL_PASSWORD_ENABLED=true
</code>
</li>
<li>
{" "}
<code className="font-mono text-xs">
AUTH_PASSKEY_ENABLED=true
</code>
</li>
<li> Or configure an SSO provider</li>
</ul>
</div>
</div>
);
}
if (!meta?.hasExistingUsers) {
return (
<div className="flex flex-col gap-4">
<div>
<h1 className="text-2xl font-semibold">Welcome to Portabase</h1>
<p className="text-sm text-muted-foreground mt-1">
Set up your instance by creating the first account.
</p>
</div>
{(hasAnySsoProvider || hasAnyOAuthProvider) && (
<div className="flex flex-col gap-2">
{meta?.ssoProviders.map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => handleSso(provider.id)}
className="flex items-center gap-3 rounded-lg border border-border p-3 text-sm hover:bg-accent/50 hover:border-primary/20 transition-colors w-full"
>
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
{provider.icon ? (
provider.icon.startsWith("/") || provider.icon.startsWith("http") ? (
<Image
src={provider.icon}
alt={provider.label || "icon"}
width={16}
height={16}
className="size-4"
unoptimized={provider.icon.startsWith("http")}
/>
) : (
<Icon icon={provider.icon} className="size-4" />
)
) : (
<Globe className="size-4 text-muted-foreground" />
)}
</div>
<div className="flex flex-col items-start text-left">
<span>Continue with {provider.label}</span>
{provider.description && (
<span className="text-xs text-muted-foreground">{provider.description}</span>
)}
</div>
</button>
))}
{meta?.oauthProviders.map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => handleOAuth(provider.id)}
className="flex items-center gap-3 rounded-lg border border-border p-3 text-sm hover:bg-accent/50 hover:border-primary/20 transition-colors w-full"
>
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
<Icon icon={provider.icon} className="size-4" />
</div>
<span>Continue with {provider.label}</span>
</button>
))}
</div>
)}
{(emailPasswordEnabled || passkeyEnabled) && (
<Button type="button" onClick={() => next()}>
{passkeyEnabled && !emailPasswordEnabled ? (
<KeyRound className="size-4 mr-2" />
) : (
<Mail className="size-4 mr-2" />
)}
{emailPasswordEnabled && passkeyEnabled
? "Register account"
: passkeyEnabled
? "Register with passkey"
: "Register with email"}
</Button>
)}
</div>
);
}
return (
<div className="flex flex-col gap-4">
<div>
<h1 className="text-2xl font-semibold">Welcome back</h1>
<p className="text-sm text-muted-foreground mt-1">
{meta?.defaultUserMode
? "Sign in to continue onboarding."
: "Your session expired. Sign in to continue where you left off."}
</p>
</div>
{(hasAnySsoProvider || hasAnyOAuthProvider) && !meta?.defaultUserMode && (
<div className="flex flex-col gap-2">
{meta?.ssoProviders.map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => handleSso(provider.id)}
className="flex items-center gap-3 rounded-lg border border-border p-3 text-sm hover:bg-accent/50 hover:border-primary/20 transition-colors w-full"
>
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
{provider.icon ? (
<Icon icon={provider.icon} className="size-4" />
) : (
<Globe className="size-4 text-muted-foreground" />
)}
</div>
<div className="flex flex-col items-start text-left">
<span>Continue with {provider.label}</span>
{provider.description && (
<span className="text-xs text-muted-foreground">{provider.description}</span>
)}
</div>
</button>
))}
{meta?.oauthProviders.map((provider) => (
<button
key={provider.id}
type="button"
onClick={() => handleOAuth(provider.id)}
className="flex items-center gap-3 rounded-lg border border-border p-3 text-sm hover:bg-accent/50 hover:border-primary/20 transition-colors w-full"
>
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
<Icon icon={provider.icon} className="size-4" />
</div>
<span>Continue with {provider.label}</span>
</button>
))}
</div>
)}
{passkeyEnabled && (
<Button
type="button"
variant="outline"
onClick={() => passkeyMutation.mutate()}
disabled={passkeyMutation.isPending}
>
<KeyRound className="size-4 mr-2" />
Sign in with passkey
</Button>
)}
{emailPasswordEnabled && (
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => loginMutation.mutateAsync(values)}
>
<FormField
control={form.control}
name="email"
defaultValue=""
render={({ field }) => (
<FormItem>
<FormLabel>Email</FormLabel>
<FormControl>
<Input placeholder="your@email.com" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
defaultValue=""
render={({ field }) => (
<FormItem>
<FormLabel>Password</FormLabel>
<FormControl>
<PasswordInput placeholder="Your password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" disabled={loginMutation.isPending}>
Sign in
</Button>
</Form>
)}
</div>
);
};
@@ -0,0 +1,215 @@
"use client";
import { useState } from "react";
import { useOnboarding } from "@onboardjs/react";
import { ArrowLeft, Check, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
useZodForm,
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
} from "@/components/ui/form";
import { notificationProviders } from "@/features/channel/components/channels-notification-helper";
import { renderChannelForm } from "@/features/channel/components/channels-helpers";
import { NotificationChannelFormSchema } from "@/features/channel/schemas/channel-form.schema";
import { useAddNotifier } from "@/features/onboarding/hooks/use-add-notifier";
import { useRemoveNotifier } from "@/features/onboarding/hooks/use-remove-notifier";
import type { OnboardingChannel } from "@/features/onboarding/types";
type Phase = { kind: "grid" } | { kind: "configuring"; provider: string };
export const StepNotifier = () => {
const { next, updateContext, state } = useOnboarding();
const notifiers = (state?.context.flowData.notifiers ??
[]) as OnboardingChannel[];
const [phase, setPhase] = useState<Phase>({ kind: "grid" });
const form = useZodForm({ schema: NotificationChannelFormSchema });
const addNotifier = useAddNotifier();
const removeNotifier = useRemoveNotifier();
const startConfiguring = (provider: string) => {
if (notifiers.some((c) => c.provider === provider)) return;
form.reset({ provider, enabled: true, name: "", config: {} } as any);
setPhase({ kind: "configuring", provider });
};
const onContinue = async () => {
await updateContext({
flowData: { ...state?.context.flowData, notifiers },
});
await next();
};
if (phase.kind === "configuring") {
const providerDetails = notificationProviders.find(
(p) => p.value === phase.provider,
);
const Icon = providerDetails?.icon;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3 p-3 bg-secondary/30 rounded-lg border border-border">
{Icon && (
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center">
<Icon className="size-5" />
</div>
)}
<p className="flex-1 text-sm font-medium">
Configuring {providerDetails?.label}
</p>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setPhase({ kind: "grid" })}
>
<ArrowLeft className="size-4 mr-1" />
Back
</Button>
</div>
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values: any) => {
const details = notificationProviders.find(
(p) => p.value === values.provider,
);
addNotifier.mutate(
{
provider: values.provider,
name: values.name,
config: values.config,
label: details?.label ?? values.provider,
},
{
onSuccess: () => {
form.reset({ enabled: true } as any);
setPhase({ kind: "grid" });
},
},
);
}}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Channel name *</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
placeholder={`e.g. ${providerDetails?.label ?? ""} alerts`}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="provider"
render={({ field }) => (
<input type="hidden" {...field} value={field.value || ""} />
)}
/>
{renderChannelForm(phase.provider, form)}
<Button type="submit" disabled={addNotifier.isPending}>
{addNotifier.isPending ? "Saving…" : "Add channel"}
</Button>
</Form>
</div>
);
}
const configuredProviderIds = notifiers.map((c) => c.provider);
const availableProviders = notificationProviders.filter((p) => !p.preview);
return (
<div className="flex flex-col gap-4">
<div>
<h1 className="text-2xl font-semibold">Connect a notifier</h1>
<p className="text-sm text-muted-foreground mt-1">
Optional get notified about backups, restores and health checks.
</p>
</div>
{notifiers.length > 0 && (
<div className="flex flex-col gap-1">
{notifiers.map((ch) => {
const details = notificationProviders.find(
(p) => p.value === ch.provider,
);
const Icon = details?.icon;
return (
<div
key={ch.id}
className="flex items-center gap-2 rounded-lg border border-primary/20 bg-primary/10 p-2 text-sm text-primary"
>
{Icon && (
<div className="size-7 rounded-md border bg-muted/50 flex items-center justify-center shrink-0">
<Icon className="size-4" />
</div>
)}
<span className="flex-1 truncate">
{ch.name} <span className="opacity-60">({ch.label})</span>
</span>
<button
type="button"
onClick={() => removeNotifier.mutate(ch.id)}
disabled={removeNotifier.isPending}
className="opacity-50 hover:opacity-100 transition-opacity"
>
<X className="size-4" />
</button>
</div>
);
})}
</div>
)}
<div className="grid grid-cols-2 gap-2">
{availableProviders.map((provider) => {
const Icon = provider.icon;
const isConfigured = configuredProviderIds.includes(provider.value);
return (
<button
key={provider.value}
type="button"
onClick={() => startConfiguring(provider.value)}
className={`flex items-center gap-2 rounded-lg border p-2 text-sm transition-colors ${
isConfigured
? "border-primary/20 bg-primary/10 text-primary"
: "border-border hover:bg-accent/50 hover:border-primary/20"
}`}
>
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
<Icon className="size-4" />
</div>
<span className="flex-1 text-left">{provider.label}</span>
{isConfigured && (
<div className="size-5 rounded-full bg-primary flex items-center justify-center ml-auto">
<Check
className="size-3 text-primary-foreground"
strokeWidth={3}
/>
</div>
)}
</button>
);
})}
</div>
<Button type="button" onClick={onContinue}>
Continue
</Button>
</div>
);
};
@@ -0,0 +1,52 @@
"use client";
import { useState } from "react";
import { useOnboarding } from "@onboardjs/react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useCreateOrg } from "@/features/onboarding/hooks/use-create-org";
export const StepOrgCreate = () => {
const { state } = useOnboarding();
const existingOrg = state?.context.flowData.org;
const isEditMode = !!existingOrg;
const [name, setName] = useState(existingOrg?.name ?? "");
const mutation = useCreateOrg();
return (
<div className="flex flex-col gap-4">
<div>
<h1 className="text-2xl font-semibold">
{isEditMode ? "Edit your organisation" : "Create your organisation"}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{isEditMode
? "Rename your organisation."
: "This step can't be skipped."}
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="org-name">Organisation name</Label>
<Input
id="org-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="Acme Inc."
/>
</div>
<Button
type="button"
onClick={() => mutation.mutate(name)}
disabled={!name.trim() || mutation.isPending}
>
{mutation.isPending
? isEditMode
? "Saving…"
: "Creating…"
: "Continue"}
</Button>
</div>
);
};
@@ -0,0 +1,126 @@
"use client";
import { useState } from "react";
import { useOnboarding } from "@onboardjs/react";
import { Check, Database, Loader2 } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
import { useCreateProject } from "@/features/onboarding/hooks/use-create-project";
import type {
OnboardingDatabase,
OnboardingProjectData,
} from "@/features/onboarding/types";
export const StepProjectCreate = () => {
const { state, next } = useOnboarding();
const existingProject = state?.context.flowData.project as
| OnboardingProjectData
| undefined;
const databases = (state?.context.flowData.databases ??
[]) as OnboardingDatabase[];
const isUpdateMode = !!existingProject;
const [name, setName] = useState(existingProject?.name ?? "");
const [databaseIds, setDatabaseIds] = useState<string[]>(
existingProject?.databaseIds ?? [],
);
const [loadingDbId, setLoadingDbId] = useState<string | null>(null);
const mutation = useCreateProject();
const toggleDb = (id: string) => {
setLoadingDbId(id);
const newDbIds = databaseIds.includes(id)
? databaseIds.filter((v) => v !== id)
: [...databaseIds, id];
setDatabaseIds(newDbIds);
mutation.mutate(
{ name: name || "My project", databaseIds: newDbIds },
{ onSettled: () => setLoadingDbId(null) },
);
};
return (
<div className="flex flex-col gap-4">
<div>
<h1 className="text-2xl font-semibold">
{isUpdateMode ? "Update project" : "Create a project"}
</h1>
<p className="text-sm text-muted-foreground mt-1">
Optional group databases under a project.
</p>
</div>
<div className="flex flex-col gap-2">
<Label htmlFor="project-name">Project name</Label>
<Input
id="project-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="My project"
/>
</div>
{databases.length > 0 && (
<div className="flex flex-col gap-2">
<Label>Select databases</Label>
<div className="flex flex-col gap-2 max-h-52 sm:max-h-64 md:max-h-80 overflow-y-auto scrollbar-hide">
{databases.map((db) => {
const isSelected = databaseIds.includes(db.id);
const isCurrentLoading = loadingDbId === db.id;
return (
<button
key={db.id}
type="button"
disabled={mutation.isPending}
onClick={() => toggleDb(db.id)}
className={`flex items-center gap-3 rounded-lg border p-3 text-sm transition-all text-left ${
isSelected
? "border-primary/20 bg-primary/10 text-primary"
: "border-border hover:bg-accent/50 hover:border-primary/20"
}`}
>
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
<Database className="size-4 text-muted-foreground" />
</div>
<div className="flex flex-col gap-0.5 flex-1">
<span className="font-medium">{db.name}</span>
<span className="text-xs text-muted-foreground">
{db.engine}
</span>
</div>
{isCurrentLoading ? (
<div className="size-5 rounded-full flex items-center justify-center ml-auto">
<Loader2 className="size-4 animate-spin text-muted-foreground" />
</div>
) : isSelected ? (
<div className="size-5 rounded-full bg-primary flex items-center justify-center ml-auto">
<Check
className="size-3 text-primary-foreground"
strokeWidth={3}
/>
</div>
) : (
<div className="size-5 rounded-full border-2 border-muted-foreground/30 ml-auto" />
)}
</button>
);
})}
</div>
</div>
)}
<Button
type="button"
onClick={() =>
mutation.mutate(
{ name: name || "My project", databaseIds },
{ onSuccess: () => next() },
)
}
disabled={!name.trim() && !existingProject}
>
{mutation.isPending ? "Saving…" : "Continue"}
</Button>
</div>
);
};
@@ -0,0 +1,99 @@
"use client";
import { useState } from "react";
import { useOnboarding } from "@onboardjs/react";
import { KeyRound, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { authClient } from "@/lib/auth/auth-client";
import { TwoFactorSetupContent } from "@/features/profile/components/two-factor-setup-content";
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 [phase, setPhase] = useState<"choose" | "two-factor">("choose");
const { mutate: addPasskey, isPending: isAddingPasskey } = useMutation({
mutationFn: async () => {
const result = await authClient.passkey.addPasskey({ name: "My Passkey" });
if (result?.error) throw result.error;
return result;
},
onSuccess: async () => {
toast.success("Passkey added successfully.");
await updateContext({ flowData: { ...state?.context.flowData, security: { method: "passkey" } } });
await next();
},
onError: (e: any) => toast.error(e.message || "Failed to add passkey"),
});
const handleTwoFactorSuccess = async () => {
await updateContext({ flowData: { ...state?.context.flowData, security: { method: "two-factor" } } });
await next();
};
if (phase === "two-factor") {
return (
<div className="flex flex-col gap-4">
<div>
<h1 className="text-2xl font-semibold">Set up two-factor</h1>
<p className="text-sm text-muted-foreground mt-1">
Add an extra layer of security to your account.
</p>
</div>
<TwoFactorSetupContent onSuccess={handleTwoFactorSuccess} />
</div>
);
}
return (
<div className="flex flex-col gap-4">
<div>
<h1 className="text-2xl font-semibold">Secure your account</h1>
<p className="text-sm text-muted-foreground mt-1">
Choose a method to protect your account.
</p>
</div>
{passkeyEnabled && (
<button
type="button"
disabled={isAddingPasskey}
onClick={() => addPasskey()}
className="flex items-center gap-3 rounded-lg border border-border p-4 text-sm hover:bg-accent/50 hover:border-primary/20 transition-colors w-full text-left disabled:opacity-50"
>
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
{isAddingPasskey ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4 text-muted-foreground" />}
</div>
<div className="flex flex-col gap-0.5">
<span className="font-medium">Set up passkey</span>
<span className="text-xs text-muted-foreground">Faster, safer sign-in with biometrics</span>
</div>
</button>
)}
<button
type="button"
onClick={() => setPhase("two-factor")}
className="flex items-center gap-3 rounded-lg border border-border p-4 text-sm hover:bg-accent/50 hover:border-primary/20 transition-colors w-full text-left"
>
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
<ShieldCheck className="size-4 text-muted-foreground" />
</div>
<div className="flex flex-col gap-0.5">
<span className="font-medium">Set up two-factor authentication</span>
<span className="text-xs text-muted-foreground">Secure your account with a TOTP app</span>
</div>
</button>
<Button type="button" variant="ghost" onClick={() => next()}>
Skip for now
</Button>
</div>
);
};
@@ -0,0 +1,217 @@
"use client";
import { useState } from "react";
import { useOnboarding } from "@onboardjs/react";
import { ArrowLeft, Check, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
useZodForm,
Form,
FormField,
FormItem,
FormLabel,
FormControl,
FormMessage,
} from "@/components/ui/form";
import { storageProviders } from "@/features/channel/components/channels-storage-helper";
import { renderChannelForm } from "@/features/channel/components/channels-helpers";
import { StorageChannelFormSchema } from "@/features/channel/schemas/channel-form.schema";
import { useAddStorage } from "@/features/onboarding/hooks/use-add-storage";
import { useRemoveStorage } from "@/features/onboarding/hooks/use-remove-storage";
import type { OnboardingChannel } from "@/features/onboarding/types";
type Phase = { kind: "grid" } | { kind: "configuring"; provider: string };
export const StepStorage = () => {
const { next, updateContext, state } = useOnboarding();
const storages = (state?.context.flowData.storages ??
[]) as OnboardingChannel[];
const [phase, setPhase] = useState<Phase>({ kind: "grid" });
const form = useZodForm({ schema: StorageChannelFormSchema });
const addStorage = useAddStorage();
const removeStorage = useRemoveStorage();
const startConfiguring = (provider: string) => {
if (storages.some((c) => c.provider === provider)) return;
form.reset({ provider, enabled: true, name: "", config: {} } as any);
setPhase({ kind: "configuring", provider });
};
const onContinue = async () => {
await updateContext({ flowData: { ...state?.context.flowData, storages } });
await next();
};
if (phase.kind === "configuring") {
const providerDetails = storageProviders.find(
(p) => p.value === phase.provider,
);
const Icon = providerDetails?.icon;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center gap-3 p-3 bg-secondary/30 rounded-lg border border-border">
{Icon && (
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center">
<Icon className="size-5" />
</div>
)}
<p className="flex-1 text-sm font-medium">
Configuring {providerDetails?.label}
</p>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setPhase({ kind: "grid" })}
>
<ArrowLeft className="size-4 mr-1" />
Back
</Button>
</div>
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values: any) => {
const details = storageProviders.find(
(p) => p.value === values.provider,
);
addStorage.mutate(
{
provider: values.provider,
name: values.name,
config: values.config,
label: details?.label ?? values.provider,
},
{
onSuccess: () => {
form.reset({ enabled: true } as any);
setPhase({ kind: "grid" });
},
},
);
}}
>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Channel name *</FormLabel>
<FormControl>
<Input
{...field}
value={field.value ?? ""}
placeholder={`e.g. ${providerDetails?.label ?? ""} backup`}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="provider"
render={({ field }) => (
<input type="hidden" {...field} value={field.value || ""} />
)}
/>
{renderChannelForm(phase.provider, form)}
<Button type="submit" disabled={addStorage.isPending}>
{addStorage.isPending ? "Saving…" : "Add storage"}
</Button>
</Form>
</div>
);
}
const availableProviders = storageProviders.filter(
(p) => !p.preview && p.value !== "local",
);
const configuredProviderIds = storages.map((c) => c.provider);
return (
<div className="flex flex-col gap-4">
<div>
<h1 className="text-2xl font-semibold">Connect an external storage</h1>
<p className="text-sm text-muted-foreground mt-1">
Optional link an S3-compatible bucket or other external storage to keep your backups safe and off-instance.
</p>
</div>
{storages.length > 0 && (
<div className="flex flex-col gap-1">
{storages.map((ch) => {
const details = storageProviders.find(
(p) => p.value === ch.provider,
);
const Icon = details?.icon;
return (
<div
key={ch.id}
className="flex items-center gap-2 rounded-lg border border-primary/20 bg-primary/10 p-2 text-sm text-primary"
>
{Icon && (
<div className="size-7 rounded-md border bg-muted/50 flex items-center justify-center shrink-0">
<Icon className="size-4" />
</div>
)}
<span className="flex-1 truncate">
{ch.name} <span className="opacity-60">({ch.label})</span>
</span>
{ch.organizationId !== null && (
<button
type="button"
onClick={() => removeStorage.mutate(ch.id)}
disabled={removeStorage.isPending}
className="opacity-50 hover:opacity-100 transition-opacity"
>
<X className="size-4" />
</button>
)}
</div>
);
})}
</div>
)}
<div className="grid grid-cols-2 gap-2">
{availableProviders.map((provider) => {
const Icon = provider.icon;
const isConfigured = configuredProviderIds.includes(provider.value);
return (
<button
key={provider.value}
type="button"
onClick={() => startConfiguring(provider.value)}
className={`flex items-center gap-2 rounded-lg border p-2 text-sm transition-colors ${
isConfigured
? "border-primary/20 bg-primary/10 text-primary"
: "border-border hover:bg-accent/50 hover:border-primary/20"
}`}
>
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
<Icon className="size-4" />
</div>
<span className="flex-1 text-left">{provider.label}</span>
{isConfigured && (
<div className="size-5 rounded-full bg-primary flex items-center justify-center ml-auto">
<Check
className="size-3 text-primary-foreground"
strokeWidth={3}
/>
</div>
)}
</button>
);
})}
</div>
<Button type="button" onClick={onContinue}>
Continue
</Button>
</div>
);
};
+127
View File
@@ -0,0 +1,127 @@
export type OnboardingSsoProvider = { id: string; label: string; icon: string; description?: string };
export type OnboardingOAuthProvider = { id: string; label: string; icon: string };
export type OnboardingMeta = {
passkeyEnabled: boolean;
hasExistingUsers: boolean;
ssoProviders: OnboardingSsoProvider[];
oauthProviders: OnboardingOAuthProvider[];
defaultUserMode: boolean;
resumeStepId: string;
emailPasswordEnabled: boolean;
};
export type OnboardingAccountData = {
firstName: string;
lastName: string;
email: string;
};
export type OnboardingSecurityData = {
method: "passkey" | "two-factor" | "skipped";
};
export type OnboardingOrgData = {
id: string;
name: string;
};
export type OnboardingMember = {
email: string;
role: "member" | "admin";
};
export type OnboardingChannel = {
id: string;
provider: string;
label: string;
name: string;
config: Record<string, unknown>;
organizationId?: string | null;
};
export type OnboardingDefaultsData = {
notifierId?: string;
storageId?: string;
};
export type OnboardingAgent = {
id: string;
name: string;
edgeKey?: string;
connected?: boolean;
notifierId?: string;
storageId?: string;
};
export type OnboardingDatabase = {
id: string;
name: string;
engine: "postgres" | "mysql" | "mongodb";
};
export type SectionKind =
| "retention"
| "scheduling"
| "notifications"
| "storage";
export type EventKind =
| "error_backup"
| "error_restore"
| "success_restore"
| "success_backup"
| "weekly_report"
| "error_health_agent"
| "error_health_database";
export type OnboardingNotificationPolicy = {
channelId: string;
eventKinds: EventKind[];
enabled: boolean;
};
export type OnboardingStoragePolicy = {
channelId: string;
enabled: boolean;
};
export type OnboardingDbSettings = {
retention?: {
type?: "count" | "days" | "gfs";
count: number;
days: number;
gfs: {
daily: number;
weekly: number;
monthly: number;
yearly: number;
};
};
backupMethod?: "manual" | "automatic";
backupCron?: string;
notificationPolicies?: OnboardingNotificationPolicy[];
storagePolicies?: OnboardingStoragePolicy[];
};
export type OnboardingProjectData = {
id: string;
name: string;
description: string;
databaseIds: string[];
};
export type OnboardingFlowData = {
meta?: OnboardingMeta;
account?: OnboardingAccountData;
security?: OnboardingSecurityData;
org?: OnboardingOrgData;
members?: OnboardingMember[];
notifiers?: OnboardingChannel[];
storages?: OnboardingChannel[];
defaults?: OnboardingDefaultsData;
agents?: OnboardingAgent[];
databases?: OnboardingDatabase[];
project?: OnboardingProjectData;
dbSettings?: Record<string, OnboardingDbSettings>;
};