From 1b3f972bc8fe6e936498d0790d00c74d55ab7961 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20LAGACHE?= Date: Mon, 22 Jun 2026 10:52:42 +0200 Subject: [PATCH] add: onboarding --- app/api/auth/[...all]/route.ts | 42 +- app/api/avatar/route.tsx | 68 +- proxy.ts | 5 +- src/components/common/theme-selector.tsx | 122 ++ src/db/services/organization.ts | 15 + src/db/services/setting.ts | 5 + src/db/services/user.ts | 5 + .../actions/apply-db-settings.action.ts | 19 +- .../generate-passkey-context.action.ts | 11 + .../actions/get-agent-status.action.ts | 19 +- .../actions/onboarding-mark-done.action.ts | 24 +- .../actions/update-account.action.ts | 28 +- .../backup-schedule-selector.tsx | 71 +- .../components/db-settings/db-detail.tsx | 119 ++ .../components/db-settings/db-grid.tsx | 59 + .../components/db-settings/db-section.tsx | 120 ++ .../db-settings/notifications-section.tsx | 223 ++++ .../db-settings/retention-section.tsx | 232 ++++ .../db-settings/scheduling-section.tsx | 50 + .../db-settings/storage-section.tsx | 192 +++ .../onboarding/constants/db-settings.ts | 15 + .../onboarding/hooks/use-add-notifier.ts | 27 +- .../onboarding/hooks/use-add-storage.ts | 27 +- .../onboarding/hooks/use-apply-db-settings.ts | 12 + .../onboarding/hooks/use-generate-edge-key.ts | 19 - .../onboarding/hooks/use-update-account.ts | 43 +- .../onboarding/onboarding-checklist.tsx | 130 +- src/features/onboarding/onboarding-state.ts | 46 +- .../onboarding/onboarding-stepper.tsx | 12 +- src/features/onboarding/onboarding-steps.tsx | 2 + .../onboarding/schemas/db-settings.schema.ts | 44 + .../onboarding/steps/step-account-info.tsx | 9 +- .../onboarding/steps/step-agent-key.tsx | 3 +- .../onboarding/steps/step-agent-waiting.tsx | 2 - .../onboarding/steps/step-db-settings.tsx | 1079 ++--------------- .../onboarding/steps/step-defaults.tsx | 37 +- src/features/onboarding/steps/step-finish.tsx | 52 +- .../onboarding/steps/step-invite-members.tsx | 96 +- src/features/onboarding/steps/step-login.tsx | 407 ++++--- .../onboarding/steps/step-notifier.tsx | 36 +- .../onboarding/steps/step-org-create.tsx | 74 +- .../onboarding/steps/step-preferences.tsx | 5 - .../onboarding/steps/step-storage.tsx | 36 +- src/features/onboarding/types/index.ts | 17 +- src/features/onboarding/utils/.gitkeep | 0 src/lib/auth/auth.ts | 31 + src/lib/auth/passkey-context.ts | 20 + 47 files changed, 2164 insertions(+), 1546 deletions(-) create mode 100644 src/components/common/theme-selector.tsx create mode 100644 src/db/services/organization.ts create mode 100644 src/db/services/setting.ts create mode 100644 src/features/onboarding/actions/generate-passkey-context.action.ts rename src/features/onboarding/{utils => components}/backup-schedule-selector.tsx (70%) create mode 100644 src/features/onboarding/components/db-settings/db-detail.tsx create mode 100644 src/features/onboarding/components/db-settings/db-grid.tsx create mode 100644 src/features/onboarding/components/db-settings/db-section.tsx create mode 100644 src/features/onboarding/components/db-settings/notifications-section.tsx create mode 100644 src/features/onboarding/components/db-settings/retention-section.tsx create mode 100644 src/features/onboarding/components/db-settings/scheduling-section.tsx create mode 100644 src/features/onboarding/components/db-settings/storage-section.tsx create mode 100644 src/features/onboarding/constants/db-settings.ts create mode 100644 src/features/onboarding/hooks/use-apply-db-settings.ts delete mode 100644 src/features/onboarding/hooks/use-generate-edge-key.ts create mode 100644 src/features/onboarding/schemas/db-settings.schema.ts create mode 100644 src/features/onboarding/utils/.gitkeep create mode 100644 src/lib/auth/passkey-context.ts diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts index 27ac28b8..f47c2094 100644 --- a/app/api/auth/[...all]/route.ts +++ b/app/api/auth/[...all]/route.ts @@ -5,31 +5,33 @@ import { headers } from "next/headers"; const authHandler = toNextJsHandler(auth.handler); -async function blockApiKeyCreateForRestrictedUsers(req: NextRequest): Promise { - const url = req.nextUrl; - if (req.method !== "POST" || !url.pathname.endsWith("/api-key/create")) { - return null; - } - const session = await auth.api.getSession({ headers: await headers() }); - if (!session?.user) { - return null; - } - // @ts-ignore - if (session.user.banned || (session.user.role as string) === "pending") { - return NextResponse.json( - { error: "Account not eligible to create API keys" }, - { status: 403 } - ); - } +async function blockApiKeyCreateForRestrictedUsers( + req: NextRequest, +): Promise { + const url = req.nextUrl; + if (req.method !== "POST" || !url.pathname.endsWith("/api-key/create")) { return null; + } + const session = await auth.api.getSession({ headers: await headers() }); + if (!session?.user) { + return null; + } + // @ts-ignore + if (session.user.banned || (session.user.role as string) === "pending") { + return NextResponse.json( + { error: "Account not eligible to create API keys" }, + { status: 403 }, + ); + } + return null; } export async function GET(req: NextRequest) { - return authHandler.GET(req); + return authHandler.GET(req); } export async function POST(req: NextRequest) { - const guard = await blockApiKeyCreateForRestrictedUsers(req); - if (guard) return guard; - return authHandler.POST(req); + const guard = await blockApiKeyCreateForRestrictedUsers(req); + if (guard) return guard; + return authHandler.POST(req); } diff --git a/app/api/avatar/route.tsx b/app/api/avatar/route.tsx index d99b8ec4..6235a3ab 100644 --- a/app/api/avatar/route.tsx +++ b/app/api/avatar/route.tsx @@ -4,42 +4,42 @@ import { NextRequest } from "next/server"; export const runtime = "edge"; const AVATAR_COLORS = [ - "#4f46e5", // indigo - "#7c3aed", // violet - "#e11d48", // rose - "#ea580c", // orange - "#d97706", // amber - "#059669", // emerald - "#0891b2", // cyan - "#52525b", // zinc + "#4f46e5", + "#7c3aed", + "#e11d48", + "#ea580c", + "#d97706", + "#059669", + "#0891b2", + "#52525b", ]; export async function GET(request: NextRequest) { - const { searchParams } = new URL(request.url); - const initials = (searchParams.get("initials") ?? "?").slice(0, 2).toUpperCase(); - const color = searchParams.get("color") ?? AVATAR_COLORS[0]; + const { searchParams } = new URL(request.url); + const initials = (searchParams.get("initials") ?? "?") + .slice(0, 2) + .toUpperCase(); + const color = searchParams.get("color") ?? AVATAR_COLORS[0]; - return new ImageResponse( - ( -
- {initials} -
- ), - { width: 80, height: 80 } - ); + return new ImageResponse( +
+ {initials} +
, + { width: 80, height: 80 }, + ); } diff --git a/proxy.ts b/proxy.ts index 32331a12..1c1442c7 100644 --- a/proxy.ts +++ b/proxy.ts @@ -4,7 +4,7 @@ import { errorHandler } from "@/middleware/errorHandler"; import { auth } from "@/lib/auth/auth"; import { headers } from "next/headers"; import { env } from "@/env.mjs"; -import {User} from "@/db/schema/02_user"; +import { User } from "@/db/schema/02_user"; export async function proxy(request: NextRequest) { const url = request.nextUrl.clone(); @@ -19,7 +19,7 @@ export async function proxy(request: NextRequest) { new URL(`/login?redirect=${redirectUrl}`, request.url), ); } - const user = session.user as User + const user = session.user as User; if (user.banned) { await auth.api.signOut({ headers: await headers() }); @@ -104,6 +104,7 @@ function checkRouteExists(pathname: string) { /^\/api\/config\/?$/, /^\/api\/health\/?$/, /^\/api\/google\/drive\/callback\/?$/, + /^\/api\/avatar\/?$/, // v1 external API /^\/api\/v1\/mcp\/?$/, /^\/api\/v1\/docs\/?$/, diff --git a/src/components/common/theme-selector.tsx b/src/components/common/theme-selector.tsx new file mode 100644 index 00000000..68783d2a --- /dev/null +++ b/src/components/common/theme-selector.tsx @@ -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 = { + dark: "Dark", + light: "Light", + system: "System", +}; + +interface ThemeSelectorProps { + value?: string; + onSelect: (value: ThemeKey) => void; + className?: string; +} + +export function ThemeSelector({ + value, + onSelect, + className, +}: ThemeSelectorProps) { + return ( +
+ {themes.map((item) => { + const isDark = item.value === "dark"; + const isSystem = item.value === "system"; + const isActive = value === item.value; + + return ( +
onSelect(item.value)} + > +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + {THEME_TEXT[item.value]} + +
+ {isActive && ( +
+ )} +
+
+
+ ); + })} +
+ ); +} diff --git a/src/db/services/organization.ts b/src/db/services/organization.ts new file mode 100644 index 00000000..a892bde9 --- /dev/null +++ b/src/db/services/organization.ts @@ -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), + }); +} diff --git a/src/db/services/setting.ts b/src/db/services/setting.ts new file mode 100644 index 00000000..a499db86 --- /dev/null +++ b/src/db/services/setting.ts @@ -0,0 +1,5 @@ +import { db } from "@/db"; + +export async function getSettings() { + return db.query.setting.findFirst(); +} diff --git a/src/db/services/user.ts b/src/db/services/user.ts index f847e15d..df4a1ad0 100644 --- a/src/db/services/user.ts +++ b/src/db/services/user.ts @@ -6,6 +6,11 @@ import {User, UserThemeEnum} from "@/db/schema/02_user"; import {assertValidPassword} from "@/utils/password"; +export async function hasUsers(): Promise { + const result = await db.select().from(drizzleDb.schemas.user).limit(1); + return result.length > 0; +} + export async function createUserDb(data: SignUpUser): Promise { assertValidPassword(data.password); diff --git a/src/features/onboarding/actions/apply-db-settings.action.ts b/src/features/onboarding/actions/apply-db-settings.action.ts index 7213d0bc..ab3fae02 100644 --- a/src/features/onboarding/actions/apply-db-settings.action.ts +++ b/src/features/onboarding/actions/apply-db-settings.action.ts @@ -43,13 +43,19 @@ export const applyOnboardingDbSettingsAction = userAction .schema( z.object({ databaseId: z.string().min(1), - section: z.enum(["retention", "scheduling", "notifications", "storage", "all"]), + section: z.enum([ + "retention", + "scheduling", + "notifications", + "storage", + "all", + ]), retention: RetentionSchema.optional(), backupMethod: z.enum(["manual", "automatic"]).optional(), backupCron: z.string().optional(), notificationPolicies: z.array(NotifPolicySchema).optional(), storagePolicies: z.array(StoragePolicyInputSchema).optional(), - }) + }), ) .action(async ({ parsedInput }) => { const { @@ -94,8 +100,6 @@ export const applyOnboardingDbSettingsAction = userAction const applyScheduling = async () => { if (backupMethod === undefined) return; - // Direct DB update — intentionally skips the side effect in - // updateDatabaseBackupPolicyAction that deletes retention policy on null. const cronValue = backupMethod === "manual" ? null : (backupCron ?? "0 0 * * *"); await db @@ -118,7 +122,7 @@ export const applyOnboardingDbSettingsAction = userAction notificationChannelId: p.channelId, eventKinds: p.eventKinds as any, enabled: p.enabled, - })) + })), ); } }); @@ -137,7 +141,7 @@ export const applyOnboardingDbSettingsAction = userAction databaseId, storageChannelId: p.channelId, enabled: p.enabled, - })) + })), ); } }); @@ -145,7 +149,8 @@ export const applyOnboardingDbSettingsAction = userAction if (section === "retention" || section === "all") await applyRetention(); if (section === "scheduling" || section === "all") await applyScheduling(); - if (section === "notifications" || section === "all") await applyNotifications(); + if (section === "notifications" || section === "all") + await applyNotifications(); if (section === "storage" || section === "all") await applyStorage(); return { success: true }; diff --git a/src/features/onboarding/actions/generate-passkey-context.action.ts b/src/features/onboarding/actions/generate-passkey-context.action.ts new file mode 100644 index 00000000..09cfaa64 --- /dev/null +++ b/src/features/onboarding/actions/generate-passkey-context.action.ts @@ -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); + }); diff --git a/src/features/onboarding/actions/get-agent-status.action.ts b/src/features/onboarding/actions/get-agent-status.action.ts index 6049fa30..00cc5df9 100644 --- a/src/features/onboarding/actions/get-agent-status.action.ts +++ b/src/features/onboarding/actions/get-agent-status.action.ts @@ -5,12 +5,13 @@ import { z } from "zod"; import { getAgentAction } from "@/features/agents/agents.action"; export const getAgentStatusAction = userAction - .schema(z.object({ agentId: z.string() })) - .action(async ({ parsedInput }) => { - const result = await getAgentAction(parsedInput.agentId); - if (!result?.data?.data) return { connected: false }; - const agent = result.data.data; - const lastContact = agent.lastContact ? new Date(agent.lastContact) : null; - const connected = lastContact !== null && Date.now() - lastContact.getTime() < 60_000; - return { connected }; - }); + .schema(z.object({ agentId: z.string() })) + .action(async ({ parsedInput }) => { + const result = await getAgentAction(parsedInput.agentId); + if (!result?.data?.data) return { connected: false }; + const agent = result.data.data; + const lastContact = agent.lastContact ? new Date(agent.lastContact) : null; + const connected = + lastContact !== null && Date.now() - lastContact.getTime() < 60_000; + return { connected }; + }); diff --git a/src/features/onboarding/actions/onboarding-mark-done.action.ts b/src/features/onboarding/actions/onboarding-mark-done.action.ts index ff3ecfb1..538d42ef 100644 --- a/src/features/onboarding/actions/onboarding-mark-done.action.ts +++ b/src/features/onboarding/actions/onboarding-mark-done.action.ts @@ -7,15 +7,15 @@ import * as drizzleDb from "@/db"; import { eq } from "drizzle-orm"; export const markOnboardingDoneAction = userAction - .schema(z.object({})) - .action(async () => { - const settings = await db.query.setting.findFirst(); - if (!settings) { - throw new Error("Settings not found"); - } - await db - .update(drizzleDb.schemas.setting) - .set({ onboarding: true }) - .where(eq(drizzleDb.schemas.setting.id, settings.id)); - return { done: true }; - }); + .schema(z.object({})) + .action(async () => { + const settings = await db.query.setting.findFirst(); + if (!settings) { + throw new Error("Settings not found"); + } + await db + .update(drizzleDb.schemas.setting) + .set({ onboarding: true }) + .where(eq(drizzleDb.schemas.setting.id, settings.id)); + return { done: true }; + }); diff --git a/src/features/onboarding/actions/update-account.action.ts b/src/features/onboarding/actions/update-account.action.ts index a34a9888..f9b062ae 100644 --- a/src/features/onboarding/actions/update-account.action.ts +++ b/src/features/onboarding/actions/update-account.action.ts @@ -7,16 +7,18 @@ import * as drizzleDb from "@/db"; import { eq } from "drizzle-orm"; export const updateAccountAction = userAction - .schema(z.object({ - firstName: z.string().min(1), - lastName: z.string().min(1), - })) - .action(async ({ parsedInput, ctx }) => { - const name = `${parsedInput.firstName} ${parsedInput.lastName}`.trim(); - const [updated] = await db - .update(drizzleDb.schemas.user) - .set({ name, updatedAt: new Date() }) - .where(eq(drizzleDb.schemas.user.id, ctx.user.id)) - .returning(); - return { user: updated }; - }); + .schema( + z.object({ + firstName: z.string().min(1), + lastName: z.string().min(1), + }), + ) + .action(async ({ parsedInput, ctx }) => { + const name = `${parsedInput.firstName} ${parsedInput.lastName}`.trim(); + const [updated] = await db + .update(drizzleDb.schemas.user) + .set({ name, updatedAt: new Date() }) + .where(eq(drizzleDb.schemas.user.id, ctx.user.id)) + .returning(); + return { user: updated }; + }); diff --git a/src/features/onboarding/utils/backup-schedule-selector.tsx b/src/features/onboarding/components/backup-schedule-selector.tsx similarity index 70% rename from src/features/onboarding/utils/backup-schedule-selector.tsx rename to src/features/onboarding/components/backup-schedule-selector.tsx index af1573f5..7747310b 100644 --- a/src/features/onboarding/utils/backup-schedule-selector.tsx +++ b/src/features/onboarding/components/backup-schedule-selector.tsx @@ -20,9 +20,9 @@ export type BackupScheduleValue = { const PRESETS = [ { label: "Every hour", cron: "0 * * * *" }, - { label: "Every day", cron: "0 0 * * *" }, + { label: "Every day", cron: "0 0 * * *" }, { label: "Every week", cron: "0 0 * * 0" }, - { label: "Custom", cron: "custom" }, + { label: "Custom", cron: "custom" }, ] as const; type PresetCron = (typeof PRESETS)[number]["cron"]; @@ -42,7 +42,7 @@ export const BackupScheduleSelector = ({ onChange, }: BackupScheduleSelectorProps) => { const [customCron, setCustomCron] = useState( - value.cron ?? "0 0 * * *" + value.cron ?? "0 0 * * *", ); const selectedPreset = detectPreset(value.cron); @@ -65,7 +65,7 @@ export const BackupScheduleSelector = ({ const handleCronPartChange = ( type: "minute" | "hour" | "day-of-month" | "month" | "day-of-week", - part: string + part: string, ) => { const indexMap: Record = { minute: 0, @@ -149,11 +149,55 @@ export const BackupScheduleSelector = ({
{( [ - { type: "minute", label: "Minute", options: Array.from({ length: 60 }, (_, i) => String(i).padStart(2, "0")), partIdx: 0 }, - { type: "hour", label: "Hour", options: Array.from({ length: 24 }, (_, i) => String(i).padStart(2, "0")), partIdx: 1 }, - { type: "day-of-month", label: "Day of Month", options: Array.from({ length: 31 }, (_, i) => String(i + 1).padStart(2, "0")), partIdx: 2 }, - { type: "month", label: "Month", options: ["01","02","03","04","05","06","07","08","09","10","11","12"], partIdx: 3 }, - { type: "day-of-week", label: "Day of Week", options: ["0","1","2","3","4","5","6"], partIdx: 4 }, + { + type: "minute", + label: "Minute", + options: Array.from({ length: 60 }, (_, i) => + String(i).padStart(2, "0"), + ), + partIdx: 0, + }, + { + type: "hour", + label: "Hour", + options: Array.from({ length: 24 }, (_, i) => + String(i).padStart(2, "0"), + ), + partIdx: 1, + }, + { + type: "day-of-month", + label: "Day of Month", + options: Array.from({ length: 31 }, (_, i) => + String(i + 1).padStart(2, "0"), + ), + partIdx: 2, + }, + { + type: "month", + label: "Month", + options: [ + "01", + "02", + "03", + "04", + "05", + "06", + "07", + "08", + "09", + "10", + "11", + "12", + ], + partIdx: 3, + }, + { + type: "day-of-week", + label: "Day of Week", + options: ["0", "1", "2", "3", "4", "5", "6"], + partIdx: 4, + }, ] as const ).map(({ type, label, options, partIdx }) => ( handleCronPartChange( - type as "minute" | "hour" | "day-of-month" | "month" | "day-of-week", - val + type as + | "minute" + | "hour" + | "day-of-month" + | "month" + | "day-of-week", + val, ) } /> diff --git a/src/features/onboarding/components/db-settings/db-detail.tsx b/src/features/onboarding/components/db-settings/db-detail.tsx new file mode 100644 index 00000000..50ac8d0b --- /dev/null +++ b/src/features/onboarding/components/db-settings/db-detail.tsx @@ -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: , + }, + { + kind: "scheduling", + label: "Scheduling", + icon: , + }, + { + kind: "notifications", + label: "Notifications", + icon: , + }, + { + kind: "storage", + label: "Storage", + icon: , + }, + ]; + +type DbDetailProps = { + db: OnboardingDatabase | undefined; + dbId: string; + isSectionConfigured: (section: SectionKind) => boolean; + isMultiDb: boolean; + hasAnyConfigured: boolean; + isApplyingToAll: boolean; + onSelectSection: (section: SectionKind) => void; + onApplyToAll: () => Promise; + onBack: () => void; +}; + +export const DbDetail = ({ + db, + dbId, + isSectionConfigured, + isMultiDb, + hasAnyConfigured, + isApplyingToAll, + onSelectSection, + onApplyToAll, + onBack, +}: DbDetailProps) => ( +
+
+
+ +
+

+ {db?.name ?? dbId}{" "} + + ({db?.engine}) + +

+ +
+ +
+ {SECTIONS.map(({ kind, label, icon }) => ( + + ))} +
+ + {isMultiDb && hasAnyConfigured && ( + + )} +
+); diff --git a/src/features/onboarding/components/db-settings/db-grid.tsx b/src/features/onboarding/components/db-settings/db-grid.tsx new file mode 100644 index 00000000..de042396 --- /dev/null +++ b/src/features/onboarding/components/db-settings/db-grid.tsx @@ -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) => ( +
+
+

Configure databases

+

+ Optional — configure backup policies for each database. +

+
+
+ {databaseIds.map((dbId) => { + const db = getDb(dbId); + return ( + + ); + })} +
+ +
+); diff --git a/src/features/onboarding/components/db-settings/db-section.tsx b/src/features/onboarding/components/db-settings/db-section.tsx new file mode 100644 index 00000000..3e65a58d --- /dev/null +++ b/src/features/onboarding/components/db-settings/db-section.tsx @@ -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 = { + retention: "Retention Policy", + scheduling: "Scheduling", + notifications: "Notifications", + storage: "Storage", +}; + +type DbSectionProps = { + dbId: string; + db: OnboardingDatabase | undefined; + section: SectionKind; + settings: OnboardingDbSettings; + applyMutation: ReturnType; + notifiers: OnboardingChannel[]; + storages: OnboardingChannel[]; + onBack: () => void; + onSaved: () => void; + updateDbSettings: (dbId: string, patch: Partial) => Promise; +}; + +export const DbSection = ({ + dbId, + db, + section, + settings, + applyMutation, + notifiers, + storages, + onBack, + onSaved, + updateDbSettings, +}: DbSectionProps) => ( +
+
+

+ {SECTION_LABELS[section]}{" "} + — {db?.name ?? dbId} +

+ +
+ + {section === "retention" && ( + { + await applyMutation.mutateAsync({ databaseId: dbId, section: "retention", retention }); + await updateDbSettings(dbId, { retention }); + toast.success("Retention policy saved."); + onSaved(); + }} + /> + )} + + {section === "scheduling" && ( + { + await applyMutation.mutateAsync({ databaseId: dbId, section: "scheduling", backupMethod, backupCron }); + await updateDbSettings(dbId, { backupMethod, backupCron }); + toast.success("Schedule saved."); + onSaved(); + }} + /> + )} + + {section === "notifications" && ( + { + await applyMutation.mutateAsync({ databaseId: dbId, section: "notifications", notificationPolicies }); + await updateDbSettings(dbId, { notificationPolicies }); + toast.success("Notification policies saved."); + onSaved(); + }} + /> + )} + + {section === "storage" && ( + { + await applyMutation.mutateAsync({ databaseId: dbId, section: "storage", storagePolicies }); + await updateDbSettings(dbId, { storagePolicies }); + toast.success("Storage policies saved."); + onSaved(); + }} + /> + )} +
+); diff --git a/src/features/onboarding/components/db-settings/notifications-section.tsx b/src/features/onboarding/components/db-settings/notifications-section.tsx new file mode 100644 index 00000000..c10b6eb0 --- /dev/null +++ b/src/features/onboarding/components/db-settings/notifications-section.tsx @@ -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; + onBack: () => void; + isPending: boolean; +}; + +export const NotificationsSection = ({ + initial, + notifiers, + onSave, + onBack, + isPending, +}: NotificationsSectionProps) => { + const [policies, setPolicies] = + useState(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, + ) => + 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 ( +
+
+ +

No notifiers configured

+

+ Go back and configure notifiers in the "Connect a + notifier" step first. +

+
+ +
+ ); + } + + return ( +
+
+ + +
+ + {policies.length === 0 ? ( +
+

+ Click "Add Policy" to start receiving notifications. +

+
+ ) : ( +
+ {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 ( + +
+
+ + +
+ +
+ +
+ + + updatePolicy(index, { enabled: v }) + } + className="scale-75 origin-right" + /> +
+
+ + +
+ +
+ + + updatePolicy(index, { eventKinds: v as EventKind[] }) + } + defaultValue={policy.eventKinds} + placeholder="Select events…" + variant="inverted" + animation={0} + className="bg-background/50 w-full" + /> +
+
+ ); + })} +
+ )} + +
+ + +
+
+ ); +}; diff --git a/src/features/onboarding/components/db-settings/retention-section.tsx b/src/features/onboarding/components/db-settings/retention-section.tsx new file mode 100644 index 00000000..90ddc0eb --- /dev/null +++ b/src/features/onboarding/components/db-settings/retention-section.tsx @@ -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, + ) => Promise; + onBack: () => void; + isPending: boolean; +}; + +export const RetentionSection = ({ + initial, + onSave, + onBack, + isPending, +}: RetentionSectionProps) => { + const [settings, setSettings] = useState< + NonNullable + >(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 ( +
+
+ + + 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) => ( + + ))} + +
+ + {settings.type && } + + {settings.type === "count" && ( +
+ + + setSettings((prev) => ({ + ...prev, + count: parseInt(e.target.value) || 1, + })) + } + /> +

+ Older backups beyond this count will be automatically deleted. +

+
+ )} + + {settings.type === "days" && ( +
+ + + setSettings((prev) => ({ + ...prev, + days: parseInt(e.target.value) || 1, + })) + } + /> +

+ Backups older than {settings.days} days will be automatically + deleted. +

+
+ )} + + {settings.type === "gfs" && ( +
+ {( + [ + { 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 }) => ( +
+ + + setSettings((prev) => ({ + ...prev, + gfs: { ...prev.gfs, [key]: parseInt(e.target.value) || 0 }, + })) + } + /> +

+ Keep N {key} backups +

+
+ ))} +
+ )} + + {settings.type && ( + <> + +
+
+ Storage Impact + + {storageEstimate()} Usage + +
+

+ ~{totalFiles()} backup files per database +

+
+ + )} + +
+ + +
+
+ ); +}; diff --git a/src/features/onboarding/components/db-settings/scheduling-section.tsx b/src/features/onboarding/components/db-settings/scheduling-section.tsx new file mode 100644 index 00000000..be40701d --- /dev/null +++ b/src/features/onboarding/components/db-settings/scheduling-section.tsx @@ -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; + onSave: (method: "manual" | "automatic", cron?: string) => Promise; + onBack: () => void; + isPending: boolean; +}; + +export const SchedulingSection = ({ + initial, + onSave, + onBack, + isPending, +}: SchedulingSectionProps) => { + const [schedule, setSchedule] = useState({ + method: initial.backupMethod ?? DEFAULT_SCHEDULE.method, + cron: initial.backupCron ?? DEFAULT_SCHEDULE.cron, + }); + + return ( +
+ +
+ + +
+
+ ); +}; diff --git a/src/features/onboarding/components/db-settings/storage-section.tsx b/src/features/onboarding/components/db-settings/storage-section.tsx new file mode 100644 index 00000000..3c02d7c2 --- /dev/null +++ b/src/features/onboarding/components/db-settings/storage-section.tsx @@ -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; + onBack: () => void; + isPending: boolean; +}; + +export const StorageSection = ({ + initial, + storages, + onSave, + onBack, + isPending, +}: StorageSectionProps) => { + const [policies, setPolicies] = useState(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, + ) => + 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 ( +
+
+ +

No storages configured

+

+ Go back and configure storages in the "Connect a storage" + step first. +

+
+ +
+ ); + } + + return ( +
+
+ + +
+ + {policies.length === 0 ? ( +
+

+ Click "Add Policy" to assign a storage to this database. +

+
+ ) : ( +
+ {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 ( + +
+ + +
+ +
+ +
+ + + updatePolicy(index, { enabled: v }) + } + className="scale-75 origin-right" + /> +
+
+ + +
+ ); + })} +
+ )} + +
+ + +
+
+ ); +}; diff --git a/src/features/onboarding/constants/db-settings.ts b/src/features/onboarding/constants/db-settings.ts new file mode 100644 index 00000000..bdafada1 --- /dev/null +++ b/src/features/onboarding/constants/db-settings.ts @@ -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 = + { + 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 * * *", +}; diff --git a/src/features/onboarding/hooks/use-add-notifier.ts b/src/features/onboarding/hooks/use-add-notifier.ts index f8dc2c70..80083fea 100644 --- a/src/features/onboarding/hooks/use-add-notifier.ts +++ b/src/features/onboarding/hooks/use-add-notifier.ts @@ -1,4 +1,3 @@ -// src/features/onboarding/hooks/use-add-notifier.ts "use client"; import { useMutation } from "@tanstack/react-query"; @@ -19,20 +18,36 @@ export const useAddNotifier = () => { return useMutation({ mutationFn: async ({ provider, name, config, label }: NotifierInput) => { - const orgId = (state?.context.flowData.org as any)?.id as string | undefined; + const orgId = (state?.context.flowData.org as any)?.id as + | string + | undefined; const result = await addNotificationChannelAction({ organizationId: orgId, - data: { provider: provider as any, name, config: config as any, enabled: true }, + data: { + provider: provider as any, + name, + config: config as any, + enabled: true, + }, }); const inner = result?.data; - if (!inner?.success || !inner.value) throw new Error("Failed to save channel"); + if (!inner?.success || !inner.value) + throw new Error("Failed to save channel"); - const channel: OnboardingChannel = { id: inner.value.id, provider, label, name, config }; + const channel: OnboardingChannel = { + id: inner.value.id, + provider, + label, + name, + config, + }; const notifiers = [ ...((state?.context.flowData.notifiers ?? []) as OnboardingChannel[]), channel, ]; - await updateContext({ flowData: { ...state?.context.flowData, notifiers } }); + await updateContext({ + flowData: { ...state?.context.flowData, notifiers }, + }); return channel; }, onError: (err: Error) => toast.error(err.message), diff --git a/src/features/onboarding/hooks/use-add-storage.ts b/src/features/onboarding/hooks/use-add-storage.ts index 3f41f995..54b271ea 100644 --- a/src/features/onboarding/hooks/use-add-storage.ts +++ b/src/features/onboarding/hooks/use-add-storage.ts @@ -1,4 +1,3 @@ -// src/features/onboarding/hooks/use-add-storage.ts "use client"; import { useMutation } from "@tanstack/react-query"; @@ -19,20 +18,36 @@ export const useAddStorage = () => { return useMutation({ mutationFn: async ({ provider, name, config, label }: StorageInput) => { - const orgId = (state?.context.flowData.org as any)?.id as string | undefined; + const orgId = (state?.context.flowData.org as any)?.id as + | string + | undefined; const result = await addStorageChannelAction({ organizationId: orgId, - data: { provider: provider as any, name, config: config as any, enabled: true }, + data: { + provider: provider as any, + name, + config: config as any, + enabled: true, + }, }); const inner = result?.data; - if (!inner?.success || !inner.value) throw new Error("Failed to save storage"); + if (!inner?.success || !inner.value) + throw new Error("Failed to save storage"); - const channel: OnboardingChannel = { id: inner.value.id, provider, label, name, config }; + const channel: OnboardingChannel = { + id: inner.value.id, + provider, + label, + name, + config, + }; const storages = [ ...((state?.context.flowData.storages ?? []) as OnboardingChannel[]), channel, ]; - await updateContext({ flowData: { ...state?.context.flowData, storages } }); + await updateContext({ + flowData: { ...state?.context.flowData, storages }, + }); return channel; }, onError: (err: Error) => toast.error(err.message), diff --git a/src/features/onboarding/hooks/use-apply-db-settings.ts b/src/features/onboarding/hooks/use-apply-db-settings.ts new file mode 100644 index 00000000..d474dd9c --- /dev/null +++ b/src/features/onboarding/hooks/use-apply-db-settings.ts @@ -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[0]) => + applyOnboardingDbSettingsAction(args), + onError: () => toast.error("Failed to save settings."), + }); diff --git a/src/features/onboarding/hooks/use-generate-edge-key.ts b/src/features/onboarding/hooks/use-generate-edge-key.ts deleted file mode 100644 index ca2de8f3..00000000 --- a/src/features/onboarding/hooks/use-generate-edge-key.ts +++ /dev/null @@ -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, - }); -}; diff --git a/src/features/onboarding/hooks/use-update-account.ts b/src/features/onboarding/hooks/use-update-account.ts index 73a0592f..d06164bd 100644 --- a/src/features/onboarding/hooks/use-update-account.ts +++ b/src/features/onboarding/hooks/use-update-account.ts @@ -8,9 +8,10 @@ import { authClient, signUp, passkey, signIn } from "@/lib/auth/auth-client"; import { updateAccountAction } from "@/features/onboarding/actions/update-account.action"; import { generatePasskeyContextAction } from "@/features/onboarding/actions/generate-passkey-context.action"; import { WithPasswordSchema } from "@/features/onboarding/schemas/account.schema"; -import type { OnboardingMeta } from "@/features/onboarding/types"; -type AccountInput = z.infer & { method?: "passkey" | "password" }; +type AccountInput = z.infer & { + method?: "passkey" | "password"; +}; export const useUpdateAccount = (refetchSession: () => Promise) => { const { state, updateContext, next } = useOnboarding(); @@ -30,7 +31,11 @@ export const useUpdateAccount = (refetchSession: () => Promise) => { await updateContext({ flowData: { ...state?.context.flowData, - account: { firstName: values.firstName, lastName: values.lastName, email: values.email }, + account: { + firstName: values.firstName, + lastName: values.lastName, + email: values.email, + }, }, }); await next(); @@ -39,23 +44,39 @@ export const useUpdateAccount = (refetchSession: () => Promise) => { if (selectedMethod === "passkey") { const name = `${values.firstName} ${values.lastName}`; - const context = await generatePasskeyContextAction(name, values.email); - const result = await passkey.addPasskey({ name: values.email, context }); + const ctxResult = await generatePasskeyContextAction({ + name, + email: values.email, + }); + const context = ctxResult?.data; + if (!context) throw new Error("Failed to generate passkey context"); + const result = await passkey.addPasskey({ + name: values.email, + context, + }); if (result?.error) { - throw new Error(result.error.message ?? "Passkey registration failed"); + throw new Error( + result.error.message ?? "Passkey registration failed", + ); } await refetchSession(); const { data: freshSession } = await authClient.getSession(); if (!freshSession?.user) { const signInResult = await (signIn as any).passkey(); if (signInResult?.error) { - throw new Error("Registration succeeded but sign-in failed. Please reload and sign in."); + throw new Error( + "Registration succeeded but sign-in failed. Please reload and sign in.", + ); } } await updateContext({ flowData: { ...state?.context.flowData, - account: { firstName: values.firstName, lastName: values.lastName, email: values.email }, + account: { + firstName: values.firstName, + lastName: values.lastName, + email: values.email, + }, security: { method: "passkey" }, }, }); @@ -74,7 +95,11 @@ export const useUpdateAccount = (refetchSession: () => Promise) => { await updateContext({ flowData: { ...state?.context.flowData, - account: { firstName: values.firstName, lastName: values.lastName, email: values.email }, + account: { + firstName: values.firstName, + lastName: values.lastName, + email: values.email, + }, }, }); await next(); diff --git a/src/features/onboarding/onboarding-checklist.tsx b/src/features/onboarding/onboarding-checklist.tsx index 14b90d1e..363e3b03 100644 --- a/src/features/onboarding/onboarding-checklist.tsx +++ b/src/features/onboarding/onboarding-checklist.tsx @@ -5,76 +5,78 @@ import { CheckCircle2, Circle } from "lucide-react"; import { cn } from "@/lib/utils"; const CHECKLIST_STEPS = [ - { id: "login", label: "Sign in" }, - { id: "account-info", label: "Your account" }, - { id: "security", label: "Security" }, - { id: "preferences", label: "Preferences" }, - { id: "org-create", label: "Organisation" }, - { id: "invite-members", label: "Team members" }, - { id: "notifier", label: "Notifications" }, - { id: "storage", label: "Storage" }, - { id: "defaults", label: "Defaults" }, - { id: "agent-create", label: "Agent setup" }, - { id: "agent-key", label: "Agent key" }, - { id: "agent-waiting", label: "Agent connection" }, - { id: "project-create", label: "Project" }, - { id: "db-settings", label: "Database settings" }, - { id: "finish", label: "Done" }, + { id: "login", label: "Sign in" }, + { id: "account-info", label: "Your account" }, + { id: "security", label: "Security" }, + { id: "preferences", label: "Preferences" }, + { id: "org-create", label: "Organisation" }, + { id: "invite-members", label: "Team members" }, + { id: "notifier", label: "Notifications" }, + { id: "storage", label: "Storage" }, + { id: "defaults", label: "Defaults" }, + { id: "agent-create", label: "Agent setup" }, + { id: "agent-key", label: "Agent key" }, + { id: "agent-waiting", label: "Agent connection" }, + { id: "project-create", label: "Project" }, + { id: "db-settings", label: "Database settings" }, + { id: "finish", label: "Done" }, ] as const; export const OnboardingChecklist = () => { - const { state } = useOnboarding(); - if (!state) return null; + const { state } = useOnboarding(); + if (!state) return null; - const currentId = state.currentStep?.id ?? ""; - const currentIndex = CHECKLIST_STEPS.findIndex((s) => s.id === currentId); + const currentId = state.currentStep?.id ?? ""; + const currentIndex = CHECKLIST_STEPS.findIndex((s) => s.id === currentId); - return ( -
-

Progress

-
- {CHECKLIST_STEPS.map((step, i) => { - const isCompleted = i < currentIndex; - const isCurrent = i === currentIndex; - const isLast = i === CHECKLIST_STEPS.length - 1; + return ( +
+

+ Progress +

+
+ {CHECKLIST_STEPS.map((step, i) => { + const isCompleted = i < currentIndex; + const isCurrent = i === currentIndex; + const isLast = i === CHECKLIST_STEPS.length - 1; - return ( -
- {/* Icon + line */} -
- {isCompleted ? ( - - ) : ( - - )} - {!isLast && ( -
- )} -
+ return ( +
+
+ {isCompleted ? ( + + ) : ( + + )} + {!isLast && ( +
+ )} +
- {/* Label */} -

- {step.label} -

-
- ); - })} +

+ {step.label} +

-
- ); + ); + })} +
+
+ ); }; diff --git a/src/features/onboarding/onboarding-state.ts b/src/features/onboarding/onboarding-state.ts index f74917b5..d6baeecb 100644 --- a/src/features/onboarding/onboarding-state.ts +++ b/src/features/onboarding/onboarding-state.ts @@ -93,9 +93,7 @@ export async function resolveOnboardingState(): Promise id: a.id, name: a.name, edgeKey: await generateEdgeKey(getServerUrl(), a.id), - connected: a.lastContact - ? Date.now() - new Date(a.lastContact).getTime() < 60_000 - : false, + connected: !!a.lastContact, })) ); @@ -129,41 +127,43 @@ export async function resolveOnboardingState(): Promise : {}), }; + const hasAgents = agents && agents.length > 0; + + // Has project → late stage (project was created after agent-key) if (project) { - if (!agents || agents.length === 0) { + if (!hasAgents) { + // Project without agents: missed earlier steps + if (notifiers.length === 0) { + meta.resumeStepId = "notifier"; + return { stepId: "notifier", flowData: fullData }; + } + if (storages.length === 0) { + meta.resumeStepId = "storage"; + return { stepId: "storage", flowData: fullData }; + } meta.resumeStepId = "agent-create"; return { stepId: "agent-create", flowData: fullData }; } - const firstAgent = agents[0]; - const agentConnected = firstAgent?.lastContact - ? Date.now() - new Date(firstAgent.lastContact).getTime() < 60_000 - : false; - - if (agentConnected && (project as any).databases?.length === 0) { - meta.resumeStepId = "project-create"; - return { stepId: "project-create", flowData: fullData }; - } - - if (!agentConnected) { - meta.resumeStepId = "finish"; - return { stepId: "finish", flowData: fullData }; + const agentHasPinged = !!agents[0]?.lastContact; + if (!agentHasPinged) { + meta.resumeStepId = "agent-key"; + return { stepId: "agent-key", flowData: fullData }; } meta.resumeStepId = "finish"; return { stepId: "finish", flowData: fullData }; } - if (agents && agents.length > 0) { - const firstAgent = agents[0]; - const agentConnected = firstAgent?.lastContact - ? Date.now() - new Date(firstAgent.lastContact).getTime() < 60_000 - : false; - const stepId = agentConnected ? "project-create" : "agent-key"; + // Has agents but no project → past notifier/storage, waiting on project + if (hasAgents) { + const agentHasPinged = !!agents[0]?.lastContact; + const stepId = agentHasPinged ? "project-create" : "agent-key"; meta.resumeStepId = stepId; return { stepId, flowData: fullData }; } + // No agents, no project → check earlier steps in order if (notifiers.length === 0) { meta.resumeStepId = "notifier"; return { stepId: "notifier", flowData: fullData }; diff --git a/src/features/onboarding/onboarding-stepper.tsx b/src/features/onboarding/onboarding-stepper.tsx index e1672fd7..bdaaa603 100644 --- a/src/features/onboarding/onboarding-stepper.tsx +++ b/src/features/onboarding/onboarding-stepper.tsx @@ -3,10 +3,11 @@ import { useOnboarding } from "@onboardjs/react"; import { Progress } from "@/components/ui/progress"; import { STEP_ORDER } from "@/features/onboarding/constants/steps"; +import { useIsMobile } from "@/hooks/use-mobile"; export const OnboardingStepper = () => { const { state } = useOnboarding(); - + const mobile = useIsMobile(); if (!state) return null; const currentId = String(state.currentStep?.id ?? ""); @@ -18,10 +19,11 @@ export const OnboardingStepper = () => { return (
- - Step {stepNumber} of {totalSteps} - - {progress}% + {mobile && ( + + Step {stepNumber} of {totalSteps} + + )}
diff --git a/src/features/onboarding/onboarding-steps.tsx b/src/features/onboarding/onboarding-steps.tsx index 9843ad64..4e2d1b47 100644 --- a/src/features/onboarding/onboarding-steps.tsx +++ b/src/features/onboarding/onboarding-steps.tsx @@ -120,12 +120,14 @@ export const onboardingSteps: OnboardingStep[] = [ isSkippable: true, skipToStep: (ctx: any) => { const agents = (ctx.flowData?.agents as any[]) || []; + if (agents.length === 0) return "agent-create"; const isAgentConnected = agents.some((a) => a.connected); const databaseIds = (ctx.flowData?.project?.databaseIds as string[]) || []; return !isAgentConnected || databaseIds.length === 0 ? "finish" : "db-settings"; }, nextStep: (ctx: any) => { const agents = (ctx.flowData?.agents as any[]) || []; + if (agents.length === 0) return "agent-create"; const isAgentConnected = agents.some((a) => a.connected); const databaseIds = (ctx.flowData?.project?.databaseIds as string[]) || []; return !isAgentConnected || databaseIds.length === 0 ? "finish" : "db-settings"; diff --git a/src/features/onboarding/schemas/db-settings.schema.ts b/src/features/onboarding/schemas/db-settings.schema.ts new file mode 100644 index 00000000..f631d956 --- /dev/null +++ b/src/features/onboarding/schemas/db-settings.schema.ts @@ -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(), +}); diff --git a/src/features/onboarding/steps/step-account-info.tsx b/src/features/onboarding/steps/step-account-info.tsx index 876a44cb..8c8d4105 100644 --- a/src/features/onboarding/steps/step-account-info.tsx +++ b/src/features/onboarding/steps/step-account-info.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect } from "react"; import { useOnboarding } from "@onboardjs/react"; import { useSession } from "@/lib/auth/auth-client"; import { z } from "zod"; @@ -22,7 +22,6 @@ import { } from "@/features/onboarding/schemas/account.schema"; import { useUpdateAccount } from "@/features/onboarding/hooks/use-update-account"; import type { OnboardingMeta } from "@/features/onboarding/types"; -import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; export const StepAccountInfo = () => { const { next, state } = useOnboarding(); @@ -164,7 +163,11 @@ export const StepAccountInfo = () => { ) : ( <> {emailPasswordEnabled && ( - )} diff --git a/src/features/onboarding/steps/step-agent-key.tsx b/src/features/onboarding/steps/step-agent-key.tsx index f1f12c3f..34cdcedd 100644 --- a/src/features/onboarding/steps/step-agent-key.tsx +++ b/src/features/onboarding/steps/step-agent-key.tsx @@ -15,7 +15,8 @@ export const StepAgentKey = () => {

Connect your agent

- 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.

{agents.map((agent) => ( diff --git a/src/features/onboarding/steps/step-agent-waiting.tsx b/src/features/onboarding/steps/step-agent-waiting.tsx index 8dfaaa9f..d742ff5c 100644 --- a/src/features/onboarding/steps/step-agent-waiting.tsx +++ b/src/features/onboarding/steps/step-agent-waiting.tsx @@ -17,8 +17,6 @@ export const StepAgentWaiting = () => { } }, [data?.connected, next]); - // Don't render until the first fetch completes, or if the agent is already - // connected (next() fires before the spinner is ever displayed). if (isLoading || data?.connected) return null; return ( diff --git a/src/features/onboarding/steps/step-db-settings.tsx b/src/features/onboarding/steps/step-db-settings.tsx index 5a72905c..4a668a21 100644 --- a/src/features/onboarding/steps/step-db-settings.tsx +++ b/src/features/onboarding/steps/step-db-settings.tsx @@ -2,1024 +2,175 @@ import { useState } from "react"; import { useOnboarding } from "@onboardjs/react"; -import { useMutation } from "@tanstack/react-query"; import { toast } from "sonner"; -import { - ArrowLeft, - Check, - Database, - Bell, - HardDrive, - Shield, - Clock, - Plus, - Trash2, - Copy, -} from "lucide-react"; -import { Button } from "@/components/ui/button"; -import { Badge } from "@/components/ui/badge"; -import { Switch } from "@/components/ui/switch"; -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 { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@/components/ui/select"; -import { Card } from "@/components/ui/card"; -import { MultiSelect } from "@/components/common/multi-select"; -import { BackupScheduleSelector } from "@/features/onboarding/utils/backup-schedule-selector"; -import type { BackupScheduleValue } from "@/features/onboarding/utils/backup-schedule-selector"; -import { applyOnboardingDbSettingsAction } from "@/features/onboarding/actions/apply-db-settings.action"; -import { EVENT_KIND_OPTIONS } from "@/features/database/channels-policy.schema"; -import { getChannelIcon } from "@/features/channel/channels-helpers"; +import { DbGrid } from "@/features/onboarding/components/db-settings/db-grid"; +import { DbDetail } from "@/features/onboarding/components/db-settings/db-detail"; +import { DbSection } from "@/features/onboarding/components/db-settings/db-section"; +import { useApplyDbSettings } from "@/features/onboarding/hooks/use-apply-db-settings"; import type { OnboardingChannel, OnboardingDatabase, OnboardingDbSettings, - OnboardingNotificationPolicy, OnboardingProjectData, - OnboardingStoragePolicy, + SectionKind, } from "@/features/onboarding/types"; -// ─── Types ──────────────────────────────────────────────────────────────────── - -type SectionKind = "retention" | "scheduling" | "notifications" | "storage"; - type Phase = | { kind: "grid" } | { kind: "db"; dbId: string } | { kind: "section"; dbId: string; section: SectionKind }; -// ─── Defaults ───────────────────────────────────────────────────────────────── - -const DEFAULT_RETENTION: OnboardingDbSettings["retention"] = { - type: "gfs", - count: 7, - days: 30, - gfs: { daily: 7, weekly: 4, monthly: 12, yearly: 3 }, -}; - -const DEFAULT_SCHEDULE: BackupScheduleValue = { - method: "automatic", - cron: "0 0 * * *", -}; - -// ─── Section: Retention ─────────────────────────────────────────────────────── - -type RetentionSectionProps = { - initial: OnboardingDbSettings["retention"]; - onSave: (value: NonNullable) => Promise; - onBack: () => void; - isPending: boolean; -}; - -const RetentionSection = ({ - initial, - onSave, - onBack, - isPending, -}: RetentionSectionProps) => { - const [settings, setSettings] = useState>( - 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 ( -
-
- - - 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) => ( - - ))} - -
- - {settings.type && } - - {settings.type === "count" && ( -
- - - setSettings((prev) => ({ - ...prev, - count: parseInt(e.target.value) || 1, - })) - } - /> -

- Older backups beyond this count will be automatically deleted. -

-
- )} - - {settings.type === "days" && ( -
- - - setSettings((prev) => ({ - ...prev, - days: parseInt(e.target.value) || 1, - })) - } - /> -

- Backups older than {settings.days} days will be automatically deleted. -

-
- )} - - {settings.type === "gfs" && ( -
- {( - [ - { 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 }) => ( -
- - - setSettings((prev) => ({ - ...prev, - gfs: { ...prev.gfs, [key]: parseInt(e.target.value) || 0 }, - })) - } - /> -

Keep N {key} backups

-
- ))} -
- )} - - {settings.type && ( - <> - -
-
- Storage Impact - - {storageEstimate()} Usage - -
-

- ~{totalFiles()} backup files per database -

-
- - )} - -
- - -
-
- ); -}; - -// ─── Section: Scheduling ────────────────────────────────────────────────────── - -type SchedulingSectionProps = { - initial: Pick; - onSave: (method: "manual" | "automatic", cron?: string) => Promise; - onBack: () => void; - isPending: boolean; -}; - -const SchedulingSection = ({ - initial, - onSave, - onBack, - isPending, -}: SchedulingSectionProps) => { - const [schedule, setSchedule] = useState({ - method: initial.backupMethod ?? DEFAULT_SCHEDULE.method, - cron: initial.backupCron ?? DEFAULT_SCHEDULE.cron, - }); - - return ( -
- -
- - -
-
- ); -}; - -// ─── Section: Notifications ─────────────────────────────────────────────────── - -type NotificationsSectionProps = { - initial: OnboardingNotificationPolicy[]; - notifiers: OnboardingChannel[]; - onSave: (policies: OnboardingNotificationPolicy[]) => Promise; - onBack: () => void; - isPending: boolean; -}; - -const NotificationsSection = ({ - initial, - notifiers, - onSave, - onBack, - isPending, -}: NotificationsSectionProps) => { - const [policies, setPolicies] = useState(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 - ) => - 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 ( -
-
- -

