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); const authHandler = toNextJsHandler(auth.handler);
async function blockApiKeyCreateForRestrictedUsers(req: NextRequest): Promise<NextResponse | null> { async function blockApiKeyCreateForRestrictedUsers(
const url = req.nextUrl; req: NextRequest,
if (req.method !== "POST" || !url.pathname.endsWith("/api-key/create")) { ): Promise<NextResponse | null> {
return null; const url = req.nextUrl;
} if (req.method !== "POST" || !url.pathname.endsWith("/api-key/create")) {
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; 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) { export async function GET(req: NextRequest) {
return authHandler.GET(req); return authHandler.GET(req);
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
const guard = await blockApiKeyCreateForRestrictedUsers(req); const guard = await blockApiKeyCreateForRestrictedUsers(req);
if (guard) return guard; if (guard) return guard;
return authHandler.POST(req); return authHandler.POST(req);
} }
+34 -34
View File
@@ -4,42 +4,42 @@ import { NextRequest } from "next/server";
export const runtime = "edge"; export const runtime = "edge";
const AVATAR_COLORS = [ const AVATAR_COLORS = [
"#4f46e5", // indigo "#4f46e5",
"#7c3aed", // violet "#7c3aed",
"#e11d48", // rose "#e11d48",
"#ea580c", // orange "#ea580c",
"#d97706", // amber "#d97706",
"#059669", // emerald "#059669",
"#0891b2", // cyan "#0891b2",
"#52525b", // zinc "#52525b",
]; ];
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const initials = (searchParams.get("initials") ?? "?").slice(0, 2).toUpperCase(); const initials = (searchParams.get("initials") ?? "?")
const color = searchParams.get("color") ?? AVATAR_COLORS[0]; .slice(0, 2)
.toUpperCase();
const color = searchParams.get("color") ?? AVATAR_COLORS[0];
return new ImageResponse( return new ImageResponse(
( <div
<div style={{
style={{ width: 80,
width: 80, height: 80,
height: 80, borderRadius: "50%",
borderRadius: "50%", backgroundColor: color,
backgroundColor: color, display: "flex",
display: "flex", alignItems: "center",
alignItems: "center", justifyContent: "center",
justifyContent: "center", color: "white",
color: "white", fontSize: 28,
fontSize: 28, fontWeight: 700,
fontWeight: 700, fontFamily: "sans-serif",
fontFamily: "sans-serif", letterSpacing: "-0.5px",
letterSpacing: "-0.5px", }}
}} >
> {initials}
{initials} </div>,
</div> { width: 80, height: 80 },
), );
{ width: 80, height: 80 }
);
} }
+3 -2
View File
@@ -4,7 +4,7 @@ import { errorHandler } from "@/middleware/errorHandler";
import { auth } from "@/lib/auth/auth"; import { auth } from "@/lib/auth/auth";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { env } from "@/env.mjs"; 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) { export async function proxy(request: NextRequest) {
const url = request.nextUrl.clone(); const url = request.nextUrl.clone();
@@ -19,7 +19,7 @@ export async function proxy(request: NextRequest) {
new URL(`/login?redirect=${redirectUrl}`, request.url), new URL(`/login?redirect=${redirectUrl}`, request.url),
); );
} }
const user = session.user as User const user = session.user as User;
if (user.banned) { if (user.banned) {
await auth.api.signOut({ headers: await headers() }); await auth.api.signOut({ headers: await headers() });
@@ -104,6 +104,7 @@ function checkRouteExists(pathname: string) {
/^\/api\/config\/?$/, /^\/api\/config\/?$/,
/^\/api\/health\/?$/, /^\/api\/health\/?$/,
/^\/api\/google\/drive\/callback\/?$/, /^\/api\/google\/drive\/callback\/?$/,
/^\/api\/avatar\/?$/,
// v1 external API // v1 external API
/^\/api\/v1\/mcp\/?$/, /^\/api\/v1\/mcp\/?$/,
/^\/api\/v1\/docs\/?$/, /^\/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"; 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> { export async function createUserDb(data: SignUpUser): Promise<User> {
assertValidPassword(data.password); assertValidPassword(data.password);
@@ -43,13 +43,19 @@ export const applyOnboardingDbSettingsAction = userAction
.schema( .schema(
z.object({ z.object({
databaseId: z.string().min(1), databaseId: z.string().min(1),
section: z.enum(["retention", "scheduling", "notifications", "storage", "all"]), section: z.enum([
"retention",
"scheduling",
"notifications",
"storage",
"all",
]),
retention: RetentionSchema.optional(), retention: RetentionSchema.optional(),
backupMethod: z.enum(["manual", "automatic"]).optional(), backupMethod: z.enum(["manual", "automatic"]).optional(),
backupCron: z.string().optional(), backupCron: z.string().optional(),
notificationPolicies: z.array(NotifPolicySchema).optional(), notificationPolicies: z.array(NotifPolicySchema).optional(),
storagePolicies: z.array(StoragePolicyInputSchema).optional(), storagePolicies: z.array(StoragePolicyInputSchema).optional(),
}) }),
) )
.action(async ({ parsedInput }) => { .action(async ({ parsedInput }) => {
const { const {
@@ -94,8 +100,6 @@ export const applyOnboardingDbSettingsAction = userAction
const applyScheduling = async () => { const applyScheduling = async () => {
if (backupMethod === undefined) return; if (backupMethod === undefined) return;
// Direct DB update — intentionally skips the side effect in
// updateDatabaseBackupPolicyAction that deletes retention policy on null.
const cronValue = const cronValue =
backupMethod === "manual" ? null : (backupCron ?? "0 0 * * *"); backupMethod === "manual" ? null : (backupCron ?? "0 0 * * *");
await db await db
@@ -118,7 +122,7 @@ export const applyOnboardingDbSettingsAction = userAction
notificationChannelId: p.channelId, notificationChannelId: p.channelId,
eventKinds: p.eventKinds as any, eventKinds: p.eventKinds as any,
enabled: p.enabled, enabled: p.enabled,
})) })),
); );
} }
}); });
@@ -137,7 +141,7 @@ export const applyOnboardingDbSettingsAction = userAction
databaseId, databaseId,
storageChannelId: p.channelId, storageChannelId: p.channelId,
enabled: p.enabled, enabled: p.enabled,
})) })),
); );
} }
}); });
@@ -145,7 +149,8 @@ export const applyOnboardingDbSettingsAction = userAction
if (section === "retention" || section === "all") await applyRetention(); if (section === "retention" || section === "all") await applyRetention();
if (section === "scheduling" || section === "all") await applyScheduling(); 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(); if (section === "storage" || section === "all") await applyStorage();
return { success: true }; 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"; import { getAgentAction } from "@/features/agents/agents.action";
export const getAgentStatusAction = userAction export const getAgentStatusAction = userAction
.schema(z.object({ agentId: z.string() })) .schema(z.object({ agentId: z.string() }))
.action(async ({ parsedInput }) => { .action(async ({ parsedInput }) => {
const result = await getAgentAction(parsedInput.agentId); const result = await getAgentAction(parsedInput.agentId);
if (!result?.data?.data) return { connected: false }; if (!result?.data?.data) return { connected: false };
const agent = result.data.data; const agent = result.data.data;
const lastContact = agent.lastContact ? new Date(agent.lastContact) : null; const lastContact = agent.lastContact ? new Date(agent.lastContact) : null;
const connected = lastContact !== null && Date.now() - lastContact.getTime() < 60_000; const connected =
return { 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"; import { eq } from "drizzle-orm";
export const markOnboardingDoneAction = userAction export const markOnboardingDoneAction = userAction
.schema(z.object({})) .schema(z.object({}))
.action(async () => { .action(async () => {
const settings = await db.query.setting.findFirst(); const settings = await db.query.setting.findFirst();
if (!settings) { if (!settings) {
throw new Error("Settings not found"); throw new Error("Settings not found");
} }
await db await db
.update(drizzleDb.schemas.setting) .update(drizzleDb.schemas.setting)
.set({ onboarding: true }) .set({ onboarding: true })
.where(eq(drizzleDb.schemas.setting.id, settings.id)); .where(eq(drizzleDb.schemas.setting.id, settings.id));
return { done: true }; return { done: true };
}); });
@@ -7,16 +7,18 @@ import * as drizzleDb from "@/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
export const updateAccountAction = userAction export const updateAccountAction = userAction
.schema(z.object({ .schema(
firstName: z.string().min(1), z.object({
lastName: z.string().min(1), 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 .action(async ({ parsedInput, ctx }) => {
.update(drizzleDb.schemas.user) const name = `${parsedInput.firstName} ${parsedInput.lastName}`.trim();
.set({ name, updatedAt: new Date() }) const [updated] = await db
.where(eq(drizzleDb.schemas.user.id, ctx.user.id)) .update(drizzleDb.schemas.user)
.returning(); .set({ name, updatedAt: new Date() })
return { user: updated }; .where(eq(drizzleDb.schemas.user.id, ctx.user.id))
}); .returning();
return { user: updated };
});
@@ -20,9 +20,9 @@ export type BackupScheduleValue = {
const PRESETS = [ const PRESETS = [
{ label: "Every hour", cron: "0 * * * *" }, { 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: "Every week", cron: "0 0 * * 0" },
{ label: "Custom", cron: "custom" }, { label: "Custom", cron: "custom" },
] as const; ] as const;
type PresetCron = (typeof PRESETS)[number]["cron"]; type PresetCron = (typeof PRESETS)[number]["cron"];
@@ -42,7 +42,7 @@ export const BackupScheduleSelector = ({
onChange, onChange,
}: BackupScheduleSelectorProps) => { }: BackupScheduleSelectorProps) => {
const [customCron, setCustomCron] = useState<string>( const [customCron, setCustomCron] = useState<string>(
value.cron ?? "0 0 * * *" value.cron ?? "0 0 * * *",
); );
const selectedPreset = detectPreset(value.cron); const selectedPreset = detectPreset(value.cron);
@@ -65,7 +65,7 @@ export const BackupScheduleSelector = ({
const handleCronPartChange = ( const handleCronPartChange = (
type: "minute" | "hour" | "day-of-month" | "month" | "day-of-week", type: "minute" | "hour" | "day-of-month" | "month" | "day-of-week",
part: string part: string,
) => { ) => {
const indexMap: Record<typeof type, number> = { const indexMap: Record<typeof type, number> = {
minute: 0, minute: 0,
@@ -149,11 +149,55 @@ export const BackupScheduleSelector = ({
<div className="flex flex-col gap-2 pl-1"> <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: "minute",
{ type: "day-of-month", label: "Day of Month", options: Array.from({ length: 31 }, (_, i) => String(i + 1).padStart(2, "0")), partIdx: 2 }, label: "Minute",
{ type: "month", label: "Month", options: ["01","02","03","04","05","06","07","08","09","10","11","12"], partIdx: 3 }, options: Array.from({ length: 60 }, (_, i) =>
{ type: "day-of-week", label: "Day of Week", options: ["0","1","2","3","4","5","6"], partIdx: 4 }, 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 ] as const
).map(({ type, label, options, partIdx }) => ( ).map(({ type, label, options, partIdx }) => (
<AdvancedCronSelect <AdvancedCronSelect
@@ -166,8 +210,13 @@ export const BackupScheduleSelector = ({
defaultValue={cronParts[partIdx] ?? "*"} defaultValue={cronParts[partIdx] ?? "*"}
onValueChange={(val) => onValueChange={(val) =>
handleCronPartChange( handleCronPartChange(
type as "minute" | "hour" | "day-of-month" | "month" | "day-of-week", type as
val | "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"; "use client";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
@@ -19,20 +18,36 @@ export const useAddNotifier = () => {
return useMutation({ return useMutation({
mutationFn: async ({ provider, name, config, label }: NotifierInput) => { 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({ const result = await addNotificationChannelAction({
organizationId: orgId, 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; 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 = [ const notifiers = [
...((state?.context.flowData.notifiers ?? []) as OnboardingChannel[]), ...((state?.context.flowData.notifiers ?? []) as OnboardingChannel[]),
channel, channel,
]; ];
await updateContext({ flowData: { ...state?.context.flowData, notifiers } }); await updateContext({
flowData: { ...state?.context.flowData, notifiers },
});
return channel; return channel;
}, },
onError: (err: Error) => toast.error(err.message), onError: (err: Error) => toast.error(err.message),
@@ -1,4 +1,3 @@
// src/features/onboarding/hooks/use-add-storage.ts
"use client"; "use client";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
@@ -19,20 +18,36 @@ export const useAddStorage = () => {
return useMutation({ return useMutation({
mutationFn: async ({ provider, name, config, label }: StorageInput) => { 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({ const result = await addStorageChannelAction({
organizationId: orgId, 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; 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 = [ const storages = [
...((state?.context.flowData.storages ?? []) as OnboardingChannel[]), ...((state?.context.flowData.storages ?? []) as OnboardingChannel[]),
channel, channel,
]; ];
await updateContext({ flowData: { ...state?.context.flowData, storages } }); await updateContext({
flowData: { ...state?.context.flowData, storages },
});
return channel; return channel;
}, },
onError: (err: Error) => toast.error(err.message), 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 { updateAccountAction } from "@/features/onboarding/actions/update-account.action";
import { generatePasskeyContextAction } from "@/features/onboarding/actions/generate-passkey-context.action"; import { generatePasskeyContextAction } from "@/features/onboarding/actions/generate-passkey-context.action";
import { WithPasswordSchema } from "@/features/onboarding/schemas/account.schema"; 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>) => { export const useUpdateAccount = (refetchSession: () => Promise<any>) => {
const { state, updateContext, next } = useOnboarding(); const { state, updateContext, next } = useOnboarding();
@@ -30,7 +31,11 @@ export const useUpdateAccount = (refetchSession: () => Promise<any>) => {
await updateContext({ await updateContext({
flowData: { flowData: {
...state?.context.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(); await next();
@@ -39,23 +44,39 @@ export const useUpdateAccount = (refetchSession: () => Promise<any>) => {
if (selectedMethod === "passkey") { if (selectedMethod === "passkey") {
const name = `${values.firstName} ${values.lastName}`; const name = `${values.firstName} ${values.lastName}`;
const context = await generatePasskeyContextAction(name, values.email); const ctxResult = await generatePasskeyContextAction({
const result = await passkey.addPasskey({ name: values.email, context }); 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) { if (result?.error) {
throw new Error(result.error.message ?? "Passkey registration failed"); throw new Error(
result.error.message ?? "Passkey registration failed",
);
} }
await refetchSession(); await refetchSession();
const { data: freshSession } = await authClient.getSession(); const { data: freshSession } = await authClient.getSession();
if (!freshSession?.user) { if (!freshSession?.user) {
const signInResult = await (signIn as any).passkey(); const signInResult = await (signIn as any).passkey();
if (signInResult?.error) { 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({ await updateContext({
flowData: { flowData: {
...state?.context.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" }, security: { method: "passkey" },
}, },
}); });
@@ -74,7 +95,11 @@ export const useUpdateAccount = (refetchSession: () => Promise<any>) => {
await updateContext({ await updateContext({
flowData: { flowData: {
...state?.context.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(); await next();
@@ -5,76 +5,78 @@ import { CheckCircle2, Circle } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
const CHECKLIST_STEPS = [ const CHECKLIST_STEPS = [
{ id: "login", label: "Sign in" }, { id: "login", label: "Sign in" },
{ id: "account-info", label: "Your account" }, { id: "account-info", label: "Your account" },
{ id: "security", label: "Security" }, { id: "security", label: "Security" },
{ id: "preferences", label: "Preferences" }, { id: "preferences", label: "Preferences" },
{ id: "org-create", label: "Organisation" }, { id: "org-create", label: "Organisation" },
{ id: "invite-members", label: "Team members" }, { id: "invite-members", label: "Team members" },
{ id: "notifier", label: "Notifications" }, { id: "notifier", label: "Notifications" },
{ id: "storage", label: "Storage" }, { id: "storage", label: "Storage" },
{ id: "defaults", label: "Defaults" }, { id: "defaults", label: "Defaults" },
{ id: "agent-create", label: "Agent setup" }, { id: "agent-create", label: "Agent setup" },
{ id: "agent-key", label: "Agent key" }, { id: "agent-key", label: "Agent key" },
{ id: "agent-waiting", label: "Agent connection" }, { id: "agent-waiting", label: "Agent connection" },
{ id: "project-create", label: "Project" }, { id: "project-create", label: "Project" },
{ id: "db-settings", label: "Database settings" }, { id: "db-settings", label: "Database settings" },
{ id: "finish", label: "Done" }, { id: "finish", label: "Done" },
] as const; ] as const;
export const OnboardingChecklist = () => { export const OnboardingChecklist = () => {
const { state } = useOnboarding(); const { state } = useOnboarding();
if (!state) return null; if (!state) return null;
const currentId = state.currentStep?.id ?? ""; const currentId = state.currentStep?.id ?? "";
const currentIndex = CHECKLIST_STEPS.findIndex((s) => s.id === currentId); const currentIndex = CHECKLIST_STEPS.findIndex((s) => s.id === currentId);
return ( return (
<div className="flex flex-col gap-0 p-6 h-full"> <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> <p className="text-xs font-medium text-zinc-500 uppercase tracking-wider mb-4">
<div className="flex flex-col relative"> Progress
{CHECKLIST_STEPS.map((step, i) => { </p>
const isCompleted = i < currentIndex; <div className="flex flex-col relative">
const isCurrent = i === currentIndex; {CHECKLIST_STEPS.map((step, i) => {
const isLast = i === CHECKLIST_STEPS.length - 1; const isCompleted = i < currentIndex;
const isCurrent = i === currentIndex;
const isLast = i === CHECKLIST_STEPS.length - 1;
return ( return (
<div key={step.id} className="flex gap-3"> <div key={step.id} className="flex gap-3">
{/* Icon + line */} <div className="flex flex-col items-center">
<div className="flex flex-col items-center"> {isCompleted ? (
{isCompleted ? ( <CheckCircle2 className="size-4 text-green-500 shrink-0 mt-0.5" />
<CheckCircle2 className="size-4 text-green-500 shrink-0 mt-0.5" /> ) : (
) : ( <Circle
<Circle className={cn(
className={cn( "size-4 shrink-0 mt-0.5",
"size-4 shrink-0 mt-0.5", isCurrent ? "text-primary" : "text-zinc-700",
isCurrent ? "text-primary" : "text-zinc-700" )}
)} />
/> )}
)} {!isLast && (
{!isLast && ( <div
<div className={cn( className={cn(
"w-px flex-1 mt-1 mb-1 min-h-[16px]", "w-px flex-1 mt-1 mb-1 min-h-[16px]",
isCompleted ? "bg-green-500/40" : "bg-zinc-800" isCompleted ? "bg-green-500/40" : "bg-zinc-800",
)} /> )}
)} />
</div> )}
</div>
{/* Label */} <p
<p className={cn(
className={cn( "text-sm pb-4",
"text-sm pb-4", isCompleted && "text-zinc-400",
isCompleted && "text-zinc-400", isCurrent && "text-white font-medium",
isCurrent && "text-white font-medium", !isCompleted && !isCurrent && "text-zinc-600",
!isCompleted && !isCurrent && "text-zinc-600" )}
)} >
> {step.label}
{step.label} </p>
</p>
</div>
);
})}
</div> </div>
</div> );
); })}
</div>
</div>
);
}; };
+23 -23
View File
@@ -93,9 +93,7 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
id: a.id, id: a.id,
name: a.name, name: a.name,
edgeKey: await generateEdgeKey(getServerUrl(), a.id), edgeKey: await generateEdgeKey(getServerUrl(), a.id),
connected: a.lastContact connected: !!a.lastContact,
? Date.now() - new Date(a.lastContact).getTime() < 60_000
: false,
})) }))
); );
@@ -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 (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"; meta.resumeStepId = "agent-create";
return { stepId: "agent-create", flowData: fullData }; return { stepId: "agent-create", flowData: fullData };
} }
const firstAgent = agents[0]; const agentHasPinged = !!agents[0]?.lastContact;
const agentConnected = firstAgent?.lastContact if (!agentHasPinged) {
? Date.now() - new Date(firstAgent.lastContact).getTime() < 60_000 meta.resumeStepId = "agent-key";
: false; return { stepId: "agent-key", flowData: fullData };
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 };
} }
meta.resumeStepId = "finish"; meta.resumeStepId = "finish";
return { stepId: "finish", flowData: fullData }; return { stepId: "finish", flowData: fullData };
} }
if (agents && agents.length > 0) { // Has agents but no project → past notifier/storage, waiting on project
const firstAgent = agents[0]; if (hasAgents) {
const agentConnected = firstAgent?.lastContact const agentHasPinged = !!agents[0]?.lastContact;
? Date.now() - new Date(firstAgent.lastContact).getTime() < 60_000 const stepId = agentHasPinged ? "project-create" : "agent-key";
: false;
const stepId = agentConnected ? "project-create" : "agent-key";
meta.resumeStepId = stepId; meta.resumeStepId = stepId;
return { stepId, flowData: fullData }; return { stepId, flowData: fullData };
} }
// No agents, no project → check earlier steps in order
if (notifiers.length === 0) { if (notifiers.length === 0) {
meta.resumeStepId = "notifier"; meta.resumeStepId = "notifier";
return { stepId: "notifier", flowData: fullData }; return { stepId: "notifier", flowData: fullData };
@@ -3,10 +3,11 @@
import { useOnboarding } from "@onboardjs/react"; import { useOnboarding } from "@onboardjs/react";
import { Progress } from "@/components/ui/progress"; import { Progress } from "@/components/ui/progress";
import { STEP_ORDER } from "@/features/onboarding/constants/steps"; import { STEP_ORDER } from "@/features/onboarding/constants/steps";
import { useIsMobile } from "@/hooks/use-mobile";
export const OnboardingStepper = () => { export const OnboardingStepper = () => {
const { state } = useOnboarding(); const { state } = useOnboarding();
const mobile = useIsMobile();
if (!state) return null; if (!state) return null;
const currentId = String(state.currentStep?.id ?? ""); const currentId = String(state.currentStep?.id ?? "");
@@ -18,10 +19,11 @@ export const OnboardingStepper = () => {
return ( return (
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
<div className="flex justify-between text-xs text-muted-foreground"> <div className="flex justify-between text-xs text-muted-foreground">
<span> {mobile && (
Step {stepNumber} of {totalSteps} <span>
</span> Step {stepNumber} of {totalSteps}
<span>{progress}%</span> </span>
)}
</div> </div>
<Progress value={progress} /> <Progress value={progress} />
</div> </div>
@@ -120,12 +120,14 @@ export const onboardingSteps: OnboardingStep[] = [
isSkippable: true, isSkippable: true,
skipToStep: (ctx: any) => { skipToStep: (ctx: any) => {
const agents = (ctx.flowData?.agents as any[]) || []; const agents = (ctx.flowData?.agents as any[]) || [];
if (agents.length === 0) return "agent-create";
const isAgentConnected = agents.some((a) => a.connected); const isAgentConnected = agents.some((a) => a.connected);
const databaseIds = (ctx.flowData?.project?.databaseIds as string[]) || []; const databaseIds = (ctx.flowData?.project?.databaseIds as string[]) || [];
return !isAgentConnected || databaseIds.length === 0 ? "finish" : "db-settings"; return !isAgentConnected || databaseIds.length === 0 ? "finish" : "db-settings";
}, },
nextStep: (ctx: any) => { nextStep: (ctx: any) => {
const agents = (ctx.flowData?.agents as any[]) || []; const agents = (ctx.flowData?.agents as any[]) || [];
if (agents.length === 0) return "agent-create";
const isAgentConnected = agents.some((a) => a.connected); const isAgentConnected = agents.some((a) => a.connected);
const databaseIds = (ctx.flowData?.project?.databaseIds as string[]) || []; const databaseIds = (ctx.flowData?.project?.databaseIds as string[]) || [];
return !isAgentConnected || databaseIds.length === 0 ? "finish" : "db-settings"; 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"; "use client";
import { useEffect, useState } from "react"; import { useEffect } from "react";
import { useOnboarding } from "@onboardjs/react"; import { useOnboarding } from "@onboardjs/react";
import { useSession } from "@/lib/auth/auth-client"; import { useSession } from "@/lib/auth/auth-client";
import { z } from "zod"; import { z } from "zod";
@@ -22,7 +22,6 @@ import {
} from "@/features/onboarding/schemas/account.schema"; } from "@/features/onboarding/schemas/account.schema";
import { useUpdateAccount } from "@/features/onboarding/hooks/use-update-account"; import { useUpdateAccount } from "@/features/onboarding/hooks/use-update-account";
import type { OnboardingMeta } from "@/features/onboarding/types"; import type { OnboardingMeta } from "@/features/onboarding/types";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
export const StepAccountInfo = () => { export const StepAccountInfo = () => {
const { next, state } = useOnboarding(); const { next, state } = useOnboarding();
@@ -164,7 +163,11 @@ export const StepAccountInfo = () => {
) : ( ) : (
<> <>
{emailPasswordEnabled && ( {emailPasswordEnabled && (
<Button type="button" onClick={onSubmitPassword} disabled={mutation.isPending}> <Button
type="button"
onClick={onSubmitPassword}
disabled={mutation.isPending}
>
Create account Create account
</Button> </Button>
)} )}
@@ -15,7 +15,8 @@ export const StepAgentKey = () => {
<div> <div>
<h1 className="text-2xl font-semibold">Connect your agent</h1> <h1 className="text-2xl font-semibold">Connect your agent</h1>
<p className="text-sm text-muted-foreground mt-1"> <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> </p>
</div> </div>
{agents.map((agent) => ( {agents.map((agent) => (
@@ -17,8 +17,6 @@ export const StepAgentWaiting = () => {
} }
}, [data?.connected, next]); }, [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; if (isLoading || data?.connected) return null;
return ( return (
File diff suppressed because it is too large Load Diff
+25 -12
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useEffect, useRef } from "react"; import { useState } from "react";
import { HardDrive } from "lucide-react";
import { useOnboarding } from "@onboardjs/react"; import { useOnboarding } from "@onboardjs/react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
@@ -33,7 +34,6 @@ export const StepDefaults = () => {
existingDefaults.storageId || undefined, existingDefaults.storageId || undefined,
); );
const selectNotifier = async (value: string) => { const selectNotifier = async (value: string) => {
setNotifierId(value); setNotifierId(value);
await updateNotificationSettingsAction({ await updateNotificationSettingsAction({
@@ -49,6 +49,16 @@ export const StepDefaults = () => {
}; };
const selectStorage = async (value: string) => { const selectStorage = async (value: string) => {
if (value === "filesystem") {
setStorageId(undefined);
await updateContext({
flowData: {
...state?.context.flowData,
defaults: { notifierId, storageId: undefined },
},
});
return;
}
setStorageId(value); setStorageId(value);
await updateStorageSettingsAction({ await updateStorageSettingsAction({
name: "system", name: "system",
@@ -83,7 +93,9 @@ export const StepDefaults = () => {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Default notifier</Label> <Label>Default notifier</Label>
<Select <Select
value={notifiers.some((n) => n.id === notifierId) ? notifierId : undefined} value={
notifiers.some((n) => n.id === notifierId) ? notifierId : undefined
}
onValueChange={selectNotifier} onValueChange={selectNotifier}
disabled={notifiers.length === 0} disabled={notifiers.length === 0}
> >
@@ -108,20 +120,21 @@ export const StepDefaults = () => {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Default storage</Label> <Label>Default storage</Label>
<Select <Select
value={storages.some((s) => s.id === storageId) ? storageId : undefined} value={
storages.some((s) => s.id === storageId) ? storageId : "filesystem"
}
onValueChange={selectStorage} onValueChange={selectStorage}
disabled={storages.length === 0}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue <SelectValue />
placeholder={
storages.length === 0
? "No storage connected"
: "Choose a storage"
}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <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) => ( {storages.map((s) => (
<SelectItem key={s.id} value={s.id}> <SelectItem key={s.id} value={s.id}>
{s.label} {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"; import { useMarkOnboardingDone } from "@/features/onboarding/hooks/use-mark-onboarding-done";
export const StepFinish = () => { export const StepFinish = () => {
const { next } = useOnboarding(); const { next } = useOnboarding();
const fired = useRef(false); const fired = useRef(false);
const mutation = useMarkOnboardingDone(); const mutation = useMarkOnboardingDone();
useEffect(() => { useEffect(() => {
if (fired.current) return; if (fired.current) return;
fired.current = true; fired.current = true;
confetti({ particleCount: 150, spread: 80, origin: { y: 0.6 } }); confetti({ particleCount: 150, spread: 80, origin: { y: 0.6 } });
}, []); }, []);
return ( return (
<div className="flex flex-col items-center justify-center gap-4 h-full text-center"> <div className="flex flex-col items-center justify-center gap-4 h-full text-center">
<CheckCircle2 className="size-16 text-green-500" /> <CheckCircle2 className="size-16 text-green-500" />
<h1 className="text-2xl font-semibold">You&apos;re all set!</h1> <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> <p className="text-sm text-muted-foreground">
<Button Your workspace is ready to use.
type="button" </p>
disabled={mutation.isPending} <Button
onClick={async () => { type="button"
await mutation.mutateAsync(); disabled={mutation.isPending}
await next(); onClick={async () => {
}} await mutation.mutateAsync();
> await next();
Go to dashboard }}
</Button> >
</div> Go to dashboard
); </Button>
</div>
);
}; };
@@ -9,55 +9,57 @@ import { X } from "lucide-react";
import { OnboardingMember } from "@/features/onboarding/types"; import { OnboardingMember } from "@/features/onboarding/types";
export const StepInviteMembers = () => { export const StepInviteMembers = () => {
const { next, updateContext, state } = useOnboarding(); const { next, updateContext, state } = useOnboarding();
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [members, setMembers] = useState<OnboardingMember[]>([]); const [members, setMembers] = useState<OnboardingMember[]>([]);
const addMember = () => { const addMember = () => {
if (!email.trim()) return; if (!email.trim()) return;
setMembers((prev) => [...prev, { email: email.trim(), role: "member" }]); setMembers((prev) => [...prev, { email: email.trim(), role: "member" }]);
setEmail(""); setEmail("");
}; };
const removeMember = (target: string) => { const removeMember = (target: string) => {
setMembers((prev) => prev.filter((m) => m.email !== target)); setMembers((prev) => prev.filter((m) => m.email !== target));
}; };
const onContinue = async () => { const onContinue = async () => {
await updateContext({ flowData: { ...state?.context.flowData, members } }); await updateContext({ flowData: { ...state?.context.flowData, members } });
await next(); await next();
}; };
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div> <div>
<h1 className="text-2xl font-semibold">Invite your team</h1> <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> <p className="text-sm text-muted-foreground mt-1">
</div> Optional you can always invite people later.
<div className="flex gap-2"> </p>
<Input </div>
value={email} <div className="flex gap-2">
onChange={(e) => setEmail(e.target.value)} <Input
placeholder="teammate@portabase.io" value={email}
onKeyDown={(e) => e.key === "Enter" && addMember()} onChange={(e) => setEmail(e.target.value)}
/> placeholder="teammate@portabase.io"
<Button type="button" variant="outline" onClick={addMember}> onKeyDown={(e) => e.key === "Enter" && addMember()}
Add />
</Button> <Button type="button" variant="outline" onClick={addMember}>
</div> Add
<div className="flex flex-wrap gap-2"> </Button>
{members.map((member) => ( </div>
<Badge key={member.email} variant="secondary" className="gap-1"> <div className="flex flex-wrap gap-2">
{member.email} {members.map((member) => (
<button type="button" onClick={() => removeMember(member.email)}> <Badge key={member.email} variant="secondary" className="gap-1">
<X className="size-3" /> {member.email}
</button> <button type="button" onClick={() => removeMember(member.email)}>
</Badge> <X className="size-3" />
))} </button>
</div> </Badge>
<Button type="button" onClick={onContinue}> ))}
Continue </div>
</Button> <Button type="button" onClick={onContinue}>
</div> Continue
); </Button>
</div>
);
}; };
+214 -193
View File
@@ -11,215 +11,236 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { PasswordInput } from "@/components/ui/password-input"; import { PasswordInput } from "@/components/ui/password-input";
import { import {
useZodForm, useZodForm,
Form, Form,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormControl, FormControl,
FormMessage, FormMessage,
} from "@/components/ui/form"; } from "@/components/ui/form";
import { signIn, useSession } from "@/lib/auth/auth-client"; import { signIn, useSession } from "@/lib/auth/auth-client";
import type { OnboardingMeta } from "@/features/onboarding/types"; import type { OnboardingMeta } from "@/features/onboarding/types";
const LoginSchema = z.object({ const LoginSchema = z.object({
email: z.string().email("Invalid email"), email: z.email("Invalid email"),
password: z.string().min(1, "Password required"), password: z.string().min(1, "Password required"),
}); });
type LoginValues = z.infer<typeof LoginSchema>; type LoginValues = z.infer<typeof LoginSchema>;
export const StepLogin = () => { export const StepLogin = () => {
const { next, state } = useOnboarding(); const { next, state } = useOnboarding();
const router = useRouter(); const router = useRouter();
const meta = state?.context.flowData.meta as OnboardingMeta | undefined; const meta = state?.context.flowData.meta as OnboardingMeta | undefined;
const { data: session } = useSession(); const { data: session } = useSession();
const passkeyEnabled = meta?.passkeyEnabled ?? false; const passkeyEnabled = meta?.passkeyEnabled ?? false;
const emailPasswordEnabled = meta?.emailPasswordEnabled ?? false; const emailPasswordEnabled = meta?.emailPasswordEnabled ?? false;
const hasAnySsoProvider = (meta?.ssoProviders?.length ?? 0) > 0; const hasAnySsoProvider = (meta?.ssoProviders?.length ?? 0) > 0;
const hasAnyAuthMethod = emailPasswordEnabled || passkeyEnabled || hasAnySsoProvider; const hasAnyAuthMethod =
emailPasswordEnabled || passkeyEnabled || hasAnySsoProvider;
// After login: reload the page so resolveOnboardingState re-runs server-side useEffect(() => {
// with the authenticated user and returns the correct resume step + flowData. if (session?.user) {
// Calling next() here would keep the stale unauthenticated flowData. router.push("/welcome");
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>
);
} }
}, [session?.user?.id]);
// New user registration flow const form = useZodForm({ schema: LoginSchema });
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>
);
}
// 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 ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col items-center justify-center text-center py-8 gap-4">
<div> <div className="p-4 rounded-full bg-yellow-500/10 mb-2">
<h1 className="text-2xl font-semibold">Welcome back</h1> <ShieldAlert className="size-8 text-yellow-500" />
<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> </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"; "use client";
import { useState } from "react"; import { useState } from "react";
@@ -26,7 +25,8 @@ type Phase = { kind: "grid" } | { kind: "configuring"; provider: string };
export const StepNotifier = () => { export const StepNotifier = () => {
const { next, updateContext, state } = useOnboarding(); const { next, updateContext, state } = useOnboarding();
const notifiers = (state?.context.flowData.notifiers ?? []) as OnboardingChannel[]; const notifiers = (state?.context.flowData.notifiers ??
[]) as OnboardingChannel[];
const [phase, setPhase] = useState<Phase>({ kind: "grid" }); const [phase, setPhase] = useState<Phase>({ kind: "grid" });
const form = useZodForm({ schema: NotificationChannelFormSchema }); const form = useZodForm({ schema: NotificationChannelFormSchema });
@@ -40,12 +40,16 @@ export const StepNotifier = () => {
}; };
const onContinue = async () => { const onContinue = async () => {
await updateContext({ flowData: { ...state?.context.flowData, notifiers } }); await updateContext({
flowData: { ...state?.context.flowData, notifiers },
});
await next(); await next();
}; };
if (phase.kind === "configuring") { 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; const Icon = providerDetails?.icon;
return ( return (
@@ -56,8 +60,15 @@ export const StepNotifier = () => {
<Icon className="size-5" /> <Icon className="size-5" />
</div> </div>
)} )}
<p className="flex-1 text-sm font-medium">Configuring {providerDetails?.label}</p> <p className="flex-1 text-sm font-medium">
<Button type="button" variant="ghost" size="sm" onClick={() => setPhase({ kind: "grid" })}> Configuring {providerDetails?.label}
</p>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setPhase({ kind: "grid" })}
>
<ArrowLeft className="size-4 mr-1" /> <ArrowLeft className="size-4 mr-1" />
Back Back
</Button> </Button>
@@ -66,7 +77,9 @@ export const StepNotifier = () => {
form={form} form={form}
className="flex flex-col gap-4" className="flex flex-col gap-4"
onSubmit={async (values: any) => { onSubmit={async (values: any) => {
const details = notificationProviders.find((p) => p.value === values.provider); const details = notificationProviders.find(
(p) => p.value === values.provider,
);
addNotifier.mutate( addNotifier.mutate(
{ {
provider: values.provider, provider: values.provider,
@@ -131,7 +144,9 @@ export const StepNotifier = () => {
{notifiers.length > 0 && ( {notifiers.length > 0 && (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{notifiers.map((ch) => { {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; const Icon = details?.icon;
return ( return (
<div <div
@@ -181,7 +196,10 @@ export const StepNotifier = () => {
<span className="flex-1 text-left">{provider.label}</span> <span className="flex-1 text-left">{provider.label}</span>
{isConfigured && ( {isConfigured && (
<div className="size-5 rounded-full bg-primary flex items-center justify-center ml-auto"> <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> </div>
)} )}
</button> </button>
@@ -8,41 +8,45 @@ import { Button } from "@/components/ui/button";
import { useCreateOrg } from "@/features/onboarding/hooks/use-create-org"; import { useCreateOrg } from "@/features/onboarding/hooks/use-create-org";
export const StepOrgCreate = () => { export const StepOrgCreate = () => {
const { state } = useOnboarding(); const { state } = useOnboarding();
const existingOrg = state?.context.flowData.org; const existingOrg = state?.context.flowData.org;
const isEditMode = !!existingOrg; const isEditMode = !!existingOrg;
const [name, setName] = useState(existingOrg?.name ?? ""); const [name, setName] = useState(existingOrg?.name ?? "");
const mutation = useCreateOrg(); const mutation = useCreateOrg();
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div> <div>
<h1 className="text-2xl font-semibold"> <h1 className="text-2xl font-semibold">
{isEditMode ? "Edit your organisation" : "Create your organisation"} {isEditMode ? "Edit your organisation" : "Create your organisation"}
</h1> </h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
{isEditMode ? "Rename your organisation." : "This step can't be skipped."} {isEditMode
</p> ? "Rename your organisation."
</div> : "This step can't be skipped."}
<div className="flex flex-col gap-2"> </p>
<Label htmlFor="org-name">Organisation name</Label> </div>
<Input <div className="flex flex-col gap-2">
id="org-name" <Label htmlFor="org-name">Organisation name</Label>
value={name} <Input
onChange={(e) => setName(e.target.value)} id="org-name"
placeholder="Acme Inc." value={name}
/> onChange={(e) => setName(e.target.value)}
</div> placeholder="Acme Inc."
<Button />
type="button" </div>
onClick={() => mutation.mutate(name)} <Button
disabled={!name.trim() || mutation.isPending} type="button"
> onClick={() => mutation.mutate(name)}
{mutation.isPending disabled={!name.trim() || mutation.isPending}
? isEditMode ? "Saving…" : "Creating…" >
: "Continue"} {mutation.isPending
</Button> ? isEditMode
</div> ? "Saving…"
); : "Creating…"
: "Continue"}
</Button>
</div>
);
}; };
@@ -49,9 +49,7 @@ export const StepPreferences = () => {
}; };
const selectTheme = async (theme: ThemeKey) => { const selectTheme = async (theme: ThemeKey) => {
// Apply immediately to the UI
setTheme(theme); setTheme(theme);
// Persist to DB so it survives page reload
await authClient.updateUser({ theme }); await authClient.updateUser({ theme });
await updateContext({ await updateContext({
flowData: { flowData: {
@@ -62,7 +60,6 @@ export const StepPreferences = () => {
}; };
const onContinue = async () => { const onContinue = async () => {
// Save avatar to user profile if selected
if (selectedAvatarUrl) { if (selectedAvatarUrl) {
await authClient.updateUser({ image: selectedAvatarUrl }); await authClient.updateUser({ image: selectedAvatarUrl });
} }
@@ -85,7 +82,6 @@ export const StepPreferences = () => {
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<p className="text-sm font-medium">Avatar</p> <p className="text-sm font-medium">Avatar</p>
{/* Remplacement de la grid par un flex avec wrap */}
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{AVATAR_COLORS.map((c) => { {AVATAR_COLORS.map((c) => {
const url = `/api/avatar?initials=${initials}&color=${encodeURIComponent(c.hex)}`; const url = `/api/avatar?initials=${initials}&color=${encodeURIComponent(c.hex)}`;
@@ -96,7 +92,6 @@ export const StepPreferences = () => {
type="button" type="button"
onClick={() => selectAvatar(c.hex)} onClick={() => selectAvatar(c.hex)}
className={cn( className={cn(
// Ajout de shrink-0 pour éviter toute déformation
"rounded-full overflow-hidden transition-all shrink-0", "rounded-full overflow-hidden transition-all shrink-0",
isSelected isSelected
? "ring-2 ring-primary ring-offset-2 ring-offset-background" ? "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"; "use client";
import { useState } from "react"; import { useState } from "react";
@@ -26,7 +25,8 @@ type Phase = { kind: "grid" } | { kind: "configuring"; provider: string };
export const StepStorage = () => { export const StepStorage = () => {
const { next, updateContext, state } = useOnboarding(); const { next, updateContext, state } = useOnboarding();
const storages = (state?.context.flowData.storages ?? []) as OnboardingChannel[]; const storages = (state?.context.flowData.storages ??
[]) as OnboardingChannel[];
const [phase, setPhase] = useState<Phase>({ kind: "grid" }); const [phase, setPhase] = useState<Phase>({ kind: "grid" });
const form = useZodForm({ schema: StorageChannelFormSchema }); const form = useZodForm({ schema: StorageChannelFormSchema });
@@ -45,7 +45,9 @@ export const StepStorage = () => {
}; };
if (phase.kind === "configuring") { 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; const Icon = providerDetails?.icon;
return ( return (
@@ -56,8 +58,15 @@ export const StepStorage = () => {
<Icon className="size-5" /> <Icon className="size-5" />
</div> </div>
)} )}
<p className="flex-1 text-sm font-medium">Configuring {providerDetails?.label}</p> <p className="flex-1 text-sm font-medium">
<Button type="button" variant="ghost" size="sm" onClick={() => setPhase({ kind: "grid" })}> Configuring {providerDetails?.label}
</p>
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setPhase({ kind: "grid" })}
>
<ArrowLeft className="size-4 mr-1" /> <ArrowLeft className="size-4 mr-1" />
Back Back
</Button> </Button>
@@ -66,7 +75,9 @@ export const StepStorage = () => {
form={form} form={form}
className="flex flex-col gap-4" className="flex flex-col gap-4"
onSubmit={async (values: any) => { onSubmit={async (values: any) => {
const details = storageProviders.find((p) => p.value === values.provider); const details = storageProviders.find(
(p) => p.value === values.provider,
);
addStorage.mutate( addStorage.mutate(
{ {
provider: values.provider, 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); const configuredProviderIds = storages.map((c) => c.provider);
return ( return (
@@ -131,7 +144,9 @@ export const StepStorage = () => {
{storages.length > 0 && ( {storages.length > 0 && (
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{storages.map((ch) => { {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; const Icon = details?.icon;
return ( return (
<div <div
@@ -181,7 +196,10 @@ export const StepStorage = () => {
<span className="flex-1 text-left">{provider.label}</span> <span className="flex-1 text-left">{provider.label}</span>
{isConfigured && ( {isConfigured && (
<div className="size-5 rounded-full bg-primary flex items-center justify-center ml-auto"> <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> </div>
)} )}
</button> </button>
+16 -1
View File
@@ -62,9 +62,24 @@ export type OnboardingDatabase = {
engine: "postgres" | "mysql" | "mongodb"; 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 = { export type OnboardingNotificationPolicy = {
channelId: string; channelId: string;
eventKinds: string[]; eventKinds: EventKind[];
enabled: boolean; 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 {sso} from "@better-auth/sso";
import {SUPPORTED_PROVIDERS} from "@/lib/auth/config"; import {SUPPORTED_PROVIDERS} from "@/lib/auth/config";
import {passkey} from "@better-auth/passkey"; import {passkey} from "@better-auth/passkey";
import {verifyPasskeyContext} from "@/lib/auth/passkey-context";
import {getOidcProviders} from "./oidc"; import {getOidcProviders} from "./oidc";
import {APIError} from "better-auth/api"; import {APIError} from "better-auth/api";
import {getOAuthProviders} from "./oauth"; import {getOAuthProviders} from "./oauth";
@@ -290,6 +291,36 @@ export const auth = betterAuth({
rpID: env.PROJECT_URL rpID: env.PROJECT_URL
? new URL(env.PROJECT_URL).hostname ? new URL(env.PROJECT_URL).hostname
: "localhost", : "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 };
}