mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
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:
co-authored by
Théo LAGACHE
charles-gauthereau
parent
40d3228db6
commit
40247efddd
@@ -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 "Connect a notifier" 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 "Connect an external storage" 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>
|
||||
);
|
||||
};
|
||||
@@ -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'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'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'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>
|
||||
);
|
||||
};
|
||||
@@ -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>;
|
||||
};
|
||||
Reference in New Issue
Block a user