No notifiers configured

-

- Go back and configure notifiers in the "Connect a notifier" step first. -

-
- -
- ); - } - - return ( -
-
- - -
- - {policies.length === 0 ? ( -
-

- Click "Add Policy" to start receiving notifications. -

-
- ) : ( -
- {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 ( - -
-
- - -
- -
- -
- - updatePolicy(index, { enabled: v })} - className="scale-75 origin-right" - /> -
-
- - -
- -
- - updatePolicy(index, { eventKinds: v })} - defaultValue={policy.eventKinds} - placeholder="Select events…" - variant="inverted" - animation={0} - className="bg-background/50 w-full" - /> -
-
- ); - })} -
- )} - -
- - -
-
- ); -}; - -// ─── Section: Storage ───────────────────────────────────────────────────────── - -type StorageSectionProps = { - initial: OnboardingStoragePolicy[]; - storages: OnboardingChannel[]; - onSave: (policies: OnboardingStoragePolicy[]) => Promise; - onBack: () => void; - isPending: boolean; -}; - -const StorageSection = ({ - initial, - storages, - onSave, - onBack, - isPending, -}: StorageSectionProps) => { - const [policies, setPolicies] = useState(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 - ) => - 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 ( -
-
- -

No storages configured

-

- Go back and configure storages in the "Connect a storage" step first. -

