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