add: onboarding

This commit is contained in:
Théo LAGACHE
2026-06-22 10:52:42 +02:00
parent 68cd89fbe1
commit 1b3f972bc8
47 changed files with 2164 additions and 1546 deletions
+22 -20
View File
@@ -5,31 +5,33 @@ import { headers } from "next/headers";
const authHandler = toNextJsHandler(auth.handler);
async function blockApiKeyCreateForRestrictedUsers(req: NextRequest): Promise<NextResponse | null> {
const url = req.nextUrl;
if (req.method !== "POST" || !url.pathname.endsWith("/api-key/create")) {
return null;
}
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return null;
}
// @ts-ignore
if (session.user.banned || (session.user.role as string) === "pending") {
return NextResponse.json(
{ error: "Account not eligible to create API keys" },
{ status: 403 }
);
}
async function blockApiKeyCreateForRestrictedUsers(
req: NextRequest,
): Promise<NextResponse | null> {
const url = req.nextUrl;
if (req.method !== "POST" || !url.pathname.endsWith("/api-key/create")) {
return null;
}
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user) {
return null;
}
// @ts-ignore
if (session.user.banned || (session.user.role as string) === "pending") {
return NextResponse.json(
{ error: "Account not eligible to create API keys" },
{ status: 403 },
);
}
return null;
}
export async function GET(req: NextRequest) {
return authHandler.GET(req);
return authHandler.GET(req);
}
export async function POST(req: NextRequest) {
const guard = await blockApiKeyCreateForRestrictedUsers(req);
if (guard) return guard;
return authHandler.POST(req);
const guard = await blockApiKeyCreateForRestrictedUsers(req);
if (guard) return guard;
return authHandler.POST(req);
}
+34 -34
View File
@@ -4,42 +4,42 @@ import { NextRequest } from "next/server";
export const runtime = "edge";
const AVATAR_COLORS = [
"#4f46e5", // indigo
"#7c3aed", // violet
"#e11d48", // rose
"#ea580c", // orange
"#d97706", // amber
"#059669", // emerald
"#0891b2", // cyan
"#52525b", // zinc
"#4f46e5",
"#7c3aed",
"#e11d48",
"#ea580c",
"#d97706",
"#059669",
"#0891b2",
"#52525b",
];
export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url);
const initials = (searchParams.get("initials") ?? "?").slice(0, 2).toUpperCase();
const color = searchParams.get("color") ?? AVATAR_COLORS[0];
const { searchParams } = new URL(request.url);
const initials = (searchParams.get("initials") ?? "?")
.slice(0, 2)
.toUpperCase();
const color = searchParams.get("color") ?? AVATAR_COLORS[0];
return new ImageResponse(
(
<div
style={{
width: 80,
height: 80,
borderRadius: "50%",
backgroundColor: color,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: 28,
fontWeight: 700,
fontFamily: "sans-serif",
letterSpacing: "-0.5px",
}}
>
{initials}
</div>
),
{ width: 80, height: 80 }
);
return new ImageResponse(
<div
style={{
width: 80,
height: 80,
borderRadius: "50%",
backgroundColor: color,
display: "flex",
alignItems: "center",
justifyContent: "center",
color: "white",
fontSize: 28,
fontWeight: 700,
fontFamily: "sans-serif",
letterSpacing: "-0.5px",
}}
>
{initials}
</div>,
{ width: 80, height: 80 },
);
}
+3 -2
View File
@@ -4,7 +4,7 @@ import { errorHandler } from "@/middleware/errorHandler";
import { auth } from "@/lib/auth/auth";
import { headers } from "next/headers";
import { env } from "@/env.mjs";
import {User} from "@/db/schema/02_user";
import { User } from "@/db/schema/02_user";
export async function proxy(request: NextRequest) {
const url = request.nextUrl.clone();
@@ -19,7 +19,7 @@ export async function proxy(request: NextRequest) {
new URL(`/login?redirect=${redirectUrl}`, request.url),
);
}
const user = session.user as User
const user = session.user as User;
if (user.banned) {
await auth.api.signOut({ headers: await headers() });
@@ -104,6 +104,7 @@ function checkRouteExists(pathname: string) {
/^\/api\/config\/?$/,
/^\/api\/health\/?$/,
/^\/api\/google\/drive\/callback\/?$/,
/^\/api\/avatar\/?$/,
// v1 external API
/^\/api\/v1\/mcp\/?$/,
/^\/api\/v1\/docs\/?$/,
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { cn } from "@/lib/utils";
export type ThemeKey = "dark" | "light" | "system";
const themes: { value: ThemeKey }[] = [
{ value: "light" },
{ value: "dark" },
{ value: "system" },
];
const THEME_TEXT: Record<ThemeKey, string> = {
dark: "Dark",
light: "Light",
system: "System",
};
interface ThemeSelectorProps {
value?: string;
onSelect: (value: ThemeKey) => void;
className?: string;
}
export function ThemeSelector({
value,
onSelect,
className,
}: ThemeSelectorProps) {
return (
<div
className={cn("grid grid-cols-1 sm:grid-cols-3 gap-4 w-full", className)}
>
{themes.map((item) => {
const isDark = item.value === "dark";
const isSystem = item.value === "system";
const isActive = value === item.value;
return (
<div
key={item.value}
className={cn(
"border-2 rounded-xl p-1 cursor-pointer transition-all hover:bg-accent/50 space-y-2",
isActive ? "border-primary bg-primary/5" : "border-muted/40",
)}
onClick={() => onSelect(item.value)}
>
<div
className={cn(
"p-2 rounded-lg aspect-4/3 flex flex-col gap-2 relative overflow-hidden border",
isDark
? "bg-slate-950 border-slate-800"
: "bg-white border-slate-200",
isSystem && "bg-linear-to-br from-white to-slate-950",
)}
>
<div
className={cn(
"h-3 w-full rounded-sm shadow-sm opacity-80",
isDark ? "bg-slate-800" : "bg-slate-100",
)}
/>
<div className="flex gap-2 flex-1 relative">
<div
className={cn(
"w-1/4 h-full rounded-sm shadow-sm opacity-80",
isDark ? "bg-slate-800" : "bg-slate-100",
)}
/>
<div className="flex-1 flex flex-col gap-2">
<div
className={cn(
"h-3 w-full rounded-sm shadow-sm opacity-80",
isDark ? "bg-slate-800" : "bg-slate-100",
)}
/>
<div
className={cn(
"flex-1 rounded-sm shadow-sm p-1 space-y-2 opacity-50",
isDark ? "bg-slate-800" : "bg-slate-100",
)}
>
<div
className={cn(
"h-2 w-3/4 rounded-full",
isDark ? "bg-slate-700" : "bg-slate-300",
)}
/>
<div
className={cn(
"h-2 w-full rounded-full",
isDark ? "bg-slate-700" : "bg-slate-300",
)}
/>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between p-1 px-2">
<span className="font-medium text-sm">
{THEME_TEXT[item.value]}
</span>
<div
className={cn(
"w-4 h-4 rounded-full border flex items-center justify-center transition-all",
isActive
? "border-primary bg-primary"
: "border-muted-foreground/30",
)}
>
{isActive && (
<div className="w-1.5 h-1.5 rounded-full bg-primary-foreground" />
)}
</div>
</div>
</div>
);
})}
</div>
);
}
+15
View File
@@ -0,0 +1,15 @@
import { db } from "@/db";
import { eq } from "drizzle-orm";
import { member } from "@/db/schema/04_member";
import { organization } from "@/db/schema/03_organization";
export async function getUserOrganization(userId: string) {
const memberRow = await db.query.member.findFirst({
columns: { organizationId: true },
where: eq(member.userId, userId),
});
if (!memberRow) return null;
return db.query.organization.findFirst({
where: eq(organization.id, memberRow.organizationId),
});
}
+5
View File
@@ -0,0 +1,5 @@
import { db } from "@/db";
export async function getSettings() {
return db.query.setting.findFirst();
}
+5
View File
@@ -6,6 +6,11 @@ import {User, UserThemeEnum} from "@/db/schema/02_user";
import {assertValidPassword} from "@/utils/password";
export async function hasUsers(): Promise<boolean> {
const result = await db.select().from(drizzleDb.schemas.user).limit(1);
return result.length > 0;
}
export async function createUserDb(data: SignUpUser): Promise<User> {
assertValidPassword(data.password);
@@ -43,13 +43,19 @@ export const applyOnboardingDbSettingsAction = userAction
.schema(
z.object({
databaseId: z.string().min(1),
section: z.enum(["retention", "scheduling", "notifications", "storage", "all"]),
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(),
})
}),
)
.action(async ({ parsedInput }) => {
const {
@@ -94,8 +100,6 @@ export const applyOnboardingDbSettingsAction = userAction
const applyScheduling = async () => {
if (backupMethod === undefined) return;
// Direct DB update — intentionally skips the side effect in
// updateDatabaseBackupPolicyAction that deletes retention policy on null.
const cronValue =
backupMethod === "manual" ? null : (backupCron ?? "0 0 * * *");
await db
@@ -118,7 +122,7 @@ export const applyOnboardingDbSettingsAction = userAction
notificationChannelId: p.channelId,
eventKinds: p.eventKinds as any,
enabled: p.enabled,
}))
})),
);
}
});
@@ -137,7 +141,7 @@ export const applyOnboardingDbSettingsAction = userAction
databaseId,
storageChannelId: p.channelId,
enabled: p.enabled,
}))
})),
);
}
});
@@ -145,7 +149,8 @@ export const applyOnboardingDbSettingsAction = userAction
if (section === "retention" || section === "all") await applyRetention();
if (section === "scheduling" || section === "all") await applyScheduling();
if (section === "notifications" || section === "all") await applyNotifications();
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);
});
@@ -5,12 +5,13 @@ import { z } from "zod";
import { getAgentAction } from "@/features/agents/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 };
});
.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 };
});
@@ -7,15 +7,15 @@ 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 };
});
.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 };
});
@@ -7,16 +7,18 @@ 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 };
});
.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 };
});
@@ -20,9 +20,9 @@ export type BackupScheduleValue = {
const PRESETS = [
{ label: "Every hour", cron: "0 * * * *" },
{ label: "Every day", cron: "0 0 * * *" },
{ label: "Every day", cron: "0 0 * * *" },
{ label: "Every week", cron: "0 0 * * 0" },
{ label: "Custom", cron: "custom" },
{ label: "Custom", cron: "custom" },
] as const;
type PresetCron = (typeof PRESETS)[number]["cron"];
@@ -42,7 +42,7 @@ export const BackupScheduleSelector = ({
onChange,
}: BackupScheduleSelectorProps) => {
const [customCron, setCustomCron] = useState<string>(
value.cron ?? "0 0 * * *"
value.cron ?? "0 0 * * *",
);
const selectedPreset = detectPreset(value.cron);
@@ -65,7 +65,7 @@ export const BackupScheduleSelector = ({
const handleCronPartChange = (
type: "minute" | "hour" | "day-of-month" | "month" | "day-of-week",
part: string
part: string,
) => {
const indexMap: Record<typeof type, number> = {
minute: 0,
@@ -149,11 +149,55 @@ export const BackupScheduleSelector = ({
<div className="flex flex-col gap-2 pl-1">
{(
[
{ type: "minute", label: "Minute", options: Array.from({ length: 60 }, (_, i) => String(i).padStart(2, "0")), partIdx: 0 },
{ type: "hour", label: "Hour", options: Array.from({ length: 24 }, (_, i) => String(i).padStart(2, "0")), partIdx: 1 },
{ type: "day-of-month", label: "Day of Month", options: Array.from({ length: 31 }, (_, i) => String(i + 1).padStart(2, "0")), partIdx: 2 },
{ type: "month", label: "Month", options: ["01","02","03","04","05","06","07","08","09","10","11","12"], partIdx: 3 },
{ type: "day-of-week", label: "Day of Week", options: ["0","1","2","3","4","5","6"], partIdx: 4 },
{
type: "minute",
label: "Minute",
options: Array.from({ length: 60 }, (_, i) =>
String(i).padStart(2, "0"),
),
partIdx: 0,
},
{
type: "hour",
label: "Hour",
options: Array.from({ length: 24 }, (_, i) =>
String(i).padStart(2, "0"),
),
partIdx: 1,
},
{
type: "day-of-month",
label: "Day of Month",
options: Array.from({ length: 31 }, (_, i) =>
String(i + 1).padStart(2, "0"),
),
partIdx: 2,
},
{
type: "month",
label: "Month",
options: [
"01",
"02",
"03",
"04",
"05",
"06",
"07",
"08",
"09",
"10",
"11",
"12",
],
partIdx: 3,
},
{
type: "day-of-week",
label: "Day of Week",
options: ["0", "1", "2", "3", "4", "5", "6"],
partIdx: 4,
},
] as const
).map(({ type, label, options, partIdx }) => (
<AdvancedCronSelect
@@ -166,8 +210,13 @@ export const BackupScheduleSelector = ({
defaultValue={cronParts[partIdx] ?? "*"}
onValueChange={(val) =>
handleCronPartChange(
type as "minute" | "hour" | "day-of-month" | "month" | "day-of-week",
val
type as
| "minute"
| "hour"
| "day-of-month"
| "month"
| "day-of-week",
val,
)
}
/>
@@ -0,0 +1,119 @@
"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; icon: React.ReactNode }[] =
[
{
kind: "retention",
label: "Retention Policy",
icon: <Shield className="size-4 text-muted-foreground" />,
},
{
kind: "scheduling",
label: "Scheduling",
icon: <Clock className="size-4 text-muted-foreground" />,
},
{
kind: "notifications",
label: "Notifications",
icon: <Bell className="size-4 text-muted-foreground" />,
},
{
kind: "storage",
label: "Storage",
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="flex flex-col gap-2">
{SECTIONS.map(({ kind, label, 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>
<span className="flex-1 font-medium">{label}</span>
{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,59 @@
"use client";
import { Check, 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">
{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>
)}
</button>
);
})}
</div>
<Button type="button" onClick={onContinue}>Continue</Button>
</div>
);
@@ -0,0 +1,120 @@
"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}
onBack={onBack}
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}
onBack={onBack}
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}
onBack={onBack}
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}
onBack={onBack}
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,223 @@
"use client";
import { useState } from "react";
import { ArrowLeft, Bell, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Card } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { MultiSelect } from "@/components/common/multi-select";
import { EVENT_KIND_OPTIONS } from "@/features/database/channels-policy.schema";
import { getChannelIcon } from "@/features/channel/channels-helpers";
import type {
EventKind,
OnboardingChannel,
OnboardingNotificationPolicy,
} from "@/features/onboarding/types";
type NotificationsSectionProps = {
initial: OnboardingNotificationPolicy[];
notifiers: OnboardingChannel[];
onSave: (policies: OnboardingNotificationPolicy[]) => Promise<void>;
onBack: () => void;
isPending: boolean;
};
export const NotificationsSection = ({
initial,
notifiers,
onSave,
onBack,
isPending,
}: NotificationsSectionProps) => {
const [policies, setPolicies] =
useState<OnboardingNotificationPolicy[]>(initial);
const addPolicy = () =>
setPolicies((prev) => [
...prev,
{ channelId: "", eventKinds: [], enabled: true },
]);
const removePolicy = (index: number) =>
setPolicies((prev) => prev.filter((_, i) => i !== index));
const updatePolicy = (
index: number,
patch: Partial<OnboardingNotificationPolicy>,
) =>
setPolicies((prev) =>
prev.map((p, i) => (i === index ? { ...p, ...patch } : p)),
);
const selectedChannelIds = policies.map((p) => p.channelId).filter(Boolean);
if (notifiers.length === 0) {
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col items-center justify-center p-8 border border-dashed rounded-xl bg-muted/20 text-center gap-2">
<Bell className="h-8 w-8 text-muted-foreground/50" />
<p className="font-medium text-sm">No notifiers configured</p>
<p className="text-xs text-muted-foreground">
Go back and configure notifiers in the &quot;Connect a
notifier&quot; step first.
</p>
</div>
<Button type="button" variant="outline" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" />
Back
</Button>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Notification Policies</Label>
<Button
type="button"
size="sm"
variant="outline"
disabled={policies.length >= notifiers.length}
onClick={addPolicy}
>
<Plus className="size-4 mr-1" />
Add Policy
</Button>
</div>
{policies.length === 0 ? (
<div className="flex flex-col items-center justify-center p-6 border border-dashed rounded-xl bg-muted/20 text-center gap-1">
<p className="text-sm text-muted-foreground">
Click &quot;Add Policy&quot; to start receiving notifications.
</p>
</div>
) : (
<div className="flex flex-col gap-3">
{policies.map((policy, index) => {
const available = notifiers.filter(
(n) =>
n.id === policy.channelId || !selectedChannelIds.includes(n.id),
);
const selected = notifiers.find((n) => n.id === policy.channelId);
return (
<Card
key={policy.channelId || index}
className="p-4 flex flex-col gap-3"
>
<div className="flex items-end gap-2">
<div className="flex-1 flex flex-col gap-1.5">
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
Channel
</Label>
<Select
value={policy.channelId}
onValueChange={(v) =>
updatePolicy(index, { channelId: v })
}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select channel">
{selected && (
<div className="flex items-center gap-2">
{getChannelIcon(selected.provider)}
<span className="truncate font-medium text-sm">
{selected.name}
</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{available.map((n) => (
<SelectItem key={n.id} value={n.id}>
<div className="flex items-center gap-2">
{getChannelIcon(n.provider)}
<span>{n.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5 shrink-0">
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
Status
</Label>
<div className="flex items-center h-9 px-3 rounded-md border border-input bg-background gap-2">
<Label className="text-xs cursor-pointer">
{policy.enabled ? "Active" : "Off"}
</Label>
<Switch
checked={policy.enabled}
onCheckedChange={(v) =>
updatePolicy(index, { enabled: v })
}
className="scale-75 origin-right"
/>
</div>
</div>
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9 text-muted-foreground hover:text-destructive hover:border-destructive/50 shrink-0"
onClick={() => removePolicy(index)}
>
<Trash2 className="size-4" />
</Button>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Trigger Events
</Label>
<MultiSelect
options={EVENT_KIND_OPTIONS}
onValueChange={(v) =>
updatePolicy(index, { eventKinds: v as EventKind[] })
}
defaultValue={policy.eventKinds}
placeholder="Select events…"
variant="inverted"
animation={0}
className="bg-background/50 w-full"
/>
</div>
</Card>
);
})}
</div>
)}
<div className="flex gap-2 pt-2">
<Button type="button" variant="outline" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" />
Back
</Button>
<Button
type="button"
disabled={
isPending ||
policies.some((p) => !p.channelId || p.eventKinds.length === 0)
}
onClick={() => onSave(policies)}
className="ml-auto"
>
{isPending ? "Saving…" : "Save"}
</Button>
</div>
</div>
);
};
@@ -0,0 +1,232 @@
"use client";
import { useState } from "react";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Separator } from "@/components/ui/separator";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
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>;
onBack: () => void;
isPending: boolean;
};
export const RetentionSection = ({
initial,
onSave,
onBack,
isPending,
}: RetentionSectionProps) => {
const [settings, setSettings] = useState<
NonNullable<OnboardingDbSettings["retention"]>
>(initial ?? DEFAULT_RETENTION);
const totalFiles = () => {
if (settings.type === "gfs") {
return (
settings.gfs.daily +
settings.gfs.weekly +
settings.gfs.monthly +
settings.gfs.yearly
);
}
return settings.type === "count" ? settings.count : settings.days;
};
const storageEstimate = () => {
const t = totalFiles();
if (t <= 10) return "Low";
if (t <= 30) return "Medium";
return "High";
};
return (
<div className="flex flex-col gap-6">
<div className="space-y-4">
<Label className="text-sm font-medium">Retention Policy Type</Label>
<RadioGroup
value={settings.type ?? ""}
onValueChange={(v) =>
setSettings((prev) => ({
...prev,
type: v as "count" | "days" | "gfs",
}))
}
className="grid grid-cols-1 gap-4"
>
{[
{
id: "count",
label: "Keep last N backups",
desc: "Simple count-based retention (e.g., keep last 10 backups)",
},
{
id: "days",
label: "Keep backups for X days",
desc: "Time-based retention (e.g., keep backups for 30 days)",
},
{
id: "gfs",
label: "GFS Rotation",
desc: "Grandfather-Father-Son rotation for enterprise/critical systems",
badge: "Recommended",
},
].map((opt) => (
<Label
key={opt.id}
htmlFor={opt.id}
className={`flex items-center space-x-3 rounded-lg border p-4 transition-colors cursor-pointer ${
settings.type === opt.id
? "border-primary bg-primary/5"
: "hover:bg-muted/50"
}`}
>
<RadioGroupItem value={opt.id} id={opt.id} />
<div className="flex-1">
<span className="font-medium flex items-center gap-2">
{opt.label}
{opt.badge && (
<Badge variant="secondary" className="text-xs">
{opt.badge}
</Badge>
)}
</span>
<p className="text-sm text-muted-foreground">{opt.desc}</p>
</div>
</Label>
))}
</RadioGroup>
</div>
{settings.type && <Separator />}
{settings.type === "count" && (
<div className="space-y-2">
<Label htmlFor="backup-count">Number of backups to keep</Label>
<Input
id="backup-count"
type="number"
min={1}
max={100}
className="w-32"
value={settings.count}
onChange={(e) =>
setSettings((prev) => ({
...prev,
count: parseInt(e.target.value) || 1,
}))
}
/>
<p className="text-xs text-muted-foreground">
Older backups beyond this count will be automatically deleted.
</p>
</div>
)}
{settings.type === "days" && (
<div className="space-y-2">
<Label htmlFor="retention-days">Retention period (days)</Label>
<Input
id="retention-days"
type="number"
min={1}
max={3650}
className="w-32"
value={settings.days}
onChange={(e) =>
setSettings((prev) => ({
...prev,
days: parseInt(e.target.value) || 1,
}))
}
/>
<p className="text-xs text-muted-foreground">
Backups older than {settings.days} days will be automatically
deleted.
</p>
</div>
)}
{settings.type === "gfs" && (
<div className="grid grid-cols-2 gap-4">
{(
[
{ key: "daily", label: "Daily backups", min: 1, max: 31 },
{ key: "weekly", label: "Weekly backups", min: 0, max: 52 },
{ key: "monthly", label: "Monthly backups", min: 0, max: 120 },
{ key: "yearly", label: "Yearly backups", min: 0, max: 50 },
] as const
).map(({ key, label, min, max }) => (
<div key={key} className="space-y-2">
<Label>{label}</Label>
<Input
type="number"
min={min}
max={max}
value={settings.gfs[key]}
onChange={(e) =>
setSettings((prev) => ({
...prev,
gfs: { ...prev.gfs, [key]: parseInt(e.target.value) || 0 },
}))
}
/>
<p className="text-xs text-muted-foreground">
Keep N {key} backups
</p>
</div>
))}
</div>
)}
{settings.type && (
<>
<Separator />
<div className="rounded-lg border p-4 space-y-3 bg-card">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">Storage Impact</span>
<Badge
variant={
storageEstimate() === "Low"
? "default"
: storageEstimate() === "Medium"
? "secondary"
: "destructive"
}
>
{storageEstimate()} Usage
</Badge>
</div>
<p className="text-sm text-muted-foreground">
~{totalFiles()} backup files per database
</p>
</div>
</>
)}
<div className="flex gap-2 pt-2">
<Button type="button" variant="outline" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" />
Back
</Button>
<Button
type="button"
disabled={!settings.type || isPending}
onClick={() => onSave(settings)}
className="ml-auto"
>
{isPending ? "Saving…" : "Save"}
</Button>
</div>
</div>
);
};
@@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import { ArrowLeft } from "lucide-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>;
onBack: () => void;
isPending: boolean;
};
export const SchedulingSection = ({
initial,
onSave,
onBack,
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" variant="outline" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" />
Back
</Button>
<Button
type="button"
disabled={isPending}
onClick={() => onSave(schedule.method, schedule.cron)}
className="ml-auto"
>
{isPending ? "Saving…" : "Save"}
</Button>
</div>
</div>
);
};
@@ -0,0 +1,192 @@
"use client";
import { useState } from "react";
import { ArrowLeft, HardDrive, Plus, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { Card } from "@/components/ui/card";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { getChannelIcon } from "@/features/channel/channels-helpers";
import type {
OnboardingChannel,
OnboardingStoragePolicy,
} from "@/features/onboarding/types";
type StorageSectionProps = {
initial: OnboardingStoragePolicy[];
storages: OnboardingChannel[];
onSave: (policies: OnboardingStoragePolicy[]) => Promise<void>;
onBack: () => void;
isPending: boolean;
};
export const StorageSection = ({
initial,
storages,
onSave,
onBack,
isPending,
}: StorageSectionProps) => {
const [policies, setPolicies] = useState<OnboardingStoragePolicy[]>(initial);
const addPolicy = () =>
setPolicies((prev) => [...prev, { channelId: "", enabled: true }]);
const removePolicy = (index: number) =>
setPolicies((prev) => prev.filter((_, i) => i !== index));
const updatePolicy = (
index: number,
patch: Partial<OnboardingStoragePolicy>,
) =>
setPolicies((prev) =>
prev.map((p, i) => (i === index ? { ...p, ...patch } : p)),
);
const selectedChannelIds = policies.map((p) => p.channelId).filter(Boolean);
if (storages.length === 0) {
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col items-center justify-center p-8 border border-dashed rounded-xl bg-muted/20 text-center gap-2">
<HardDrive className="h-8 w-8 text-muted-foreground/50" />
<p className="font-medium text-sm">No storages configured</p>
<p className="text-xs text-muted-foreground">
Go back and configure storages in the &quot;Connect a storage&quot;
step first.
</p>
</div>
<Button type="button" variant="outline" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" />
Back
</Button>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Storage Policies</Label>
<Button
type="button"
size="sm"
variant="outline"
disabled={policies.length >= storages.length}
onClick={addPolicy}
>
<Plus className="size-4 mr-1" />
Add Policy
</Button>
</div>
{policies.length === 0 ? (
<div className="flex flex-col items-center justify-center p-6 border border-dashed rounded-xl bg-muted/20 text-center gap-1">
<p className="text-sm text-muted-foreground">
Click &quot;Add Policy&quot; to assign a storage to this database.
</p>
</div>
) : (
<div className="flex flex-col gap-3">
{policies.map((policy, index) => {
const available = storages.filter(
(s) =>
s.id === policy.channelId || !selectedChannelIds.includes(s.id),
);
const selected = storages.find((s) => s.id === policy.channelId);
return (
<Card
key={policy.channelId || index}
className="p-4 flex items-end gap-2"
>
<div className="flex-1 flex flex-col gap-1.5">
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
Storage Channel
</Label>
<Select
value={policy.channelId}
onValueChange={(v) => updatePolicy(index, { channelId: v })}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select storage">
{selected && (
<div className="flex items-center gap-2">
{getChannelIcon(selected.provider)}
<span className="truncate font-medium text-sm">
{selected.name}
</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{available.map((s) => (
<SelectItem key={s.id} value={s.id}>
<div className="flex items-center gap-2">
{getChannelIcon(s.provider)}
<span>{s.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5 shrink-0">
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
Status
</Label>
<div className="flex items-center h-9 px-3 rounded-md border border-input bg-background gap-2">
<Label className="text-xs cursor-pointer">
{policy.enabled ? "Active" : "Off"}
</Label>
<Switch
checked={policy.enabled}
onCheckedChange={(v) =>
updatePolicy(index, { enabled: v })
}
className="scale-75 origin-right"
/>
</div>
</div>
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9 text-muted-foreground hover:text-destructive hover:border-destructive/50 shrink-0"
onClick={() => removePolicy(index)}
>
<Trash2 className="size-4" />
</Button>
</Card>
);
})}
</div>
)}
<div className="flex gap-2 pt-2">
<Button type="button" variant="outline" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" />
Back
</Button>
<Button
type="button"
disabled={isPending || policies.some((p) => !p.channelId)}
onClick={() => onSave(policies)}
className="ml-auto"
>
{isPending ? "Saving…" : "Save"}
</Button>
</div>
</div>
);
};
@@ -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 * * *",
};
@@ -1,4 +1,3 @@
// src/features/onboarding/hooks/use-add-notifier.ts
"use client";
import { useMutation } from "@tanstack/react-query";
@@ -19,20 +18,36 @@ export const useAddNotifier = () => {
return useMutation({
mutationFn: async ({ provider, name, config, label }: NotifierInput) => {
const orgId = (state?.context.flowData.org as any)?.id as string | undefined;
const orgId = (state?.context.flowData.org as any)?.id as
| string
| undefined;
const result = await addNotificationChannelAction({
organizationId: orgId,
data: { provider: provider as any, name, config: config as any, enabled: true },
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");
if (!inner?.success || !inner.value)
throw new Error("Failed to save channel");
const channel: OnboardingChannel = { id: inner.value.id, provider, label, name, config };
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 } });
await updateContext({
flowData: { ...state?.context.flowData, notifiers },
});
return channel;
},
onError: (err: Error) => toast.error(err.message),
@@ -1,4 +1,3 @@
// src/features/onboarding/hooks/use-add-storage.ts
"use client";
import { useMutation } from "@tanstack/react-query";
@@ -19,20 +18,36 @@ export const useAddStorage = () => {
return useMutation({
mutationFn: async ({ provider, name, config, label }: StorageInput) => {
const orgId = (state?.context.flowData.org as any)?.id as string | undefined;
const orgId = (state?.context.flowData.org as any)?.id as
| string
| undefined;
const result = await addStorageChannelAction({
organizationId: orgId,
data: { provider: provider as any, name, config: config as any, enabled: true },
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");
if (!inner?.success || !inner.value)
throw new Error("Failed to save storage");
const channel: OnboardingChannel = { id: inner.value.id, provider, label, name, config };
const channel: OnboardingChannel = {
id: inner.value.id,
provider,
label,
name,
config,
};
const storages = [
...((state?.context.flowData.storages ?? []) as OnboardingChannel[]),
channel,
];
await updateContext({ flowData: { ...state?.context.flowData, storages } });
await updateContext({
flowData: { ...state?.context.flowData, storages },
});
return channel;
},
onError: (err: Error) => toast.error(err.message),
@@ -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."),
});
@@ -1,19 +0,0 @@
"use client";
import { useQuery } from "@tanstack/react-query";
import { generateEdgeKey } from "@/utils/edge_key";
import { getServerUrl } from "@/utils/get-server-url";
export const useGenerateEdgeKey = (agentId: string) => {
return useQuery({
queryKey: ["onboarding-edge-key", agentId],
queryFn: async () => {
const serverUrl = getServerUrl();
const key = await generateEdgeKey(serverUrl, agentId);
if (!key) throw new Error("Failed to generate key");
return key;
},
staleTime: Infinity,
enabled: !!agentId,
});
};
@@ -8,9 +8,10 @@ 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";
import type { OnboardingMeta } from "@/features/onboarding/types";
type AccountInput = z.infer<typeof WithPasswordSchema> & { method?: "passkey" | "password" };
type AccountInput = z.infer<typeof WithPasswordSchema> & {
method?: "passkey" | "password";
};
export const useUpdateAccount = (refetchSession: () => Promise<any>) => {
const { state, updateContext, next } = useOnboarding();
@@ -30,7 +31,11 @@ export const useUpdateAccount = (refetchSession: () => Promise<any>) => {
await updateContext({
flowData: {
...state?.context.flowData,
account: { firstName: values.firstName, lastName: values.lastName, email: values.email },
account: {
firstName: values.firstName,
lastName: values.lastName,
email: values.email,
},
},
});
await next();
@@ -39,23 +44,39 @@ export const useUpdateAccount = (refetchSession: () => Promise<any>) => {
if (selectedMethod === "passkey") {
const name = `${values.firstName} ${values.lastName}`;
const context = await generatePasskeyContextAction(name, values.email);
const result = await passkey.addPasskey({ name: values.email, context });
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");
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.");
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 },
account: {
firstName: values.firstName,
lastName: values.lastName,
email: values.email,
},
security: { method: "passkey" },
},
});
@@ -74,7 +95,11 @@ export const useUpdateAccount = (refetchSession: () => Promise<any>) => {
await updateContext({
flowData: {
...state?.context.flowData,
account: { firstName: values.firstName, lastName: values.lastName, email: values.email },
account: {
firstName: values.firstName,
lastName: values.lastName,
email: values.email,
},
},
});
await next();
@@ -5,76 +5,78 @@ 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: "preferences", label: "Preferences" },
{ 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" },
{ id: "login", label: "Sign in" },
{ id: "account-info", label: "Your account" },
{ id: "security", label: "Security" },
{ id: "preferences", label: "Preferences" },
{ 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 { state } = useOnboarding();
if (!state) return null;
const currentId = state.currentStep?.id ?? "";
const currentIndex = CHECKLIST_STEPS.findIndex((s) => s.id === currentId);
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">
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wider mb-4">Progress</p>
<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 className="flex flex-col gap-0 p-6 h-full">
<p className="text-xs font-medium text-zinc-500 uppercase tracking-wider mb-4">
Progress
</p>
<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">
{/* Icon + line */}
<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>
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>
{/* Label */}
<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>
);
})}
<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>
</div>
);
};
+23 -23
View File
@@ -93,9 +93,7 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
id: a.id,
name: a.name,
edgeKey: await generateEdgeKey(getServerUrl(), a.id),
connected: a.lastContact
? Date.now() - new Date(a.lastContact).getTime() < 60_000
: false,
connected: !!a.lastContact,
}))
);
@@ -129,41 +127,43 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
: {}),
};
const hasAgents = agents && agents.length > 0;
// Has project → late stage (project was created after agent-key)
if (project) {
if (!agents || agents.length === 0) {
if (!hasAgents) {
// Project without agents: missed earlier steps
if (notifiers.length === 0) {
meta.resumeStepId = "notifier";
return { stepId: "notifier", flowData: fullData };
}
if (storages.length === 0) {
meta.resumeStepId = "storage";
return { stepId: "storage", flowData: fullData };
}
meta.resumeStepId = "agent-create";
return { stepId: "agent-create", flowData: fullData };
}
const firstAgent = agents[0];
const agentConnected = firstAgent?.lastContact
? Date.now() - new Date(firstAgent.lastContact).getTime() < 60_000
: false;
if (agentConnected && (project as any).databases?.length === 0) {
meta.resumeStepId = "project-create";
return { stepId: "project-create", flowData: fullData };
}
if (!agentConnected) {
meta.resumeStepId = "finish";
return { stepId: "finish", flowData: fullData };
const agentHasPinged = !!agents[0]?.lastContact;
if (!agentHasPinged) {
meta.resumeStepId = "agent-key";
return { stepId: "agent-key", flowData: fullData };
}
meta.resumeStepId = "finish";
return { stepId: "finish", flowData: fullData };
}
if (agents && agents.length > 0) {
const firstAgent = agents[0];
const agentConnected = firstAgent?.lastContact
? Date.now() - new Date(firstAgent.lastContact).getTime() < 60_000
: false;
const stepId = agentConnected ? "project-create" : "agent-key";
// Has agents but no project → past notifier/storage, waiting on project
if (hasAgents) {
const agentHasPinged = !!agents[0]?.lastContact;
const stepId = agentHasPinged ? "project-create" : "agent-key";
meta.resumeStepId = stepId;
return { stepId, flowData: fullData };
}
// No agents, no project → check earlier steps in order
if (notifiers.length === 0) {
meta.resumeStepId = "notifier";
return { stepId: "notifier", flowData: fullData };
@@ -3,10 +3,11 @@
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 ?? "");
@@ -18,10 +19,11 @@ export const OnboardingStepper = () => {
return (
<div className="flex flex-col gap-2 w-full">
<div className="flex justify-between text-xs text-muted-foreground">
<span>
Step {stepNumber} of {totalSteps}
</span>
<span>{progress}%</span>
{mobile && (
<span>
Step {stepNumber} of {totalSteps}
</span>
)}
</div>
<Progress value={progress} />
</div>
@@ -120,12 +120,14 @@ export const onboardingSteps: OnboardingStep[] = [
isSkippable: true,
skipToStep: (ctx: any) => {
const agents = (ctx.flowData?.agents as any[]) || [];
if (agents.length === 0) return "agent-create";
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 "agent-create";
const isAgentConnected = agents.some((a) => a.connected);
const databaseIds = (ctx.flowData?.project?.databaseIds as string[]) || [];
return !isAgentConnected || databaseIds.length === 0 ? "finish" : "db-settings";
@@ -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(),
});
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect } from "react";
import { useOnboarding } from "@onboardjs/react";
import { useSession } from "@/lib/auth/auth-client";
import { z } from "zod";
@@ -22,7 +22,6 @@ import {
} from "@/features/onboarding/schemas/account.schema";
import { useUpdateAccount } from "@/features/onboarding/hooks/use-update-account";
import type { OnboardingMeta } from "@/features/onboarding/types";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
export const StepAccountInfo = () => {
const { next, state } = useOnboarding();
@@ -164,7 +163,11 @@ export const StepAccountInfo = () => {
) : (
<>
{emailPasswordEnabled && (
<Button type="button" onClick={onSubmitPassword} disabled={mutation.isPending}>
<Button
type="button"
onClick={onSubmitPassword}
disabled={mutation.isPending}
>
Create account
</Button>
)}
@@ -15,7 +15,8 @@ export const StepAgentKey = () => {
<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.
Run the command below on your server. The next step waits for the
agent to connect.
</p>
</div>
{agents.map((agent) => (
@@ -17,8 +17,6 @@ export const StepAgentWaiting = () => {
}
}, [data?.connected, next]);
// Don't render until the first fetch completes, or if the agent is already
// connected (next() fires before the spinner is ever displayed).
if (isLoading || data?.connected) return null;
return (
File diff suppressed because it is too large Load Diff
+25 -12
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useEffect, useRef } from "react";
import { useState } from "react";
import { HardDrive } from "lucide-react";
import { useOnboarding } from "@onboardjs/react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
@@ -33,7 +34,6 @@ export const StepDefaults = () => {
existingDefaults.storageId || undefined,
);
const selectNotifier = async (value: string) => {
setNotifierId(value);
await updateNotificationSettingsAction({
@@ -49,6 +49,16 @@ export const StepDefaults = () => {
};
const selectStorage = async (value: string) => {
if (value === "filesystem") {
setStorageId(undefined);
await updateContext({
flowData: {
...state?.context.flowData,
defaults: { notifierId, storageId: undefined },
},
});
return;
}
setStorageId(value);
await updateStorageSettingsAction({
name: "system",
@@ -83,7 +93,9 @@ export const StepDefaults = () => {
<div className="flex flex-col gap-2">
<Label>Default notifier</Label>
<Select
value={notifiers.some((n) => n.id === notifierId) ? notifierId : undefined}
value={
notifiers.some((n) => n.id === notifierId) ? notifierId : undefined
}
onValueChange={selectNotifier}
disabled={notifiers.length === 0}
>
@@ -108,20 +120,21 @@ export const StepDefaults = () => {
<div className="flex flex-col gap-2">
<Label>Default storage</Label>
<Select
value={storages.some((s) => s.id === storageId) ? storageId : undefined}
value={
storages.some((s) => s.id === storageId) ? storageId : "filesystem"
}
onValueChange={selectStorage}
disabled={storages.length === 0}
>
<SelectTrigger>
<SelectValue
placeholder={
storages.length === 0
? "No storage connected"
: "Choose a storage"
}
/>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value={"filesystem"}>
<div className="flex items-center gap-2">
<HardDrive className="size-4 text-muted-foreground" />
<span>Filesystem</span>
</div>
</SelectItem>
{storages.map((s) => (
<SelectItem key={s.id} value={s.id}>
{s.label}
+27 -25
View File
@@ -8,31 +8,33 @@ import { Button } from "@/components/ui/button";
import { useMarkOnboardingDone } from "@/features/onboarding/hooks/use-mark-onboarding-done";
export const StepFinish = () => {
const { next } = useOnboarding();
const fired = useRef(false);
const mutation = useMarkOnboardingDone();
const { next } = useOnboarding();
const fired = useRef(false);
const mutation = useMarkOnboardingDone();
useEffect(() => {
if (fired.current) return;
fired.current = true;
confetti({ particleCount: 150, spread: 80, origin: { y: 0.6 } });
}, []);
useEffect(() => {
if (fired.current) return;
fired.current = true;
confetti({ particleCount: 150, spread: 80, origin: { y: 0.6 } });
}, []);
return (
<div className="flex flex-col items-center justify-center gap-4 h-full text-center">
<CheckCircle2 className="size-16 text-green-500" />
<h1 className="text-2xl font-semibold">You&apos;re all set!</h1>
<p className="text-sm text-muted-foreground">Your workspace is ready to use.</p>
<Button
type="button"
disabled={mutation.isPending}
onClick={async () => {
await mutation.mutateAsync();
await next();
}}
>
Go to dashboard
</Button>
</div>
);
return (
<div className="flex flex-col items-center justify-center gap-4 h-full text-center">
<CheckCircle2 className="size-16 text-green-500" />
<h1 className="text-2xl font-semibold">You&apos;re all set!</h1>
<p className="text-sm text-muted-foreground">
Your workspace is ready to use.
</p>
<Button
type="button"
disabled={mutation.isPending}
onClick={async () => {
await mutation.mutateAsync();
await next();
}}
>
Go to dashboard
</Button>
</div>
);
};
@@ -9,55 +9,57 @@ 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 { 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 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 removeMember = (target: string) => {
setMembers((prev) => prev.filter((m) => m.email !== target));
};
const onContinue = async () => {
await updateContext({ flowData: { ...state?.context.flowData, members } });
await next();
};
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>
);
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>
);
};
+214 -193
View File
@@ -11,215 +11,236 @@ 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,
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.string().email("Invalid email"),
password: z.string().min(1, "Password required"),
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 { 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 hasAnyAuthMethod = emailPasswordEnabled || passkeyEnabled || hasAnySsoProvider;
const passkeyEnabled = meta?.passkeyEnabled ?? false;
const emailPasswordEnabled = meta?.emailPasswordEnabled ?? false;
const hasAnySsoProvider = (meta?.ssoProviders?.length ?? 0) > 0;
const hasAnyAuthMethod =
emailPasswordEnabled || passkeyEnabled || hasAnySsoProvider;
// After login: reload the page so resolveOnboardingState re-runs server-side
// with the authenticated user and returns the correct resume step + flowData.
// Calling next() here would keep the stale unauthenticated flowData.
useEffect(() => {
if (session?.user) {
router.push('/welcome');
}
}, [session?.user?.id]);
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 handleSso = async (providerId: string) => {
try {
await signIn.social({ provider: providerId as any, callbackURL: "/welcome" });
} catch (err) {
toast.error(err instanceof Error ? err.message : "SSO sign in failed");
}
};
// No auth methods configured
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-[280px] 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>
);
useEffect(() => {
if (session?.user) {
router.push("/welcome");
}
}, [session?.user?.id]);
// New user registration flow
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 && (
<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">
<Globe className="size-4 text-muted-foreground" />
</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>
);
}
const form = useZodForm({ schema: LoginSchema });
// Existing user login flow
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 handleSso = async (providerId: string) => {
const result = await signIn.social({
provider: providerId as any,
callbackURL: "/welcome",
});
if (result?.error)
toast.error(result.error.message ?? "SSO sign in failed");
};
if (!hasAnyAuthMethod) {
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 && !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">
<Globe className="size-4 text-muted-foreground" />
</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 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 && (
<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">
<Globe className="size-4 text-muted-foreground" />
</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 && !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">
<Globe className="size-4 text-muted-foreground" />
</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>
);
};
@@ -1,4 +1,3 @@
// src/features/onboarding/steps/step-notifier.tsx
"use client";
import { useState } from "react";
@@ -26,7 +25,8 @@ 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 notifiers = (state?.context.flowData.notifiers ??
[]) as OnboardingChannel[];
const [phase, setPhase] = useState<Phase>({ kind: "grid" });
const form = useZodForm({ schema: NotificationChannelFormSchema });
@@ -40,12 +40,16 @@ export const StepNotifier = () => {
};
const onContinue = async () => {
await updateContext({ flowData: { ...state?.context.flowData, notifiers } });
await updateContext({
flowData: { ...state?.context.flowData, notifiers },
});
await next();
};
if (phase.kind === "configuring") {
const providerDetails = notificationProviders.find((p) => p.value === phase.provider);
const providerDetails = notificationProviders.find(
(p) => p.value === phase.provider,
);
const Icon = providerDetails?.icon;
return (
@@ -56,8 +60,15 @@ export const StepNotifier = () => {
<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" })}>
<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>
@@ -66,7 +77,9 @@ export const StepNotifier = () => {
form={form}
className="flex flex-col gap-4"
onSubmit={async (values: any) => {
const details = notificationProviders.find((p) => p.value === values.provider);
const details = notificationProviders.find(
(p) => p.value === values.provider,
);
addNotifier.mutate(
{
provider: values.provider,
@@ -131,7 +144,9 @@ export const StepNotifier = () => {
{notifiers.length > 0 && (
<div className="flex flex-col gap-1">
{notifiers.map((ch) => {
const details = notificationProviders.find((p) => p.value === ch.provider);
const details = notificationProviders.find(
(p) => p.value === ch.provider,
);
const Icon = details?.icon;
return (
<div
@@ -181,7 +196,10 @@ export const StepNotifier = () => {
<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} />
<Check
className="size-3 text-primary-foreground"
strokeWidth={3}
/>
</div>
)}
</button>
@@ -8,41 +8,45 @@ 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 { state } = useOnboarding();
const existingOrg = state?.context.flowData.org;
const isEditMode = !!existingOrg;
const [name, setName] = useState(existingOrg?.name ?? "");
const mutation = useCreateOrg();
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>
);
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>
);
};
@@ -49,9 +49,7 @@ export const StepPreferences = () => {
};
const selectTheme = async (theme: ThemeKey) => {
// Apply immediately to the UI
setTheme(theme);
// Persist to DB so it survives page reload
await authClient.updateUser({ theme });
await updateContext({
flowData: {
@@ -62,7 +60,6 @@ export const StepPreferences = () => {
};
const onContinue = async () => {
// Save avatar to user profile if selected
if (selectedAvatarUrl) {
await authClient.updateUser({ image: selectedAvatarUrl });
}
@@ -85,7 +82,6 @@ export const StepPreferences = () => {
<div className="flex flex-col gap-2">
<p className="text-sm font-medium">Avatar</p>
{/* Remplacement de la grid par un flex avec wrap */}
<div className="flex flex-wrap gap-2">
{AVATAR_COLORS.map((c) => {
const url = `/api/avatar?initials=${initials}&color=${encodeURIComponent(c.hex)}`;
@@ -96,7 +92,6 @@ export const StepPreferences = () => {
type="button"
onClick={() => selectAvatar(c.hex)}
className={cn(
// Ajout de shrink-0 pour éviter toute déformation
"rounded-full overflow-hidden transition-all shrink-0",
isSelected
? "ring-2 ring-primary ring-offset-2 ring-offset-background"
+27 -9
View File
@@ -1,4 +1,3 @@
// src/features/onboarding/steps/step-storage.tsx
"use client";
import { useState } from "react";
@@ -26,7 +25,8 @@ 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 storages = (state?.context.flowData.storages ??
[]) as OnboardingChannel[];
const [phase, setPhase] = useState<Phase>({ kind: "grid" });
const form = useZodForm({ schema: StorageChannelFormSchema });
@@ -45,7 +45,9 @@ export const StepStorage = () => {
};
if (phase.kind === "configuring") {
const providerDetails = storageProviders.find((p) => p.value === phase.provider);
const providerDetails = storageProviders.find(
(p) => p.value === phase.provider,
);
const Icon = providerDetails?.icon;
return (
@@ -56,8 +58,15 @@ export const StepStorage = () => {
<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" })}>
<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>
@@ -66,7 +75,9 @@ export const StepStorage = () => {
form={form}
className="flex flex-col gap-4"
onSubmit={async (values: any) => {
const details = storageProviders.find((p) => p.value === values.provider);
const details = storageProviders.find(
(p) => p.value === values.provider,
);
addStorage.mutate(
{
provider: values.provider,
@@ -116,7 +127,9 @@ export const StepStorage = () => {
);
}
const availableProviders = storageProviders.filter((p) => !p.preview && p.value !== "local");
const availableProviders = storageProviders.filter(
(p) => !p.preview && p.value !== "local",
);
const configuredProviderIds = storages.map((c) => c.provider);
return (
@@ -131,7 +144,9 @@ export const StepStorage = () => {
{storages.length > 0 && (
<div className="flex flex-col gap-1">
{storages.map((ch) => {
const details = storageProviders.find((p) => p.value === ch.provider);
const details = storageProviders.find(
(p) => p.value === ch.provider,
);
const Icon = details?.icon;
return (
<div
@@ -181,7 +196,10 @@ export const StepStorage = () => {
<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} />
<Check
className="size-3 text-primary-foreground"
strokeWidth={3}
/>
</div>
)}
</button>
+16 -1
View File
@@ -62,9 +62,24 @@ export type OnboardingDatabase = {
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: string[];
eventKinds: EventKind[];
enabled: boolean;
};
+31
View File
@@ -19,6 +19,7 @@ import EmailNewLogin from "@/components/emails/auth/email-new-login";
import {sso} from "@better-auth/sso";
import {SUPPORTED_PROVIDERS} from "@/lib/auth/config";
import {passkey} from "@better-auth/passkey";
import {verifyPasskeyContext} from "@/lib/auth/passkey-context";
import {getOidcProviders} from "./oidc";
import {APIError} from "better-auth/api";
import {getOAuthProviders} from "./oauth";
@@ -290,6 +291,36 @@ export const auth = betterAuth({
rpID: env.PROJECT_URL
? new URL(env.PROJECT_URL).hostname
: "localhost",
registration: {
requireSession: false,
resolveUser: async ({ ctx, context }) => {
const session = (ctx as any).context?.session;
if (session?.user?.id) {
return {
id: session.user.id,
name: session.user.name || session.user.email,
displayName: session.user.email,
};
}
if (!context) throw new APIError("BAD_REQUEST", { message: "Passkey context required" });
const payload = verifyPasskeyContext(context);
if (!payload) throw new APIError("BAD_REQUEST", { message: "Invalid passkey context" });
const [existing] = await db
.select()
.from(drizzleDb.schemas.user)
.where(eq(drizzleDb.schemas.user.email, payload.email))
.limit(1);
if (existing) {
return { id: existing.id, name: existing.name || payload.name, displayName: payload.email };
}
const newUser = await (ctx as any).context.internalAdapter.createUser({
name: payload.name,
email: payload.email,
emailVerified: true,
});
return { id: newUser.id, name: payload.name, displayName: payload.email };
},
},
}),
]
: []),
+20
View File
@@ -0,0 +1,20 @@
import { createHmac } from "crypto";
import { env } from "@/env.mjs";
export function signPasskeyContext(name: string, email: string): string {
const payload = Buffer.from(JSON.stringify({ name, email, exp: Date.now() + 5 * 60 * 1000 })).toString("base64url");
const sig = createHmac("sha256", env.PROJECT_SECRET).update(payload).digest("base64url");
return `${payload}.${sig}`;
}
export function verifyPasskeyContext(token: string): { name: string; email: string } | null {
const dot = token.lastIndexOf(".");
if (dot === -1) return null;
const payload = token.slice(0, dot);
const sig = token.slice(dot + 1);
const expected = createHmac("sha256", env.PROJECT_SECRET).update(payload).digest("base64url");
if (sig !== expected) return null;
const data = JSON.parse(Buffer.from(payload, "base64url").toString());
if (data.exp < Date.now()) return null;
return { name: data.name, email: data.email };
}