-
- -
- ); - } - - return ( -
-
- - -
- - {policies.length === 0 ? ( -
-

- Click "Add Policy" to assign a storage to this database. -

-
- ) : ( -
- {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 ( - -
- - -
- -
- -
- - updatePolicy(index, { enabled: v })} - className="scale-75 origin-right" - /> -
-
- - -
- ); - })} -
- )} - -
- - -
-
- ); -}; - -// ─── Main Component ─────────────────────────────────────────────────────────── - export const StepDbSettings = () => { const { next, updateContext, state } = useOnboarding(); + const [phase, setPhase] = useState({ kind: "grid" }); const project = (state?.context.flowData.project ?? { databaseIds: [], }) as OnboardingProjectData; - const databases = (state?.context.flowData.databases ?? []) as OnboardingDatabase[]; - const notifiers = (state?.context.flowData.notifiers ?? []) as OnboardingChannel[]; - const storages = (state?.context.flowData.storages ?? []) as OnboardingChannel[]; - const dbSettings = ( - state?.context.flowData.dbSettings ?? {} - ) as Record; + const databases = (state?.context.flowData.databases ?? + []) as OnboardingDatabase[]; + const notifiers = (state?.context.flowData.notifiers ?? + []) as OnboardingChannel[]; + const storages = (state?.context.flowData.storages ?? + []) as OnboardingChannel[]; + const dbSettings = (state?.context.flowData.dbSettings ?? {}) as Record< + string, + OnboardingDbSettings + >; const databaseIds = project.databaseIds; - const [phase, setPhase] = useState({ kind: "grid" }); - - const applyMutation = useMutation({ - mutationFn: (args: Parameters[0]) => - applyOnboardingDbSettingsAction(args), - onError: () => toast.error("Failed to save settings."), - }); + const applyMutation = useApplyDbSettings(); if (!databaseIds || databaseIds.length === 0) return null; - const getDb = (dbId: string) => databases.find((d) => d.id === dbId); + const getDb = (id: string) => databases.find((d) => d.id === id); - const isDbConfigured = (dbId: string) => { - const s = dbSettings[dbId]; - return !!( - s && - (s.retention || - s.backupMethod !== undefined || - s.notificationPolicies !== undefined || - s.storagePolicies !== undefined) - ); - }; - - const isSectionConfigured = (dbId: string, section: SectionKind) => { + const isSectionConfigured = (dbId: string, section: SectionKind): boolean => { const s = dbSettings[dbId]; if (!s) return false; switch (section) { - case "retention": return !!s.retention; - case "scheduling": return s.backupMethod !== undefined; - case "notifications": return s.notificationPolicies !== undefined; - case "storage": return s.storagePolicies !== undefined; + case "retention": + return !!s.retention; + case "scheduling": + return s.backupMethod !== undefined; + case "notifications": + return s.notificationPolicies !== undefined; + case "storage": + return s.storagePolicies !== undefined; } }; + const SECTION_KINDS: SectionKind[] = [ + "retention", + "scheduling", + "notifications", + "storage", + ]; + + const isDbConfigured = (dbId: string) => + SECTION_KINDS.some((k) => isSectionConfigured(dbId, k)); + const updateDbSettings = async ( dbId: string, - patch: Partial + patch: Partial, ) => { const updated = { ...dbSettings, [dbId]: { ...(dbSettings[dbId] ?? {}), ...patch }, }; - await updateContext({ flowData: { ...state?.context.flowData, dbSettings: updated } }); + await updateContext({ + flowData: { ...state?.context.flowData, dbSettings: updated }, + }); }; - // ── Phase: grid ──────────────────────────────────────────────────────────── + const handleApplyToAll = async (dbId: string) => { + const settings = dbSettings[dbId] ?? {}; + const otherDbIds = databaseIds.filter((id) => id !== dbId); + const succeededIds: string[] = []; - if (phase.kind === "grid") { + for (const targetId of otherDbIds) { + try { + await applyMutation.mutateAsync({ + databaseId: targetId, + section: "all", + retention: settings.retention, + backupMethod: settings.backupMethod, + backupCron: settings.backupCron, + notificationPolicies: settings.notificationPolicies, + storagePolicies: settings.storagePolicies, + }); + succeededIds.push(targetId); + } catch {} + } + + if (succeededIds.length > 0) { + const updatedSettings = { ...dbSettings }; + succeededIds.forEach((id) => { + updatedSettings[id] = { ...(dbSettings[id] ?? {}), ...settings }; + }); + await updateContext({ + flowData: { ...state?.context.flowData, dbSettings: updatedSettings }, + }); + } + + if (succeededIds.length === otherDbIds.length) { + toast.success("Settings applied to all databases."); + setPhase({ kind: "grid" }); + } else if (succeededIds.length > 0) { + toast.warning( + `Settings applied to ${succeededIds.length} of ${otherDbIds.length} databases.`, + ); + setPhase({ kind: "grid" }); + } + }; + + if (phase.kind === "grid") return ( -
-
-

Configure databases

-

- Optional — configure backup policies for each database. -

-
- -
- {databaseIds.map((dbId) => { - const db = getDb(dbId); - const configured = isDbConfigured(dbId); - return ( - - ); - })} -
- - -
+ setPhase({ kind: "db", dbId })} + onContinue={next} + /> ); - } - - // ── Phase: db ────────────────────────────────────────────────────────────── if (phase.kind === "db") { const { dbId } = phase; - const db = getDb(dbId); - const settings = dbSettings[dbId] ?? {}; - - const configuredCount = ( - ["retention", "scheduling", "notifications", "storage"] as SectionKind[] - ).filter((s) => isSectionConfigured(dbId, s)).length; - - const hasAnyConfigured = configuredCount > 0; - - const handleApplyToAll = async () => { - const otherDbIds = databaseIds.filter((id) => id !== dbId); - const succeededIds: string[] = []; - - for (const targetId of otherDbIds) { - try { - await applyMutation.mutateAsync({ - databaseId: targetId, - section: "all", - retention: settings.retention, - backupMethod: settings.backupMethod, - backupCron: settings.backupCron, - notificationPolicies: settings.notificationPolicies, - storagePolicies: settings.storagePolicies, - }); - succeededIds.push(targetId); - } catch { - // mutation onError already shows a toast - } - } - - if (succeededIds.length > 0) { - const updatedSettings = { ...dbSettings }; - succeededIds.forEach((id) => { - updatedSettings[id] = { ...(dbSettings[id] ?? {}), ...settings }; - }); - await updateContext({ - flowData: { ...state?.context.flowData, dbSettings: updatedSettings }, - }); - } - - if (succeededIds.length === otherDbIds.length) { - toast.success("Settings applied to all databases."); - setPhase({ kind: "grid" }); - } else if (succeededIds.length > 0) { - toast.warning(`Settings applied to ${succeededIds.length} of ${otherDbIds.length} databases.`); - setPhase({ kind: "grid" }); - } - }; - - const SECTIONS: { - kind: SectionKind; - label: string; - icon: React.ReactNode; - }[] = [ - { kind: "retention", label: "Retention Policy", icon: }, - { kind: "scheduling", label: "Scheduling", icon: }, - { kind: "notifications", label: "Notifications", icon: }, - { kind: "storage", label: "Storage", icon: }, - ]; - return ( -
-
-
- -
-

- {db?.name ?? dbId}{" "} - ({db?.engine}) -

- -
- -
- {SECTIONS.map(({ kind, label, icon }) => { - const configured = isSectionConfigured(dbId, kind); - return ( - - ); - })} -
- - {databaseIds.length > 1 && hasAnyConfigured && ( - + isSectionConfigured(dbId, s)} + isMultiDb={databaseIds.length > 1} + hasAnyConfigured={SECTION_KINDS.some((k) => + isSectionConfigured(dbId, k), )} -
+ isApplyingToAll={applyMutation.isPending} + onSelectSection={(section) => + setPhase({ kind: "section", dbId, section }) + } + onApplyToAll={() => handleApplyToAll(dbId)} + onBack={() => setPhase({ kind: "grid" })} + /> ); } - // ── Phase: section ───────────────────────────────────────────────────────── - if (phase.kind === "section") { const { dbId, section } = phase; - const db = getDb(dbId); - const settings = dbSettings[dbId] ?? {}; - - const sectionLabels: Record = { - retention: "Retention Policy", - scheduling: "Scheduling", - notifications: "Notifications", - storage: "Storage", - }; - - const back = () => setPhase({ kind: "db", dbId }); - return ( -
-
-

- {sectionLabels[section]}{" "} - - — {db?.name ?? dbId} - -

- -
- - {section === "retention" && ( - { - await applyMutation.mutateAsync({ - databaseId: dbId, - section: "retention", - retention, - }); - await updateDbSettings(dbId, { retention }); - toast.success("Retention policy saved."); - setPhase({ kind: "db", dbId }); - }} - /> - )} - - {section === "scheduling" && ( - { - await applyMutation.mutateAsync({ - databaseId: dbId, - section: "scheduling", - backupMethod, - backupCron, - }); - await updateDbSettings(dbId, { backupMethod, backupCron }); - toast.success("Schedule saved."); - setPhase({ kind: "db", dbId }); - }} - /> - )} - - {section === "notifications" && ( - { - await applyMutation.mutateAsync({ - databaseId: dbId, - section: "notifications", - notificationPolicies: notificationPolicies as any, - }); - await updateDbSettings(dbId, { notificationPolicies }); - toast.success("Notification policies saved."); - setPhase({ kind: "db", dbId }); - }} - /> - )} - - {section === "storage" && ( - { - await applyMutation.mutateAsync({ - databaseId: dbId, - section: "storage", - storagePolicies, - }); - await updateDbSettings(dbId, { storagePolicies }); - toast.success("Storage policies saved."); - setPhase({ kind: "db", dbId }); - }} - /> - )} -
+ setPhase({ kind: "db", dbId })} + onSaved={() => setPhase({ kind: "db", dbId })} + /> ); } diff --git a/src/features/onboarding/steps/step-defaults.tsx b/src/features/onboarding/steps/step-defaults.tsx index a6f82121..5ef9879f 100644 --- a/src/features/onboarding/steps/step-defaults.tsx +++ b/src/features/onboarding/steps/step-defaults.tsx @@ -1,6 +1,7 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { useState } from "react"; +import { HardDrive } from "lucide-react"; import { useOnboarding } from "@onboardjs/react"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; @@ -33,7 +34,6 @@ export const StepDefaults = () => { existingDefaults.storageId || undefined, ); - const selectNotifier = async (value: string) => { setNotifierId(value); await updateNotificationSettingsAction({ @@ -49,6 +49,16 @@ export const StepDefaults = () => { }; const selectStorage = async (value: string) => { + if (value === "filesystem") { + setStorageId(undefined); + await updateContext({ + flowData: { + ...state?.context.flowData, + defaults: { notifierId, storageId: undefined }, + }, + }); + return; + } setStorageId(value); await updateStorageSettingsAction({ name: "system", @@ -83,7 +93,9 @@ export const StepDefaults = () => {
s.id === storageId) ? storageId : undefined} + value={ + storages.some((s) => s.id === storageId) ? storageId : "filesystem" + } onValueChange={selectStorage} - disabled={storages.length === 0} > - + + +
+ + Filesystem +
+
{storages.map((s) => ( {s.label} diff --git a/src/features/onboarding/steps/step-finish.tsx b/src/features/onboarding/steps/step-finish.tsx index e97ccda7..e238c1d5 100644 --- a/src/features/onboarding/steps/step-finish.tsx +++ b/src/features/onboarding/steps/step-finish.tsx @@ -8,31 +8,33 @@ import { Button } from "@/components/ui/button"; import { useMarkOnboardingDone } from "@/features/onboarding/hooks/use-mark-onboarding-done"; export const StepFinish = () => { - const { next } = useOnboarding(); - const fired = useRef(false); - const mutation = useMarkOnboardingDone(); + const { next } = useOnboarding(); + const fired = useRef(false); + const mutation = useMarkOnboardingDone(); - useEffect(() => { - if (fired.current) return; - fired.current = true; - confetti({ particleCount: 150, spread: 80, origin: { y: 0.6 } }); - }, []); + useEffect(() => { + if (fired.current) return; + fired.current = true; + confetti({ particleCount: 150, spread: 80, origin: { y: 0.6 } }); + }, []); - return ( -
- -

You're all set!

-

Your workspace is ready to use.

- -
- ); + return ( +
+ +

You're all set!

+

+ Your workspace is ready to use. +

+ +
+ ); }; diff --git a/src/features/onboarding/steps/step-invite-members.tsx b/src/features/onboarding/steps/step-invite-members.tsx index fd02f8ff..8104e392 100644 --- a/src/features/onboarding/steps/step-invite-members.tsx +++ b/src/features/onboarding/steps/step-invite-members.tsx @@ -9,55 +9,57 @@ import { X } from "lucide-react"; import { OnboardingMember } from "@/features/onboarding/types"; export const StepInviteMembers = () => { - const { next, updateContext, state } = useOnboarding(); - const [email, setEmail] = useState(""); - const [members, setMembers] = useState([]); + const { next, updateContext, state } = useOnboarding(); + const [email, setEmail] = useState(""); + const [members, setMembers] = useState([]); - const addMember = () => { - if (!email.trim()) return; - setMembers((prev) => [...prev, { email: email.trim(), role: "member" }]); - setEmail(""); - }; + const addMember = () => { + if (!email.trim()) return; + setMembers((prev) => [...prev, { email: email.trim(), role: "member" }]); + setEmail(""); + }; - const removeMember = (target: string) => { - setMembers((prev) => prev.filter((m) => m.email !== target)); - }; + const removeMember = (target: string) => { + setMembers((prev) => prev.filter((m) => m.email !== target)); + }; - const onContinue = async () => { - await updateContext({ flowData: { ...state?.context.flowData, members } }); - await next(); - }; + const onContinue = async () => { + await updateContext({ flowData: { ...state?.context.flowData, members } }); + await next(); + }; - return ( -
-
-

Invite your team

-

Optional — you can always invite people later.

-
-
- setEmail(e.target.value)} - placeholder="teammate@portabase.io" - onKeyDown={(e) => e.key === "Enter" && addMember()} - /> - -
-
- {members.map((member) => ( - - {member.email} - - - ))} -
- -
- ); + return ( +
+
+

Invite your team

+

+ Optional — you can always invite people later. +

+
+
+ setEmail(e.target.value)} + placeholder="teammate@portabase.io" + onKeyDown={(e) => e.key === "Enter" && addMember()} + /> + +
+
+ {members.map((member) => ( + + {member.email} + + + ))} +
+ +
+ ); }; diff --git a/src/features/onboarding/steps/step-login.tsx b/src/features/onboarding/steps/step-login.tsx index 5b69ac51..8a1cbe51 100644 --- a/src/features/onboarding/steps/step-login.tsx +++ b/src/features/onboarding/steps/step-login.tsx @@ -11,215 +11,236 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { PasswordInput } from "@/components/ui/password-input"; import { - useZodForm, - Form, - FormField, - FormItem, - FormLabel, - FormControl, - FormMessage, + useZodForm, + Form, + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, } from "@/components/ui/form"; import { signIn, useSession } from "@/lib/auth/auth-client"; import type { OnboardingMeta } from "@/features/onboarding/types"; const LoginSchema = z.object({ - email: z.string().email("Invalid email"), - password: z.string().min(1, "Password required"), + email: z.email("Invalid email"), + password: z.string().min(1, "Password required"), }); type LoginValues = z.infer; export const StepLogin = () => { - const { next, state } = useOnboarding(); - const router = useRouter(); - const meta = state?.context.flowData.meta as OnboardingMeta | undefined; - const { data: session } = useSession(); + const { next, state } = useOnboarding(); + const router = useRouter(); + const meta = state?.context.flowData.meta as OnboardingMeta | undefined; + const { data: session } = useSession(); - const passkeyEnabled = meta?.passkeyEnabled ?? false; - const emailPasswordEnabled = meta?.emailPasswordEnabled ?? false; - const hasAnySsoProvider = (meta?.ssoProviders?.length ?? 0) > 0; - const hasAnyAuthMethod = emailPasswordEnabled || passkeyEnabled || hasAnySsoProvider; + const passkeyEnabled = meta?.passkeyEnabled ?? false; + const emailPasswordEnabled = meta?.emailPasswordEnabled ?? false; + const hasAnySsoProvider = (meta?.ssoProviders?.length ?? 0) > 0; + const hasAnyAuthMethod = + emailPasswordEnabled || passkeyEnabled || hasAnySsoProvider; - // After login: reload the page so resolveOnboardingState re-runs server-side - // with the authenticated user and returns the correct resume step + flowData. - // Calling next() here would keep the stale unauthenticated flowData. - useEffect(() => { - if (session?.user) { - router.push('/welcome'); - } - }, [session?.user?.id]); - - const form = useZodForm({ schema: LoginSchema }); - - const passkeyMutation = useMutation({ - mutationFn: async () => { - const result = await (signIn as any).passkey(); - if (result?.error) throw new Error(result.error.message ?? "Passkey sign in failed"); - router.push('/welcome'); - }, - onError: (err: Error) => { toast.error(err.message); }, - }); - - const loginMutation = useMutation({ - mutationFn: async (values: LoginValues) => { - const result = await signIn.email({ - email: values.email, - password: values.password, - }); - if (result.error) throw new Error(result.error.message ?? "Sign in failed"); - router.push('/welcome'); - }, - onError: (err: Error) => { toast.error(err.message); }, - }); - - const handleSso = async (providerId: string) => { - try { - await signIn.social({ provider: providerId as any, callbackURL: "/welcome" }); - } catch (err) { - toast.error(err instanceof Error ? err.message : "SSO sign in failed"); - } - }; - - // No auth methods configured - if (!hasAnyAuthMethod) { - return ( -
-
- -
-
-

Configuration needed

-

- Please enable an authentication method in your environment variables to continue. -

-
-
-

Set at least one of these:

-
    -
  • AUTH_EMAIL_PASSWORD_ENABLED=true
  • -
  • AUTH_PASSKEY_ENABLED=true
  • -
  • • Or configure an SSO provider
  • -
-
-
- ); + useEffect(() => { + if (session?.user) { + router.push("/welcome"); } + }, [session?.user?.id]); - // New user registration flow - if (!meta?.hasExistingUsers) { - return ( -
-
-

Welcome to Portabase

-

- Set up your instance by creating the first account. -

-
- {hasAnySsoProvider && ( -
- {meta?.ssoProviders.map((provider) => ( - - ))} -
- )} - {(emailPasswordEnabled || passkeyEnabled) && ( - - )} -
- ); - } + const form = useZodForm({ schema: LoginSchema }); - // Existing user login flow + const passkeyMutation = useMutation({ + mutationFn: async () => { + const result = await (signIn as any).passkey(); + if (result?.error) + throw new Error(result.error.message ?? "Passkey sign in failed"); + router.push("/welcome"); + }, + onError: (err: Error) => { + toast.error(err.message); + }, + }); + + const loginMutation = useMutation({ + mutationFn: async (values: LoginValues) => { + const result = await signIn.email({ + email: values.email, + password: values.password, + }); + if (result.error) + throw new Error(result.error.message ?? "Sign in failed"); + router.push("/welcome"); + }, + onError: (err: Error) => { + toast.error(err.message); + }, + }); + + const handleSso = async (providerId: string) => { + const result = await signIn.social({ + provider: providerId as any, + callbackURL: "/welcome", + }); + if (result?.error) + toast.error(result.error.message ?? "SSO sign in failed"); + }; + + if (!hasAnyAuthMethod) { return ( -
-
-

Welcome back

-

- {meta?.defaultUserMode - ? "Sign in to continue onboarding." - : "Your session expired. Sign in to continue where you left off."} -

-
- {hasAnySsoProvider && !meta?.defaultUserMode && ( -
- {meta?.ssoProviders.map((provider) => ( - - ))} -
- )} - {passkeyEnabled && ( - - )} - {emailPasswordEnabled && ( -
loginMutation.mutateAsync(values)} - > - ( - - Email - - - - - - )} - /> - ( - - Password - - - - - - )} - /> - - - )} +
+
+
+
+

Configuration needed

+

+ Please enable an authentication method in your environment variables + to continue. +

+
+
+

Set at least one of these:

+
    +
  • + •{" "} + + AUTH_EMAIL_PASSWORD_ENABLED=true + +
  • +
  • + •{" "} + + AUTH_PASSKEY_ENABLED=true + +
  • +
  • • Or configure an SSO provider
  • +
+
+
); + } + + if (!meta?.hasExistingUsers) { + return ( +
+
+

Welcome to Portabase

+

+ Set up your instance by creating the first account. +

+
+ {hasAnySsoProvider && ( +
+ {meta?.ssoProviders.map((provider) => ( + + ))} +
+ )} + {(emailPasswordEnabled || passkeyEnabled) && ( + + )} +
+ ); + } + + return ( +
+
+

Welcome back

+

+ {meta?.defaultUserMode + ? "Sign in to continue onboarding." + : "Your session expired. Sign in to continue where you left off."} +

+
+ {hasAnySsoProvider && !meta?.defaultUserMode && ( +
+ {meta?.ssoProviders.map((provider) => ( + + ))} +
+ )} + {passkeyEnabled && ( + + )} + {emailPasswordEnabled && ( +
loginMutation.mutateAsync(values)} + > + ( + + Email + + + + + + )} + /> + ( + + Password + + + + + + )} + /> + + + )} +
+ ); }; diff --git a/src/features/onboarding/steps/step-notifier.tsx b/src/features/onboarding/steps/step-notifier.tsx index 7d3ab79e..4f0f77cf 100644 --- a/src/features/onboarding/steps/step-notifier.tsx +++ b/src/features/onboarding/steps/step-notifier.tsx @@ -1,4 +1,3 @@ -// src/features/onboarding/steps/step-notifier.tsx "use client"; import { useState } from "react"; @@ -26,7 +25,8 @@ type Phase = { kind: "grid" } | { kind: "configuring"; provider: string }; export const StepNotifier = () => { const { next, updateContext, state } = useOnboarding(); - const notifiers = (state?.context.flowData.notifiers ?? []) as OnboardingChannel[]; + const notifiers = (state?.context.flowData.notifiers ?? + []) as OnboardingChannel[]; const [phase, setPhase] = useState({ kind: "grid" }); const form = useZodForm({ schema: NotificationChannelFormSchema }); @@ -40,12 +40,16 @@ export const StepNotifier = () => { }; const onContinue = async () => { - await updateContext({ flowData: { ...state?.context.flowData, notifiers } }); + await updateContext({ + flowData: { ...state?.context.flowData, notifiers }, + }); await next(); }; if (phase.kind === "configuring") { - const providerDetails = notificationProviders.find((p) => p.value === phase.provider); + const providerDetails = notificationProviders.find( + (p) => p.value === phase.provider, + ); const Icon = providerDetails?.icon; return ( @@ -56,8 +60,15 @@ export const StepNotifier = () => {
)} -

Configuring {providerDetails?.label}

- @@ -66,7 +77,9 @@ export const StepNotifier = () => { form={form} className="flex flex-col gap-4" onSubmit={async (values: any) => { - const details = notificationProviders.find((p) => p.value === values.provider); + const details = notificationProviders.find( + (p) => p.value === values.provider, + ); addNotifier.mutate( { provider: values.provider, @@ -131,7 +144,9 @@ export const StepNotifier = () => { {notifiers.length > 0 && (
{notifiers.map((ch) => { - const details = notificationProviders.find((p) => p.value === ch.provider); + const details = notificationProviders.find( + (p) => p.value === ch.provider, + ); const Icon = details?.icon; return (
{ {provider.label} {isConfigured && (
- +
)} diff --git a/src/features/onboarding/steps/step-org-create.tsx b/src/features/onboarding/steps/step-org-create.tsx index 0e0a69c2..7601b716 100644 --- a/src/features/onboarding/steps/step-org-create.tsx +++ b/src/features/onboarding/steps/step-org-create.tsx @@ -8,41 +8,45 @@ import { Button } from "@/components/ui/button"; import { useCreateOrg } from "@/features/onboarding/hooks/use-create-org"; export const StepOrgCreate = () => { - const { state } = useOnboarding(); - const existingOrg = state?.context.flowData.org; - const isEditMode = !!existingOrg; - const [name, setName] = useState(existingOrg?.name ?? ""); + const { state } = useOnboarding(); + const existingOrg = state?.context.flowData.org; + const isEditMode = !!existingOrg; + const [name, setName] = useState(existingOrg?.name ?? ""); - const mutation = useCreateOrg(); + const mutation = useCreateOrg(); - return ( -
-
-

- {isEditMode ? "Edit your organisation" : "Create your organisation"} -

-

- {isEditMode ? "Rename your organisation." : "This step can't be skipped."} -

-
-
- - setName(e.target.value)} - placeholder="Acme Inc." - /> -
- -
- ); + return ( +
+
+

+ {isEditMode ? "Edit your organisation" : "Create your organisation"} +

+

+ {isEditMode + ? "Rename your organisation." + : "This step can't be skipped."} +

+
+
+ + setName(e.target.value)} + placeholder="Acme Inc." + /> +
+ +
+ ); }; diff --git a/src/features/onboarding/steps/step-preferences.tsx b/src/features/onboarding/steps/step-preferences.tsx index 05e9f85a..00eab8b0 100644 --- a/src/features/onboarding/steps/step-preferences.tsx +++ b/src/features/onboarding/steps/step-preferences.tsx @@ -49,9 +49,7 @@ export const StepPreferences = () => { }; const selectTheme = async (theme: ThemeKey) => { - // Apply immediately to the UI setTheme(theme); - // Persist to DB so it survives page reload await authClient.updateUser({ theme }); await updateContext({ flowData: { @@ -62,7 +60,6 @@ export const StepPreferences = () => { }; const onContinue = async () => { - // Save avatar to user profile if selected if (selectedAvatarUrl) { await authClient.updateUser({ image: selectedAvatarUrl }); } @@ -85,7 +82,6 @@ export const StepPreferences = () => {

Avatar

- {/* Remplacement de la grid par un flex avec wrap */}
{AVATAR_COLORS.map((c) => { const url = `/api/avatar?initials=${initials}&color=${encodeURIComponent(c.hex)}`; @@ -96,7 +92,6 @@ export const StepPreferences = () => { type="button" onClick={() => selectAvatar(c.hex)} className={cn( - // Ajout de shrink-0 pour éviter toute déformation "rounded-full overflow-hidden transition-all shrink-0", isSelected ? "ring-2 ring-primary ring-offset-2 ring-offset-background" diff --git a/src/features/onboarding/steps/step-storage.tsx b/src/features/onboarding/steps/step-storage.tsx index 1b63e1b4..ce8db4ce 100644 --- a/src/features/onboarding/steps/step-storage.tsx +++ b/src/features/onboarding/steps/step-storage.tsx @@ -1,4 +1,3 @@ -// src/features/onboarding/steps/step-storage.tsx "use client"; import { useState } from "react"; @@ -26,7 +25,8 @@ type Phase = { kind: "grid" } | { kind: "configuring"; provider: string }; export const StepStorage = () => { const { next, updateContext, state } = useOnboarding(); - const storages = (state?.context.flowData.storages ?? []) as OnboardingChannel[]; + const storages = (state?.context.flowData.storages ?? + []) as OnboardingChannel[]; const [phase, setPhase] = useState({ kind: "grid" }); const form = useZodForm({ schema: StorageChannelFormSchema }); @@ -45,7 +45,9 @@ export const StepStorage = () => { }; if (phase.kind === "configuring") { - const providerDetails = storageProviders.find((p) => p.value === phase.provider); + const providerDetails = storageProviders.find( + (p) => p.value === phase.provider, + ); const Icon = providerDetails?.icon; return ( @@ -56,8 +58,15 @@ export const StepStorage = () => {
)} -

Configuring {providerDetails?.label}

- @@ -66,7 +75,9 @@ export const StepStorage = () => { form={form} className="flex flex-col gap-4" onSubmit={async (values: any) => { - const details = storageProviders.find((p) => p.value === values.provider); + const details = storageProviders.find( + (p) => p.value === values.provider, + ); addStorage.mutate( { provider: values.provider, @@ -116,7 +127,9 @@ export const StepStorage = () => { ); } - const availableProviders = storageProviders.filter((p) => !p.preview && p.value !== "local"); + const availableProviders = storageProviders.filter( + (p) => !p.preview && p.value !== "local", + ); const configuredProviderIds = storages.map((c) => c.provider); return ( @@ -131,7 +144,9 @@ export const StepStorage = () => { {storages.length > 0 && (
{storages.map((ch) => { - const details = storageProviders.find((p) => p.value === ch.provider); + const details = storageProviders.find( + (p) => p.value === ch.provider, + ); const Icon = details?.icon; return (
{ {provider.label} {isConfigured && (
- +
)} diff --git a/src/features/onboarding/types/index.ts b/src/features/onboarding/types/index.ts index 1dbf36fa..e8920597 100644 --- a/src/features/onboarding/types/index.ts +++ b/src/features/onboarding/types/index.ts @@ -62,9 +62,24 @@ export type OnboardingDatabase = { engine: "postgres" | "mysql" | "mongodb"; }; +export type SectionKind = + | "retention" + | "scheduling" + | "notifications" + | "storage"; + +export type EventKind = + | "error_backup" + | "error_restore" + | "success_restore" + | "success_backup" + | "weekly_report" + | "error_health_agent" + | "error_health_database"; + export type OnboardingNotificationPolicy = { channelId: string; - eventKinds: string[]; + eventKinds: EventKind[]; enabled: boolean; }; diff --git a/src/features/onboarding/utils/.gitkeep b/src/features/onboarding/utils/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/src/lib/auth/auth.ts b/src/lib/auth/auth.ts index d3958c16..8610aa12 100644 --- a/src/lib/auth/auth.ts +++ b/src/lib/auth/auth.ts @@ -19,6 +19,7 @@ import EmailNewLogin from "@/components/emails/auth/email-new-login"; import {sso} from "@better-auth/sso"; import {SUPPORTED_PROVIDERS} from "@/lib/auth/config"; import {passkey} from "@better-auth/passkey"; +import {verifyPasskeyContext} from "@/lib/auth/passkey-context"; import {getOidcProviders} from "./oidc"; import {APIError} from "better-auth/api"; import {getOAuthProviders} from "./oauth"; @@ -290,6 +291,36 @@ export const auth = betterAuth({ rpID: env.PROJECT_URL ? new URL(env.PROJECT_URL).hostname : "localhost", + registration: { + requireSession: false, + resolveUser: async ({ ctx, context }) => { + const session = (ctx as any).context?.session; + if (session?.user?.id) { + return { + id: session.user.id, + name: session.user.name || session.user.email, + displayName: session.user.email, + }; + } + if (!context) throw new APIError("BAD_REQUEST", { message: "Passkey context required" }); + const payload = verifyPasskeyContext(context); + if (!payload) throw new APIError("BAD_REQUEST", { message: "Invalid passkey context" }); + const [existing] = await db + .select() + .from(drizzleDb.schemas.user) + .where(eq(drizzleDb.schemas.user.email, payload.email)) + .limit(1); + if (existing) { + return { id: existing.id, name: existing.name || payload.name, displayName: payload.email }; + } + const newUser = await (ctx as any).context.internalAdapter.createUser({ + name: payload.name, + email: payload.email, + emailVerified: true, + }); + return { id: newUser.id, name: payload.name, displayName: payload.email }; + }, + }, }), ] : []), diff --git a/src/lib/auth/passkey-context.ts b/src/lib/auth/passkey-context.ts new file mode 100644 index 00000000..d8a6c388 --- /dev/null +++ b/src/lib/auth/passkey-context.ts @@ -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 }; +}