This commit is contained in:
Théo LAGACHE
2026-06-22 16:56:17 +02:00
parent bd157e7d49
commit fd41f7ea50
95 changed files with 9511 additions and 3458 deletions
+3
View File
@@ -81,3 +81,6 @@ TRUSTED_DOMAINS="http://localhost:8887, http://localhost:3055, http://localhost:
# Default to false # Default to false
#TUSD_BEHIND_PROXY=true #TUSD_BEHIND_PROXY=true
#
#SKIP_ONBOARDING=false
+3 -2
View File
@@ -1,8 +1,9 @@
import React from "react"; import React from "react";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { currentUser } from "@/lib/auth/current-user"; import { currentUser } from "@/lib/auth/current-user";
import { isOnboardingDone } from "@/features/onboarding/is-onboarding-done"; import { isOnboardingDone } from "@/db/services/setting";
import { AuthLogoSection } from "@/features/auth/auth-logo-section"; import { AuthLogoSection } from "@/features/auth/auth-logo-section";
import { env } from "@/env.mjs";
import { Heart } from "lucide-react"; import { Heart } from "lucide-react";
export default async function Layout({ export default async function Layout({
@@ -10,7 +11,7 @@ export default async function Layout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
if (!(await isOnboardingDone())) { if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) {
redirect("/welcome"); redirect("/welcome");
} }
@@ -5,17 +5,24 @@ import {desc, isNull} from "drizzle-orm";
import {AdminUserList} from "@/features/users/admin-user-list"; import {AdminUserList} from "@/features/users/admin-user-list";
import {AdminUserAddModal} from "@/features/users/admin-user-add-modal"; import {AdminUserAddModal} from "@/features/users/admin-user-add-modal";
import {SUPPORTED_PROVIDERS} from "@/lib/auth/config"; import {SUPPORTED_PROVIDERS} from "@/lib/auth/config";
import {getSettings} from "@/db/services/setting";
import {resolveAvatarUrl} from "@/utils/resolve-avatar-url";
export default async function RoutePage(props: PageParams<{}>) { export default async function RoutePage(props: PageParams<{}>) {
const users = await db.query.user.findMany({ const [settings, users] = await Promise.all([
where: (fields) => isNull(fields.deletedAt), getSettings(),
with: { db.query.user.findMany({
accounts: true where: (fields) => isNull(fields.deletedAt),
}, with: { accounts: true },
orderBy: (fields) => desc(fields.createdAt), orderBy: (fields) => desc(fields.createdAt),
}),
]);
const avatarUrls = Object.fromEntries(
users.map((u) => [u.id, resolveAvatarUrl(u, settings)])
);
});
const organizations = await db.query.organization.findMany({ const organizations = await db.query.organization.findMany({
with: { with: {
members: true, members: true,
@@ -38,7 +45,7 @@ export default async function RoutePage(props: PageParams<{}>) {
</div> </div>
</PageHeader> </PageHeader>
<PageContent className="flex flex-col gap-5"> <PageContent className="flex flex-col gap-5">
<AdminUserList users={users} isPasswordAuthEnabled={isPasswordAuthEnabled}/> <AdminUserList users={users} isPasswordAuthEnabled={isPasswordAuthEnabled} avatarUrls={avatarUrls}/>
</PageContent> </PageContent>
</Page> </Page>
); );
@@ -1,122 +1,137 @@
import {PageParams} from "@/types/next"; import { PageParams } from "@/types/next";
import {notFound, redirect} from "next/navigation"; import { notFound, redirect } from "next/navigation";
import {Page} from "@/features/layout/page"; import { Page } from "@/features/layout/page";
import {db} from "@/db"; import { db } from "@/db";
import {eq, and, inArray} from "drizzle-orm"; import { eq, and, inArray } from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {getOrganizationProjectDatabases} from "@/db/services/project"; import { getOrganizationProjectDatabases } from "@/db/services/project";
import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import { getActiveMember, getOrganization } from "@/lib/auth/auth";
import {BackupModalProvider} from "@/features/database/backup-modal-context"; import { BackupModalProvider } from "@/features/database/backup-modal-context";
import {DatabaseContent} from "@/features/database/database-content"; import { DatabaseContent } from "@/features/database/database-content";
import {getHealthLast12hLogs} from "@/db/services/healthcheck"; import { getHealthLast12hLogs } from "@/db/services/healthcheck";
import {LogsModalProvider} from "@/features/logs/logs-modal-context"; import { LogsModalProvider } from "@/features/logs/logs-modal-context";
export default async function RoutePage(props: PageParams<{ export default async function RoutePage(
props: PageParams<{
projectId: string; projectId: string;
databaseId: string databaseId: string;
}>) { }>,
const {projectId, databaseId} = await props.params; ) {
const { projectId, databaseId } = await props.params;
const organization = await getOrganization({}); const organization = await getOrganization({});
const activeMember = await getActiveMember() const activeMember = await getActiveMember();
if (!organization || !activeMember) { if (!organization || !activeMember) {
notFound(); notFound();
} }
const databasesProject = await getOrganizationProjectDatabases({ const databasesProject = await getOrganizationProjectDatabases({
organizationSlug: organization.slug, organizationSlug: organization.slug,
projectId: projectId projectId: projectId,
}) });
const dbItem = await db.query.database.findFirst({ const dbItem = await db.query.database.findFirst({
where: and(inArray(drizzleDb.schemas.backup.id, databasesProject.ids ?? []), eq(drizzleDb.schemas.database.id, databaseId), eq(drizzleDb.schemas.database.projectId, projectId)), where: and(
inArray(drizzleDb.schemas.backup.id, databasesProject.ids ?? []),
eq(drizzleDb.schemas.database.id, databaseId),
eq(drizzleDb.schemas.database.projectId, projectId),
),
with: {
project: true,
retentionPolicy: true,
alertPolicies: true,
storagePolicies: true,
},
});
if (!dbItem) {
redirect("/dashboard/projects");
}
const backups = await db.query.backup.findMany({
where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
with: {
restorations: true,
storages: {
with: { with: {
project: true, storageChannel: true,
retentionPolicy: true,
alertPolicies: true,
storagePolicies: true,
}
});
if (!dbItem) {
redirect("/dashboard/projects");
}
const backups = await db.query.backup.findMany({
where: eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
with: {
restorations: true,
storages: {
with: {
storageChannel: true
}
},
logs: true
}, },
orderBy: (b, {desc}) => [desc(b.createdAt)], },
}); logs: true,
},
orderBy: (b, { desc }) => [desc(b.createdAt)],
});
const restorations = await db.query.restoration.findMany({ const restorations = await db.query.restoration.findMany({
where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id), where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
with: { with: {
logs: true logs: true,
}, },
orderBy: (r, {desc}) => [desc(r.createdAt)], orderBy: (r, { desc }) => [desc(r.createdAt)],
}); });
const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing"); //const isAlreadyBackup = backups.some((b) => b.status === "waiting" || b.status === "ongoing");
const isAlreadyRestore = restorations.some((r) => r.status === "waiting"); const isAlreadyRestore = restorations.some((r) => r.status === "waiting");
const totalBackups = await db.select({count: drizzleDb.schemas.backup.id}) const totalBackups = await db
.from(drizzleDb.schemas.backup) .select({ count: drizzleDb.schemas.backup.id })
.where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id)) .from(drizzleDb.schemas.backup)
.then(rows => rows.length); .where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id))
.then((rows) => rows.length);
const availableBackups = backups.filter(b => !b.deletedAt).length; const availableBackups = backups.filter((b) => !b.deletedAt).length;
const successfulBackups = await db.select({count: drizzleDb.schemas.backup.id}) const successfulBackups = await db
.from(drizzleDb.schemas.backup) .select({ count: drizzleDb.schemas.backup.id })
.where(and( .from(drizzleDb.schemas.backup)
eq(drizzleDb.schemas.backup.databaseId, dbItem.id), .where(
eq(drizzleDb.schemas.backup.status, "success") and(
)) eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
.then(rows => rows.length); eq(drizzleDb.schemas.backup.status, "success"),
),
)
.then((rows) => rows.length);
const [settings] = await db
.select()
.from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.limit(1);
if (!settings) {
notFound();
}
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1); const databaseHealthLogs = dbItem
if (!settings) { ? await getHealthLast12hLogs({ id: dbItem.id })
notFound(); : [];
}
const databaseHealthLogs = dbItem ? await getHealthLast12hLogs({id: dbItem.id}) : [] const successRate =
totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
//const isMember = activeMember?.role === "member";
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null; return (
<Page>
const isMember = activeMember?.role === "member"; <LogsModalProvider>
<BackupModalProvider>
return ( <DatabaseContent
<Page> activeMember={activeMember}
<LogsModalProvider> settings={settings}
<BackupModalProvider> database={dbItem}
<DatabaseContent databaseHealthLogs={databaseHealthLogs}
activeMember={activeMember} isAlreadyRestore={isAlreadyRestore}
settings={settings} restorations={restorations}
database={dbItem} backups={backups}
databaseHealthLogs={databaseHealthLogs} totalBackups={totalBackups}
isAlreadyRestore={isAlreadyRestore} availableBackups={availableBackups}
restorations={restorations} successRate={successRate}
backups={backups} organizationId={organization.id}
totalBackups={totalBackups} activeOrganizationChannels={[]}
availableBackups={availableBackups} activeOrganizationStorageChannels={[]}
successRate={successRate} />
organizationId={organization.id} </BackupModalProvider>
activeOrganizationChannels={[]} </LogsModalProvider>
activeOrganizationStorageChannels={[]} </Page>
/> );
</BackupModalProvider> }
</LogsModalProvider>
</Page>
);
}
@@ -1,108 +1,115 @@
import {PageParams} from "@/types/next"; import { PageParams } from "@/types/next";
import {Page, PageContent, PageTitle} from "@/features/layout/page"; import { Page, PageContent, PageTitle } from "@/features/layout/page";
import { import { ButtonDeleteProject } from "@/features/projects/project-delete-button";
ButtonDeleteProject import { CardsWithPagination } from "@/components/common/cards-with-pagination";
} from "@/features/projects/project-delete-button"; import { ProjectDatabaseCard } from "@/features/projects/project-database-card";
import {CardsWithPagination} from "@/components/common/cards-with-pagination"; import { notFound, redirect } from "next/navigation";
import {ProjectDatabaseCard} from "@/features/projects/project-database-card"; import { db } from "@/db";
import {notFound, redirect} from "next/navigation"; import { eq } from "drizzle-orm";
import {db} from "@/db"; import { getActiveMember, getOrganization } from "@/lib/auth/auth";
import {eq} from "drizzle-orm";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {capitalizeFirstLetter} from "@/utils/text"; import { capitalizeFirstLetter, isUUID } from "@/utils/text";
import {ProjectDialog} from "@/features/projects/project-dialog"; import { ProjectDialog } from "@/features/projects/project-dialog";
import {ProjectWith} from "@/db/schema/06_project"; import { ProjectWith } from "@/db/schema/06_project";
import {isUuidv4} from "@/utils/verify-uuid"; import { getOrganizationAvailableDatabases } from "@/db/services/database";
import {getOrganizationAvailableDatabases} from "@/db/services/database";
export default async function RoutePage(
props: PageParams<{
projectId: string;
}>,
) {
const { projectId } = await props.params;
export default async function RoutePage(props: PageParams<{ if (!isUUID(projectId)) {
projectId: string notFound();
}>) { }
const {
projectId
} = await props.params;
if (!isUuidv4(projectId)) { const organization = await getOrganization({});
notFound() const activeMember = await getActiveMember();
}
const organization = await getOrganization({}); if (!organization) {
const activeMember = await getActiveMember() notFound();
}
const org = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, organization.slug),
});
if (!organization) { if (!org) notFound();
notFound();
}
const org = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.slug, organization.slug),
});
if (!org) notFound(); const proj = await db.query.project.findFirst({
where: (proj, { and, eq, not }) =>
and(
eq(proj.id, projectId),
eq(proj.organizationId, org.id),
not(eq(proj.isArchived, true)),
),
with: {
databases: true,
},
});
const proj = await db.query.project.findFirst({ if (!proj) {
where: (proj, { redirect("/dashboard/projects");
and, }
eq,
not
}) => and(eq(proj.id, projectId), eq(proj.organizationId, org.id), not(eq(proj.isArchived, true))),
with: {
databases: true,
},
});
if (!proj) { const availableDatabases = await getOrganizationAvailableDatabases(
redirect("/dashboard/projects"); organization.id,
} proj.id,
);
const isMember = activeMember?.role === "member";
const availableDatabases = await getOrganizationAvailableDatabases(organization.id, proj.id) return (
const isMember = activeMember?.role === "member"; <Page>
<div className="justify-between gap-2 sm:flex">
return ( <PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<Page> <div className="min-w-full md:min-w-fit ">
<div className="justify-between gap-2 sm:flex"> {capitalizeFirstLetter(proj.name)}
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full "> </div>
<div className="min-w-full md:min-w-fit "> {!isMember && (
{capitalizeFirstLetter(proj.name)} <div className="flex items-center gap-2 md:justify-between w-full ">
</div> <div className="flex items-center gap-2">
{!isMember && ( <ProjectDialog
<div className="flex items-center gap-2 md:justify-between w-full "> databases={availableDatabases}
<div className="flex items-center gap-2"> organization={org}
<ProjectDialog project={proj as ProjectWith}
databases={availableDatabases} isEdit={true}
organization={org} />
project={proj as ProjectWith} </div>
isEdit={true} <div className="flex items-center gap-2">
/> <ButtonDeleteProject
</div> projectId={projectId}
<div className="flex items-center gap-2"> text={"Delete Project"}
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/> />
</div> </div>
</div>
)}
</PageTitle>
</div> </div>
<PageContent className="flex flex-col w-full h-full"> )}
{proj.databases.length > 0 ? ( </PageTitle>
<CardsWithPagination </div>
data={[...proj.databases].sort((a, b) => <PageContent className="flex flex-col w-full h-full">
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() {proj.databases.length > 0 ? (
)} <CardsWithPagination
organizationSlug={organization.slug} data={[...proj.databases].sort(
// @ts-ignore (a, b) =>
cardItem={ProjectDatabaseCard} new Date(b.createdAt).getTime() -
cardsPerPage={20} new Date(a.createdAt).getTime(),
numberOfColumns={3} )}
pageSizeOptions={[10, 20, 50]} organizationSlug={organization.slug}
extendedProps={proj} // @ts-ignore
/> cardItem={ProjectDatabaseCard}
) : ( cardsPerPage={20}
<div className="flex flex-col items-center justify-center h-full text-muted-foreground py-20"> numberOfColumns={3}
<p className="text-lg font-medium">No databases found</p> pageSizeOptions={[10, 20, 50]}
<p className="text-sm mt-2">You havent added any databases to this project yet.</p> extendedProps={proj}
</div> />
)} ) : (
</PageContent> <div className="flex flex-col items-center justify-center h-full text-muted-foreground py-20">
</Page> <p className="text-lg font-medium">No databases found</p>
); <p className="text-sm mt-2">
} You havent added any databases to this project yet.
</p>
</div>
)}
</PageContent>
</Page>
);
}
+3 -2
View File
@@ -5,12 +5,13 @@ import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
import { AppSidebar } from "@/features/layout/app-sidebar"; import { AppSidebar } from "@/features/layout/app-sidebar";
import { Header } from "@/features/layout/header"; import { Header } from "@/features/layout/header";
import { currentUser } from "@/lib/auth/current-user"; import { currentUser } from "@/lib/auth/current-user";
import { isOnboardingDone } from "@/features/onboarding/is-onboarding-done"; import { isOnboardingDone } from "@/db/services/setting";
import { env } from "@/env.mjs";
import { ModeToggle } from "@/features/theme/mode-toggle"; import { ModeToggle } from "@/features/theme/mode-toggle";
import { UpdateNotification } from "@/features/updates/update-notification"; import { UpdateNotification } from "@/features/updates/update-notification";
export default async function Layout({ children }: { children: ReactNode }) { export default async function Layout({ children }: { children: ReactNode }) {
if (!(await isOnboardingDone())) { if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) {
redirect("/welcome"); redirect("/welcome");
} }
+3 -2
View File
@@ -1,10 +1,11 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getCurrentOrganizationSlug } from "@/features/organizations/organization-cookie"; import { getCurrentOrganizationSlug } from "@/features/organizations/organization-cookie";
import { currentUser } from "@/lib/auth/current-user"; import { currentUser } from "@/lib/auth/current-user";
import { isOnboardingDone } from "@/features/onboarding/is-onboarding-done"; import { isOnboardingDone } from "@/db/services/setting";
import { env } from "@/env.mjs";
export default async function Index() { export default async function Index() {
if (!(await isOnboardingDone())) { if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) {
redirect("/welcome"); redirect("/welcome");
} }
+1
View File
@@ -11,6 +11,7 @@ export default async function WelcomePage() {
return ( return (
<OnboardingClient <OnboardingClient
key={result.stepId}
initialStepId={result.stepId} initialStepId={result.stepId}
initialFlowData={result.flowData} initialFlowData={result.flowData}
/> />
@@ -1,79 +1,86 @@
import {NextResponse} from "next/server"; import { NextResponse } from "next/server";
import {and, eq} from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db} from "@/db"; import { db } from "@/db";
import {getDatabaseOrThrow, withAgentCheck} from "../../helpers"; import { getDatabaseOrThrow, withAgentCheck } from "../../helpers";
import {isUuidv4} from "@/utils/verify-uuid"; import { eventEmitter } from "@/lib/event";
import {eventEmitter} from "@/lib/event"; import { logger } from "@/lib/logger";
import {logger} from "@/lib/logger"; import { isUUID } from "@/utils/text";
const log = logger.child({module: "api/agent/backup/upload/init"}); const log = logger.child({ module: "api/agent/backup/upload/init" });
export type Body = { export type Body = {
generatedId: string generatedId: string;
storageChannelId: string storageChannelId: string;
backupId: string backupId: string;
} };
export const POST = withAgentCheck(async (request: Request, {params, agent}: { export const POST = withAgentCheck(
params: Promise<{ agentId: string }>, async (
agent: any request: Request,
}) => { {
params,
agent,
}: {
params: Promise<{ agentId: string }>;
agent: any;
},
) => {
try { try {
const body: Body = await request.json(); const body: Body = await request.json();
log.info({data: body}, "Body for backup upload init"); log.info({ data: body }, "Body for backup upload init");
const generatedId = body.generatedId; const generatedId = body.generatedId;
const storageChannelId = body.storageChannelId; const storageChannelId = body.storageChannelId;
const backupId = body.backupId; const backupId = body.backupId;
if (!generatedId || !isUuidv4(generatedId)) {
return NextResponse.json(
{error: "generatedId is not a valid UUID"},
{status: 400}
);
}
const database = await getDatabaseOrThrow(generatedId);
const backup = await db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.id, backupId),
eq(drizzleDb.schemas.backup.databaseId, database.id),
),
});
if (!backup) {
return NextResponse.json(
{error: "Unable to find the corresponding backup"},
{status: 404}
);
}
const [backupStorage] = await db
.insert(drizzleDb.schemas.backupStorage)
.values({
backupId: backup.id,
storageChannelId: storageChannelId,
status: "pending",
})
.returning();
eventEmitter.emit('modification', {update: true});
if (!generatedId || !isUUID(generatedId)) {
return NextResponse.json( return NextResponse.json(
{ { error: "generatedId is not a valid UUID" },
message: "Backup storage successfully created", { status: 400 },
backupStorage: backupStorage
},
{status: 200}
); );
}
const database = await getDatabaseOrThrow(generatedId);
const backup = await db.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.id, backupId),
eq(drizzleDb.schemas.backup.databaseId, database.id),
),
});
if (!backup) {
return NextResponse.json(
{ error: "Unable to find the corresponding backup" },
{ status: 404 },
);
}
const [backupStorage] = await db
.insert(drizzleDb.schemas.backupStorage)
.values({
backupId: backup.id,
storageChannelId: storageChannelId,
status: "pending",
})
.returning();
eventEmitter.emit("modification", { update: true });
return NextResponse.json(
{
message: "Backup storage successfully created",
backupStorage: backupStorage,
},
{ status: 200 },
);
} catch (error) { } catch (error) {
log.error({error: error}, "Error in POST for INIT backup"); log.error({ error: error }, "Error in POST for INIT backup");
return NextResponse.json( return NextResponse.json(
{error: "Internal server error"}, { error: "Internal server error" },
{status: 500} { status: 500 },
); );
} }
}); },
);
+115 -101
View File
@@ -1,110 +1,124 @@
import {NextResponse} from "next/server"; import { NextResponse } from "next/server";
import {isUuidv4} from "@/utils/verify-uuid";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db as dbClient, db} from "@/db"; import { db as dbClient, db } from "@/db";
import {and, eq} from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import {sendNotificationsBackupRestore} from "@/features/notifications/notifications.helpers"; import { sendNotificationsBackupRestore } from "@/features/notifications/notifications.helpers";
import {logger} from "@/lib/logger"; import { logger } from "@/lib/logger";
import {withUpdatedAt} from "@/db/utils"; import { withUpdatedAt } from "@/db/utils";
import {JobLogEntry} from "@/features/logs/types"; import { JobLogEntry } from "@/features/logs/types";
import { isUUID } from "@/utils/text";
const log = logger.child({module: "api/agent/restore"}); const log = logger.child({ module: "api/agent/restore" });
export type BodyResultRestore = { export type BodyResultRestore = {
generatedId: string generatedId: string;
status: string status: string;
logs: JobLogEntry[] logs: JobLogEntry[];
durationMs: number durationMs: number;
} };
type RestorationStatus = 'waiting' | 'ongoing' | 'failed' | 'success'; type RestorationStatus = "waiting" | "ongoing" | "failed" | "success";
export async function POST( export async function POST(
request: Request, request: Request,
{params}: { params: Promise<{ agentId: string }> } { params }: { params: Promise<{ agentId: string }> },
) { ) {
try {
const agentId = (await params).agentId;
const body: BodyResultRestore = await request.json();
try { if (!isUUID(body.generatedId)) {
return NextResponse.json(
const agentId = (await params).agentId { error: "generatedId is not a valid uuid" },
const body: BodyResultRestore = await request.json(); { status: 500 },
);
if (!isUuidv4(body.generatedId)) {
return NextResponse.json(
{error: "generatedId is not a valid uuid"},
{status: 500}
);
}
const agent = await db.query.agent.findFirst({
where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)),
})
if (!agent) {
return NextResponse.json({error: "Agent not found"}, {status: 404})
}
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, body.generatedId),
with: {
alertPolicies: true
}
})
if (!database) {
return NextResponse.json({error: "Database associated with generatedId provided not found"}, {status: 404})
}
const restoration = await db.query.restoration.findFirst({
where: and(eq(drizzleDb.schemas.restoration.status, "ongoing"), eq(drizzleDb.schemas.restoration.databaseId, database.id),)
})
if (!restoration) {
return NextResponse.json({error: "Unable to fin the corresponding restoration"}, {status: 404})
}
const [restorationUpdated] = await db
.update(drizzleDb.schemas.restoration)
.set(withUpdatedAt({status: body.status as RestorationStatus, durationMs: body.durationMs}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id)).returning();
const logsToInsert = body.logs.map((entry) => ({
backupId: null,
restorationId: restorationUpdated.id,
loggedAt: new Date(entry.timestamp),
entryType: entry.type,
level: entry.level,
message: entry.message,
command: entry.command ?? null,
output: entry.output ?? null,
exitCode: entry.exit_code ?? null,
durationMs: entry.duration_ms ?? null,
}));
if (logsToInsert.length > 0) {
await dbClient
.insert(drizzleDb.schemas.jobLog)
.values(logsToInsert);
}
await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore");
const response = {
status: true,
message: "Restoration successfully updated"
}
return Response.json(response, {status: 200})
} catch (error) {
log.error({error: error}, "Error in POST handler")
return NextResponse.json(
{error: 'Internal server error'},
{status: 500}
);
} }
}
const agent = await db.query.agent.findFirst({
where: and(
eq(drizzleDb.schemas.agent.id, agentId),
eq(drizzleDb.schemas.agent.isArchived, false),
),
});
if (!agent) {
return NextResponse.json({ error: "Agent not found" }, { status: 404 });
}
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, body.generatedId),
with: {
alertPolicies: true,
},
});
if (!database) {
return NextResponse.json(
{ error: "Database associated with generatedId provided not found" },
{ status: 404 },
);
}
const restoration = await db.query.restoration.findFirst({
where: and(
eq(drizzleDb.schemas.restoration.status, "ongoing"),
eq(drizzleDb.schemas.restoration.databaseId, database.id),
),
});
if (!restoration) {
return NextResponse.json(
{ error: "Unable to fin the corresponding restoration" },
{ status: 404 },
);
}
const [restorationUpdated] = await db
.update(drizzleDb.schemas.restoration)
.set(
withUpdatedAt({
status: body.status as RestorationStatus,
durationMs: body.durationMs,
}),
)
.where(eq(drizzleDb.schemas.restoration.id, restoration.id))
.returning();
const logsToInsert = body.logs.map((entry) => ({
backupId: null,
restorationId: restorationUpdated.id,
loggedAt: new Date(entry.timestamp),
entryType: entry.type,
level: entry.level,
message: entry.message,
command: entry.command ?? null,
output: entry.output ?? null,
exitCode: entry.exit_code ?? null,
durationMs: entry.duration_ms ?? null,
}));
if (logsToInsert.length > 0) {
await dbClient.insert(drizzleDb.schemas.jobLog).values(logsToInsert);
}
await sendNotificationsBackupRestore(
database,
body.status == "failed" ? "error_restore" : "success_restore",
);
const response = {
status: true,
message: "Restoration successfully updated",
};
return Response.json(response, { status: 200 });
} catch (error) {
log.error({ error: error }, "Error in POST handler");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+293 -240
View File
@@ -1,275 +1,328 @@
import {NextResponse} from "next/server"; import { NextResponse } from "next/server";
import {Body} from "./route"; import { Body } from "./route";
import {isUuidv4} from "@/utils/verify-uuid"; import { Agent } from "@/db/schema/08_agent";
import {Agent} from "@/db/schema/08_agent"; import { DatabaseWith } from "@/db/schema/07_database";
import {DatabaseWith} from "@/db/schema/07_database";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db, db as dbClient} from "@/db"; import { db, db as dbClient } from "@/db";
import {and, eq, inArray} from "drizzle-orm"; import { and, eq, inArray } from "drizzle-orm";
import {dbmsEnumSchema, EDbmsSchema} from "@/db/schema/types"; import { dbmsEnumSchema, EDbmsSchema } from "@/db/schema/types";
import {withUpdatedAt} from "@/db/utils"; import { withUpdatedAt } from "@/db/utils";
import type {StorageInput} from "@/features/storages/storages.types"; import type { StorageInput } from "@/features/storages/storages.types";
import {dispatchStorage} from "@/features/storages/storages.dispatch"; import { dispatchStorage } from "@/features/storages/storages.dispatch";
import {Setting} from "@/db/schema/01_setting"; import { Setting } from "@/db/schema/01_setting";
import {logger} from "@/lib/logger"; import { logger } from "@/lib/logger";
import { isUUID } from "@/utils/text";
const log = logger.child({module: "api/agent/status/helpers"}); const log = logger.child({ module: "api/agent/status/helpers" });
export async function handleDatabases(body: Body, agent: Agent, lastContact: Date, settings: Setting) { export async function handleDatabases(
const databasesResponse = []; body: Body,
agent: Agent,
lastContact: Date,
settings: Setting,
) {
const databasesResponse = [];
const formatDatabase = (database: DatabaseWith, backupAction: boolean, restoreAction: boolean, UrlBackup: string | null, storages: PingDatabaseStorageChannels[], urlMeta: string | null) => ({ const formatDatabase = (
generatedId: database.agentDatabaseId, database: DatabaseWith,
dbms: database.dbms, backupAction: boolean,
storages: storages, restoreAction: boolean,
encrypt: settings.encryption, UrlBackup: string | null,
data: { storages: PingDatabaseStorageChannels[],
backup: { urlMeta: string | null,
action: backupAction, ) => ({
cron: database.backupPolicy, generatedId: database.agentDatabaseId,
}, dbms: database.dbms,
restore: { storages: storages,
action: restoreAction, encrypt: settings.encryption,
file: UrlBackup, data: {
metaFile: urlMeta backup: {
}, action: backupAction,
}, cron: database.backupPolicy,
},
restore: {
action: restoreAction,
file: UrlBackup,
metaFile: urlMeta,
},
},
});
for (const db of body.databases) {
const existingDatabase = await dbClient.query.database.findFirst({
where: eq(drizzleDb.schemas.database.agentDatabaseId, db.generatedId),
with: {
project: true,
},
}); });
for (const db of body.databases) { let backupAction: boolean = false;
let restoreAction: boolean = false;
let urlBackup: string | null = null;
let urlMeta: string | null = null;
const existingDatabase = await dbClient.query.database.findFirst({ if (!existingDatabase) {
where: eq(drizzleDb.schemas.database.agentDatabaseId, db.generatedId), if (!isUUID(db.generatedId)) {
with: { return NextResponse.json(
project: true { error: "generatedId is not a valid uuid" },
} { status: 500 },
);
}
if (!dbmsEnumSchema.safeParse(db.dbms).success) {
log.error(
{ name: "handleDatabases" },
`Database type not available: ${db.dbms}`,
);
continue;
}
const [databaseCreated] = await dbClient
.insert(drizzleDb.schemas.database)
.values({
agentId: agent.id,
name: db.name,
dbms: db.dbms as EDbmsSchema,
agentDatabaseId: db.generatedId,
lastContact: db.pingStatus ? lastContact : null,
healthErrorCount: null,
})
.returning();
if (databaseCreated) {
await dbClient.insert(drizzleDb.schemas.healthcheckLog).values({
kind: "database",
status: db.pingStatus ? "success" : "failed",
objectId: databaseCreated.id,
date: lastContact,
}); });
let backupAction: boolean = false const storages = await getDatabaseStorageChannels(databaseCreated.id);
let restoreAction: boolean = false
let urlBackup: string | null = null;
let urlMeta: string | null = null
if (!existingDatabase) { databasesResponse.push(
if (!isUuidv4(db.generatedId)) { formatDatabase(
return NextResponse.json( databaseCreated,
{error: "generatedId is not a valid uuid"}, backupAction,
{status: 500} restoreAction,
); urlBackup,
} storages,
null,
),
);
}
} else {
const [databaseUpdated] = await dbClient
.update(drizzleDb.schemas.database)
.set(
withUpdatedAt({
name: db.name,
agentId: agent.id,
dbms: db.dbms as EDbmsSchema,
lastContact: db.pingStatus
? lastContact
: existingDatabase.lastContact,
healthErrorCount: db.pingStatus
? null
: existingDatabase.healthErrorCount,
}),
)
.where(eq(drizzleDb.schemas.database.id, existingDatabase.id))
.returning();
if (!dbmsEnumSchema.safeParse(db.dbms).success) { await dbClient.insert(drizzleDb.schemas.healthcheckLog).values({
log.error({name: "handleDatabases"},`Database type not available: ${db.dbms}`); kind: "database",
continue; status: db.pingStatus ? "success" : "failed",
} objectId: databaseUpdated.id,
date: lastContact,
});
const [databaseCreated] = await dbClient const activeBackup = await dbClient.query.backup.findFirst({
.insert(drizzleDb.schemas.database) where: and(
.values({ eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
agentId: agent.id, inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"]),
name: db.name, ),
dbms: db.dbms as EDbmsSchema, });
agentDatabaseId: db.generatedId,
lastContact: db.pingStatus ? lastContact : null,
healthErrorCount: null
})
.returning();
const restoration = await dbClient.query.restoration.findFirst({
where: and(
eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id),
eq(drizzleDb.schemas.restoration.status, "waiting"),
),
with: {
backupStorage: true,
},
});
if (databaseCreated) { if (activeBackup && activeBackup.status == "waiting") {
backupAction = true;
await dbClient
.update(drizzleDb.schemas.backup)
.set(withUpdatedAt({ status: "ongoing" }))
.where(eq(drizzleDb.schemas.backup.id, activeBackup.id));
}
await dbClient if (restoration) {
.insert(drizzleDb.schemas.healthcheckLog) restoreAction = true;
.values({
kind: "database",
status: db.pingStatus ? "success" : "failed",
objectId: databaseCreated.id,
date: lastContact
})
const storages = await getDatabaseStorageChannels(databaseCreated.id) if (
!restoration.backupStorage ||
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null)); restoration.backupStorage.status != "success" ||
} !restoration.backupStorage.path
} else { ) {
restoreAction = false;
const [databaseUpdated] = await dbClient continue;
.update(drizzleDb.schemas.database)
.set(withUpdatedAt({
name: db.name,
agentId: agent.id,
dbms: db.dbms as EDbmsSchema,
lastContact: db.pingStatus ? lastContact : existingDatabase.lastContact,
healthErrorCount: db.pingStatus ? null : existingDatabase.healthErrorCount,
}))
.where(eq(drizzleDb.schemas.database.id, existingDatabase.id))
.returning();
await dbClient
.insert(drizzleDb.schemas.healthcheckLog)
.values({
kind: "database",
status: db.pingStatus ? "success" : "failed",
objectId: databaseUpdated.id,
date: lastContact
})
const activeBackup = await dbClient.query.backup.findFirst({
where: and(
eq(drizzleDb.schemas.backup.databaseId, databaseUpdated.id),
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"])
)
})
const restoration = await dbClient.query.restoration.findFirst({
where: and(eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id), eq(drizzleDb.schemas.restoration.status, "waiting")),
with: {
backupStorage: true
}
})
if (activeBackup && activeBackup.status == "waiting") {
backupAction = true
await dbClient
.update(drizzleDb.schemas.backup)
.set(withUpdatedAt({status: "ongoing"}))
.where(eq(drizzleDb.schemas.backup.id, activeBackup.id));
}
if (restoration) {
restoreAction = true
if (!restoration.backupStorage || restoration.backupStorage.status != "success" || !restoration.backupStorage.path) {
restoreAction = false
continue;
}
const input: StorageInput = {
action: "get",
data: {
path: restoration.backupStorage.path,
signedUrl: true,
},
metadata: {
storageId: restoration.backupStorage.storageChannelId,
fileKind: "backups"
}
};
const inputMeta: StorageInput = {
action: "get",
data: {
path: `${restoration.backupStorage.path}.meta`,
signedUrl: true,
},
metadata: {
storageId: restoration.backupStorage.storageChannelId,
fileKind: "backups"
}
};
try {
const result = await dispatchStorage(input, undefined, restoration.backupStorage.storageChannelId);
const resultMeta = await dispatchStorage(inputMeta, undefined, restoration.backupStorage.storageChannelId);
if (result.success) {
urlBackup = result.url ?? null;
urlMeta = resultMeta.url ?? null
} else {
await dbClient
.update(drizzleDb.schemas.restoration)
.set(withUpdatedAt({status: "failed"}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
const errorMessage = "Failed to get backup URL";
log.error({error: errorMessage, name: "handleDatabases"}, "Restoration failed");
continue;
}
} catch (err) {
log.error({error: err, name: "handleDatabases"}, "Restoration crashed unexpectedly");
await dbClient
.update(drizzleDb.schemas.restoration)
.set(withUpdatedAt({status: "failed"}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
continue;
}
await dbClient
.update(drizzleDb.schemas.restoration)
.set(withUpdatedAt({status: "ongoing"}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
}
const storages = await getDatabaseStorageChannels(databaseUpdated.id)
databasesResponse.push(formatDatabase(databaseUpdated, backupAction, restoreAction, urlBackup, storages, urlMeta));
} }
}
return databasesResponse;
}
const input: StorageInput = {
action: "get",
data: {
path: restoration.backupStorage.path,
signedUrl: true,
},
metadata: {
storageId: restoration.backupStorage.storageChannelId,
fileKind: "backups",
},
};
const inputMeta: StorageInput = {
action: "get",
data: {
path: `${restoration.backupStorage.path}.meta`,
signedUrl: true,
},
metadata: {
storageId: restoration.backupStorage.storageChannelId,
fileKind: "backups",
},
};
try {
const result = await dispatchStorage(
input,
undefined,
restoration.backupStorage.storageChannelId,
);
const resultMeta = await dispatchStorage(
inputMeta,
undefined,
restoration.backupStorage.storageChannelId,
);
if (result.success) {
urlBackup = result.url ?? null;
urlMeta = resultMeta.url ?? null;
} else {
await dbClient
.update(drizzleDb.schemas.restoration)
.set(withUpdatedAt({ status: "failed" }))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
const errorMessage = "Failed to get backup URL";
log.error(
{ error: errorMessage, name: "handleDatabases" },
"Restoration failed",
);
continue;
}
} catch (err) {
log.error(
{ error: err, name: "handleDatabases" },
"Restoration crashed unexpectedly",
);
await dbClient
.update(drizzleDb.schemas.restoration)
.set(withUpdatedAt({ status: "failed" }))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
continue;
}
await dbClient
.update(drizzleDb.schemas.restoration)
.set(withUpdatedAt({ status: "ongoing" }))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
}
const storages = await getDatabaseStorageChannels(databaseUpdated.id);
databasesResponse.push(
formatDatabase(
databaseUpdated,
backupAction,
restoreAction,
urlBackup,
storages,
urlMeta,
),
);
}
}
return databasesResponse;
}
type PingDatabaseStorageChannels = { type PingDatabaseStorageChannels = {
id: string; id: string;
config: any config: any;
provider: string provider: string;
} };
async function getDatabaseStorageChannels(databaseId: string): Promise<PingDatabaseStorageChannels[]> { async function getDatabaseStorageChannels(
databaseId: string,
): Promise<PingDatabaseStorageChannels[]> {
const database = await db.query.database.findFirst({
where: eq(drizzleDb.schemas.database.id, databaseId),
with: {
project: true,
retentionPolicy: true,
alertPolicies: true,
storagePolicies: true,
},
});
const database = await db.query.database.findFirst({ if (!database) {
where: eq(drizzleDb.schemas.database.id, databaseId), return [];
with: { }
project: true,
retentionPolicy: true,
alertPolicies: true,
storagePolicies: true
}
});
if (!database) { const settings = await db.query.setting.findFirst({
return [] where: eq(drizzleDb.schemas.setting.name, "system"),
} with: { storageChannel: true },
});
const settings = await db.query.setting.findFirst({ const defaultStorageChannel: PingDatabaseStorageChannels[] =
where: eq(drizzleDb.schemas.setting.name, "system"), settings?.storageChannel
with: {storageChannel: true}, ? [
}); {
const defaultStorageChannel: PingDatabaseStorageChannels[] = settings?.storageChannel
? [{
id: settings.storageChannel.id, id: settings.storageChannel.id,
provider: settings.storageChannel.provider, provider: settings.storageChannel.provider,
config: settings.storageChannel.config, config: settings.storageChannel.config,
}] },
: []; ]
: [];
const enabledDatabaseStorageChannels = await Promise.all(
(database.storagePolicies ?? [])
.filter((p) => p.enabled)
.map(async (policy) => {
const storageChannel = await db.query.storageChannel.findFirst({
where: eq(
drizzleDb.schemas.storageChannel.id,
policy.storageChannelId,
),
});
const enabledDatabaseStorageChannels = await Promise.all( if (!storageChannel) return null;
(database.storagePolicies ?? [])
.filter(p => p.enabled)
.map(async policy => {
const storageChannel = await db.query.storageChannel.findFirst({
where: eq(drizzleDb.schemas.storageChannel.id, policy.storageChannelId),
});
if (!storageChannel) return null; return {
id: storageChannel.id,
config: storageChannel.config,
provider: storageChannel.provider,
} as PingDatabaseStorageChannels;
}),
);
return { const filteredChannels: PingDatabaseStorageChannels[] =
id: storageChannel.id, enabledDatabaseStorageChannels.filter(
config: storageChannel.config, (c): c is PingDatabaseStorageChannels => c !== null,
provider: storageChannel.provider,
} as PingDatabaseStorageChannels;
})
); );
const filteredChannels: PingDatabaseStorageChannels[] = enabledDatabaseStorageChannels.filter( return filteredChannels.length > 0 ? filteredChannels : defaultStorageChannel;
(c): c is PingDatabaseStorageChannels => c !== null
);
return filteredChannels.length > 0 ? filteredChannels : defaultStorageChannel;
} }
+95 -87
View File
@@ -1,99 +1,107 @@
import {NextResponse} from "next/server"; import { NextResponse } from "next/server";
import {handleDatabases} from "./helpers"; import { handleDatabases } from "./helpers";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db} from "@/db"; import { db } from "@/db";
import {EDbmsSchema} from "@/db/schema/types"; import { EDbmsSchema } from "@/db/schema/types";
import {and, eq} from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import {isUuidv4} from "@/utils/verify-uuid"; import { withUpdatedAt } from "@/db/utils";
import {withUpdatedAt} from "@/db/utils"; import { logger } from "@/lib/logger";
import {logger} from "@/lib/logger"; import { isUUID } from "@/utils/text";
const log = logger.child({module: "api/agent/status/route"});
const log = logger.child({ module: "api/agent/status/route" });
export type databaseAgent = { export type databaseAgent = {
name: string, name: string;
dbms: EDbmsSchema, dbms: EDbmsSchema;
generatedId: string generatedId: string;
pingStatus: boolean pingStatus: boolean;
} };
export type Body = { export type Body = {
version: string, version: string;
databases: databaseAgent[] databases: databaseAgent[];
} };
export async function POST( export async function POST(
request: Request, request: Request,
{params}: { params: Promise<{ agentId: string }> } { params }: { params: Promise<{ agentId: string }> },
) { ) {
try { try {
const agentId = (await params).agentId const agentId = (await params).agentId;
log.debug(`Agent ID: ${agentId}`) log.debug(`Agent ID: ${agentId}`);
const body: Body = await request.json(); const body: Body = await request.json();
const lastContact = new Date(); const lastContact = new Date();
let message: string let message: string;
if (!isUuidv4(agentId)) { if (!isUUID(agentId)) {
message = "agentId is not a valid uuid" message = "agentId is not a valid uuid";
log.error({error: message}, "An error occurred") log.error({ error: message }, "An error occurred");
return NextResponse.json( return NextResponse.json(
{error: "agentId is not a valid uuid"}, { error: "agentId is not a valid uuid" },
{status: 500} { status: 500 },
); );
}
const agent = await db.query.agent.findFirst({
where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)),
})
if (!agent) {
message = "Agent not found"
return NextResponse.json({error: message}, {status: 404})
}
const [settings] = await db.select().from(drizzleDb.schemas.setting).where(eq(drizzleDb.schemas.setting.name, "system")).limit(1);
if (!settings) {
return NextResponse.json({error: "An error occured"}, {status: 404})
}
const databasesResponse = await handleDatabases(body, agent, lastContact, settings)
await db
.update(drizzleDb.schemas.agent)
.set(withUpdatedAt({
version: body.version,
lastContact: lastContact,
healthErrorCount: null
}))
.where(eq(drizzleDb.schemas.agent.id, agentId));
await db
.insert(drizzleDb.schemas.healthcheckLog)
.values({
kind: "agent",
status: "success",
objectId: agentId,
date: lastContact
})
const response = {
agent: {
id: agentId,
lastContact: lastContact
},
databases: databasesResponse
}
return Response.json(response)
} catch (error) {
log.error({error: error}, "Error in POST handler")
return NextResponse.json(
{error: 'Internal server error'},
{status: 500}
);
} }
}
const agent = await db.query.agent.findFirst({
where: and(
eq(drizzleDb.schemas.agent.id, agentId),
eq(drizzleDb.schemas.agent.isArchived, false),
),
});
if (!agent) {
message = "Agent not found";
return NextResponse.json({ error: message }, { status: 404 });
}
const [settings] = await db
.select()
.from(drizzleDb.schemas.setting)
.where(eq(drizzleDb.schemas.setting.name, "system"))
.limit(1);
if (!settings) {
return NextResponse.json({ error: "An error occured" }, { status: 404 });
}
const databasesResponse = await handleDatabases(
body,
agent,
lastContact,
settings,
);
await db
.update(drizzleDb.schemas.agent)
.set(
withUpdatedAt({
version: body.version,
lastContact: lastContact,
healthErrorCount: null,
}),
)
.where(eq(drizzleDb.schemas.agent.id, agentId));
await db.insert(drizzleDb.schemas.healthcheckLog).values({
kind: "agent",
status: "success",
objectId: agentId,
date: lastContact,
});
const response = {
agent: {
id: agentId,
lastContact: lastContact,
},
databases: databasesResponse,
};
return Response.json(response);
} catch (error) {
log.error({ error: error }, "Error in POST handler");
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 },
);
}
}
+8 -2
View File
@@ -1,7 +1,8 @@
import { ImageResponse } from "next/og"; import { ImageResponse } from "next/og";
import { NextRequest } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getSettings } from "@/db/services/setting";
export const runtime = "edge"; export const runtime = "nodejs";
const AVATAR_COLORS = [ const AVATAR_COLORS = [
"#4f46e5", "#4f46e5",
@@ -15,6 +16,11 @@ const AVATAR_COLORS = [
]; ];
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const settings = await getSettings();
if (settings?.avatarMode && settings.avatarMode !== "internal") {
return new NextResponse(null, { status: 404 });
}
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const initials = (searchParams.get("initials") ?? "?") const initials = (searchParams.get("initials") ?? "?")
.slice(0, 2) .slice(0, 2)
+1 -1
View File
@@ -46,7 +46,7 @@ export const PORTABASE_DEFAULT_SETTINGS = {
"https://api.iconify.design", "https://api.iconify.design",
"https://code.iconify.design", "https://code.iconify.design",
"https://api.github.com", "https://api.github.com",
"https://api.dicebear.com",
], ],
OBJECT_SRC: ["'none'"], OBJECT_SRC: ["'none'"],
BASE_URI: ["'self'"], BASE_URI: ["'self'"],
@@ -0,0 +1,2 @@
CREATE TYPE "public"."avatar_mode" AS ENUM('internal', 'gravatar', 'dicebear');--> statement-breakpoint
ALTER TABLE "settings" ADD COLUMN "avatar_mode" "avatar_mode" DEFAULT 'internal' NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE "settings" ADD COLUMN "dicebear_style" varchar(64) DEFAULT 'thumbs' NOT NULL;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+14
View File
@@ -463,6 +463,20 @@
"when": 1782118902777, "when": 1782118902777,
"tag": "0065_overjoyed_mantis", "tag": "0065_overjoyed_mantis",
"breakpoints": true "breakpoints": true
},
{
"idx": 67,
"version": "7",
"when": 1782133059337,
"tag": "0067_adorable_jean_grey",
"breakpoints": true
},
{
"idx": 68,
"version": "7",
"when": 1782134460680,
"tag": "0068_bitter_revanche",
"breakpoints": true
} }
] ]
} }
+5 -1
View File
@@ -1,4 +1,6 @@
import {boolean, pgTable, uuid, varchar} from "drizzle-orm/pg-core"; import {boolean, pgEnum, pgTable, uuid, varchar} from "drizzle-orm/pg-core";
export const avatarModeEnum = pgEnum('avatar_mode', ['internal', 'gravatar', 'dicebear']);
import {createSelectSchema} from "drizzle-zod"; import {createSelectSchema} from "drizzle-zod";
import {z} from "zod"; import {z} from "zod";
import {timestamps} from "@/db/schema/00_common"; import {timestamps} from "@/db/schema/00_common";
@@ -21,6 +23,8 @@ export const setting = pgTable("settings", {
.references(() => storageChannel.id, {onDelete: "set null"}), .references(() => storageChannel.id, {onDelete: "set null"}),
encryption: boolean("encryption").default(false), encryption: boolean("encryption").default(false),
onboarding: boolean("onboarding").default(false).notNull(), onboarding: boolean("onboarding").default(false).notNull(),
avatarMode: avatarModeEnum('avatar_mode').default('internal').notNull(),
dicebearStyle: varchar('dicebear_style', { length: 64 }).default('thumbs').notNull(),
...timestamps ...timestamps
}); });
+33 -35
View File
@@ -1,43 +1,41 @@
import {and, desc, eq, sql} from "drizzle-orm"; "use server";
import {db} from "@/db";
import {Agent, agent, organizationAgent} from "@/db/schema/08_agent"; import { and, desc, eq, sql } from "drizzle-orm";
import {Database, database} from "@/db/schema/07_database"; import { db } from "@/db";
import { Agent, agent, organizationAgent } from "@/db/schema/08_agent";
import { Database, database } from "@/db/schema/07_database";
export async function getOrganizationAgents(organizationId: string) { export async function getOrganizationAgents(organizationId: string) {
return (await db
return await db .select({
.select({ id: agent.id,
id: agent.id, name: agent.name,
name: agent.name, organizationId: agent.organizationId,
organizationId: agent.organizationId, slug: agent.slug,
slug: agent.slug, healthErrorCount: agent.healthErrorCount,
healthErrorCount: agent.healthErrorCount, description: agent.description,
description: agent.description, isArchived: agent.isArchived,
isArchived: agent.isArchived, lastContact: agent.lastContact,
lastContact: agent.lastContact, version: agent.version,
version: agent.version, updatedAt: agent.updatedAt,
updatedAt: agent.updatedAt, createdAt: agent.createdAt,
createdAt: agent.createdAt, deletedAt: agent.deletedAt,
deletedAt: agent.deletedAt, databases: sql<Database[]>`
databases: sql<Database[]>`
COALESCE( COALESCE(
json_agg(${database}.*) FILTER (WHERE ${database}.id IS NOT NULL), json_agg(${database}.*) FILTER (WHERE ${database}.id IS NOT NULL),
'[]' '[]'
) )
`, `,
}) })
.from(organizationAgent) .from(organizationAgent)
.innerJoin( .innerJoin(agent, eq(organizationAgent.agentId, agent.id))
agent, .leftJoin(database, eq(database.agentId, agent.id))
eq(organizationAgent.agentId, agent.id) .groupBy(agent.id)
) .orderBy(desc(agent.createdAt))
.leftJoin(database, eq(database.agentId, agent.id)) .where(
.groupBy(agent.id) and(
.orderBy(desc(agent.createdAt)) eq(organizationAgent.organizationId, organizationId),
.where( eq(agent.isArchived, false),
and( ),
eq(organizationAgent.organizationId, organizationId), )) as unknown as Agent[];
eq(agent.isArchived, false)
)
) as unknown as Agent[];
} }
+13 -13
View File
@@ -1,19 +1,19 @@
"use server" "use server";
import {eq} from "drizzle-orm"; import { eq } from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db} from "@/db"; import { db } from "@/db";
export async function getDatabaseBackups(databaseId: string) { export async function getDatabaseBackups(databaseId: string) {
return await db.query.backup.findMany({ return await db.query.backup.findMany({
where: eq(drizzleDb.schemas.backup.databaseId, databaseId), where: eq(drizzleDb.schemas.backup.databaseId, databaseId),
with: {
restorations: true,
storages: {
with: { with: {
restorations: true, storageChannel: true,
storages: {
with: {
storageChannel: true,
},
},
}, },
orderBy: (b, {desc}) => [desc(b.createdAt)], },
}); },
orderBy: (b, { desc }) => [desc(b.createdAt)],
});
} }
+118 -34
View File
@@ -1,39 +1,123 @@
"use server" "use server";
import {db} from "@/db"; import { inArray } from "drizzle-orm";
import {DatabaseWith} from "@/db/schema/07_database"; import { db } from "@/db";
import {AgentWith} from "@/db/schema/08_agent"; import { database, retentionPolicy } from "@/db/schema/07_database";
import { alertPolicy } from "@/db/schema/10_alert-policy";
import { storagePolicy } from "@/db/schema/13_storage-policy";
import { DatabaseWith } from "@/db/schema/07_database";
import { AgentWith } from "@/db/schema/08_agent";
import type {
OnboardingDbSettings,
EventKind,
} from "@/features/onboarding/types";
export async function getOrganizationAvailableDatabases( export async function getOrganizationAvailableDatabases(
organizationId: string, organizationId: string,
projectId?: string projectId?: string,
) { ) {
const availableDatabases = (await db.query.database.findMany({
where: (db, { eq, or, isNull }) =>
projectId
? or(isNull(db.projectId), eq(db.projectId, projectId))
: isNull(db.projectId),
with: {
agent: {
with: {
organizations: true,
},
},
project: true,
backups: true,
restorations: true,
},
orderBy: (db, { desc }) => [desc(db.createdAt)],
})) as DatabaseWith[];
const availableDatabases = ( return availableDatabases.filter((db) => {
await db.query.database.findMany({ const agent = db.agent as AgentWith;
where: (db, { eq, or, isNull }) => if (agent?.isArchived) return false;
projectId return (
? or(isNull(db.projectId), eq(db.projectId, projectId)) agent?.organizationId === organizationId ||
: isNull(db.projectId), agent?.organizations?.some((org) => org.organizationId === organizationId)
with: { );
agent: { });
with: { }
organizations: true
} export async function getDatabasesSettings(
}, databaseIds: string[],
project: true, ): Promise<Record<string, OnboardingDbSettings>> {
backups: true, if (databaseIds.length === 0) return {};
restorations: true,
}, const [retentionPolicies, dbs, alertPolicies, storagePolicies] =
orderBy: (db, {desc}) => [desc(db.createdAt)], await Promise.all([
}) db
) as DatabaseWith[]; .select()
.from(retentionPolicy)
return availableDatabases.filter(db => { .where(inArray(retentionPolicy.databaseId, databaseIds)),
const agent = db.agent as AgentWith; db
if (agent?.isArchived) return false; .select({ id: database.id, backupPolicy: database.backupPolicy })
return ( .from(database)
agent?.organizationId === organizationId || .where(inArray(database.id, databaseIds)),
agent?.organizations?.some(org => org.organizationId === organizationId) db
); .select()
}) .from(alertPolicy)
.where(inArray(alertPolicy.databaseId, databaseIds)),
db
.select()
.from(storagePolicy)
.where(inArray(storagePolicy.databaseId, databaseIds)),
]);
const result: Record<string, OnboardingDbSettings> = {};
for (const dbId of databaseIds) {
const rp = retentionPolicies.find((r) => r.databaseId === dbId);
const dbRow = dbs.find((d) => d.id === dbId);
const alerts = alertPolicies.filter((a) => a.databaseId === dbId);
const storages = storagePolicies.filter((s) => s.databaseId === dbId);
const settings: OnboardingDbSettings = {};
if (rp) {
settings.retention = {
type: rp.type,
count: rp.count ?? 7,
days: rp.days ?? 30,
gfs: {
daily: rp.gfsDaily ?? 7,
weekly: rp.gfsWeekly ?? 4,
monthly: rp.gfsMonthly ?? 12,
yearly: rp.gfsYearly ?? 3,
},
};
}
if (dbRow) {
if (dbRow.backupPolicy) {
settings.backupMethod = "automatic";
settings.backupCron = dbRow.backupPolicy;
} else {
settings.backupMethod = "manual";
}
}
if (alerts.length > 0) {
settings.notificationPolicies = alerts.map((a) => ({
channelId: a.notificationChannelId,
eventKinds: a.eventKinds as EventKind[],
enabled: a.enabled,
}));
}
if (storages.length > 0) {
settings.storagePolicies = storages.map((s) => ({
channelId: s.storageChannelId,
enabled: s.enabled,
}));
}
result[dbId] = settings;
}
return result;
} }
+172 -157
View File
@@ -1,185 +1,200 @@
import {db} from "@/db"; "use server";
import { db } from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {and, eq, gte, isNotNull, lt} from "drizzle-orm"; import { and, eq, gte, isNotNull, lt } from "drizzle-orm";
import {dispatchNotification} from "@/features/notifications/notifications.dispatch"; import { dispatchNotification } from "@/features/notifications/notifications.dispatch";
import {EventPayload} from "@/features/notifications/notifications.types"; import { EventPayload } from "@/features/notifications/notifications.types";
import {logger} from "@/lib/logger"; import { logger } from "@/lib/logger";
const log = logger.child({module: "tasks/healthcheck"}); const log = logger.child({ module: "tasks/healthcheck" });
export async function getHealthLast12hLogs({id}: { id: string }) { export async function getHealthLast12hLogs({ id }: { id: string }) {
const now = new Date() const now = new Date();
const since = new Date(now.getTime() - 12 * 60 * 60 * 1000) const since = new Date(now.getTime() - 12 * 60 * 60 * 1000);
return db return db
.select() .select()
.from(drizzleDb.schemas.healthcheckLog) .from(drizzleDb.schemas.healthcheckLog)
.where( .where(
and( and(
eq(drizzleDb.schemas.healthcheckLog.objectId, id), eq(drizzleDb.schemas.healthcheckLog.objectId, id),
gte(drizzleDb.schemas.healthcheckLog.date, since) gte(drizzleDb.schemas.healthcheckLog.date, since),
) ),
) );
} }
export async function deleteHealthLogsOlderThan12h() { export async function deleteHealthLogsOlderThan12h() {
const now = new Date() const now = new Date();
const threshold = new Date(now.getTime() - 12 * 60 * 60 * 1000) const threshold = new Date(now.getTime() - 12 * 60 * 60 * 1000);
const logsToDelete = await db const logsToDelete = await db
.select() .select()
.from(drizzleDb.schemas.healthcheckLog) .from(drizzleDb.schemas.healthcheckLog)
.where( .where(lt(drizzleDb.schemas.healthcheckLog.date, threshold));
lt(drizzleDb.schemas.healthcheckLog.date, threshold)
)
log.info({name: "deleteHealthLogsOlderThan12h"},`Number of logs found to delete: ${logsToDelete.length}`) log.info(
{ name: "deleteHealthLogsOlderThan12h" },
`Number of logs found to delete: ${logsToDelete.length}`,
);
await db await db
.delete(drizzleDb.schemas.healthcheckLog) .delete(drizzleDb.schemas.healthcheckLog)
.where( .where(lt(drizzleDb.schemas.healthcheckLog.date, threshold));
lt(drizzleDb.schemas.healthcheckLog.date, threshold)
)
return logsToDelete.length return logsToDelete.length;
} }
export async function checkAgentsHealthError() { export async function checkAgentsHealthError() {
const agents = await db.query.agent.findMany({ const agents = await db.query.agent.findMany({
where: isNotNull(drizzleDb.schemas.agent.lastContact), where: isNotNull(drizzleDb.schemas.agent.lastContact),
}); });
const settings = await db.query.setting.findFirst({ const settings = await db.query.setting.findFirst({
where: (fields, {eq}) => eq(fields.name, "system"), where: (fields, { eq }) => eq(fields.name, "system"),
}); });
if (!settings) { if (!settings) {
throw new Error("System settings not found"); throw new Error("System settings not found");
} }
if (!settings.defaultNotificationChannelId) { if (!settings.defaultNotificationChannelId) {
log.error({name: "checkAgentsHealthError"},`No default notification channel id found.`) log.error(
return { name: "checkAgentsHealthError" },
} `No default notification channel id found.`,
);
const now = new Date(); return;
}
for (const agent of agents) {
if (!agent.lastContact) continue; const now = new Date();
const lastContactDate = new Date(agent.lastContact); for (const agent of agents) {
const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60; if (!agent.lastContact) continue;
if (diffMinutes > 10) { const lastContactDate = new Date(agent.lastContact);
if ((agent.healthErrorCount ?? 0) < 3) { const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60;
const newHealthErrorCount = (agent.healthErrorCount ?? 0) + 1 if (diffMinutes > 10) {
await db.update(drizzleDb.schemas.agent) if ((agent.healthErrorCount ?? 0) < 3) {
.set({ const newHealthErrorCount = (agent.healthErrorCount ?? 0) + 1;
healthErrorCount: newHealthErrorCount, await db
}) .update(drizzleDb.schemas.agent)
.where(eq(drizzleDb.schemas.agent.id, agent.id)); .set({
healthErrorCount: newHealthErrorCount,
const payload: EventPayload = { })
title: "Agent down", .where(eq(drizzleDb.schemas.agent.id, agent.id));
message: `Agent ${agent.name} is down, (notification number: ${newHealthErrorCount}/3)`,
level: "critical", const payload: EventPayload = {
event: "error_health_agent", title: "Agent down",
data: { message: `Agent ${agent.name} is down, (notification number: ${newHealthErrorCount}/3)`,
agent: agent.name, level: "critical",
id: agent.id, event: "error_health_agent",
error: "Agent is down", data: {
}, agent: agent.name,
}; id: agent.id,
log.info({name: "checkAgentsHealthError", payload: payload},`Agent Healthcheck Notification`) error: "Agent is down",
},
await dispatchNotification( };
payload, log.info(
undefined, { name: "checkAgentsHealthError", payload: payload },
settings.defaultNotificationChannelId, `Agent Healthcheck Notification`,
undefined );
);
} await dispatchNotification(
payload,
} undefined,
settings.defaultNotificationChannelId,
undefined,
);
}
} }
}
} }
export async function checkDatabasesHealthError() { export async function checkDatabasesHealthError() {
const databases = await db.query.database.findMany({
where: isNotNull(drizzleDb.schemas.database.lastContact),
with: {
agent: true,
alertPolicies: true,
},
});
const databases = await db.query.database.findMany({ const now = new Date();
where: isNotNull(drizzleDb.schemas.database.lastContact),
with: { for (const database of databases) {
agent: true, if (!database.lastContact) continue;
alertPolicies: true
const lastContactDate = new Date(database.lastContact);
const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60;
if (diffMinutes > 10) {
if ((database.healthErrorCount ?? 0) < 3) {
const newHealthErrorCount = (database.healthErrorCount ?? 0) + 1;
await db
.update(drizzleDb.schemas.database)
.set({
healthErrorCount: newHealthErrorCount,
})
.where(eq(drizzleDb.schemas.database.id, database.id));
const settings = await db.query.setting.findFirst({
where: eq(drizzleDb.schemas.setting.name, "system"),
with: { notificationChannel: true },
});
const defaultPolicy = settings?.notificationChannel
? [
{
id: null,
notificationChannelId: settings.notificationChannel.id,
enabled: settings.notificationChannel.enabled,
eventKinds: ["error_health_database"],
},
]
: [];
const policiesToUse =
database.alertPolicies && database.alertPolicies.length > 0
? database.alertPolicies.filter(
(policy) =>
policy.enabled &&
policy.eventKinds.includes("error_health_database"),
)
: defaultPolicy;
if (!policiesToUse || policiesToUse.length === 0) {
continue;
} }
})
const now = new Date(); const promises = policiesToUse.map((alertPolicy) => {
const payload: EventPayload = {
title: "Database down",
message: `Database ${database.name} is down, (notification number: ${newHealthErrorCount}/3)`,
level: "critical",
event: "error_health_database",
data: {
agent: database.name,
id: database.id,
error: "Database is down",
},
};
for (const database of databases) { log.info(
if (!database.lastContact) continue; { name: "checkDatabasesHealthError", payload: payload },
`Database Healthcheck Notification`,
);
const lastContactDate = new Date(database.lastContact); return dispatchNotification(
const diffMinutes = (now.getTime() - lastContactDate.getTime()) / 1000 / 60; payload,
alertPolicy.id == null ? undefined : alertPolicy.id,
alertPolicy.id ? undefined : alertPolicy.notificationChannelId,
undefined,
);
});
if (diffMinutes > 10) { await Promise.all(promises);
if ((database.healthErrorCount ?? 0) < 3) { }
const newHealthErrorCount = (database.healthErrorCount ?? 0) + 1
await db.update(drizzleDb.schemas.database)
.set({
healthErrorCount: newHealthErrorCount,
})
.where(eq(drizzleDb.schemas.database.id, database.id));
const settings = await db.query.setting.findFirst({
where: eq(drizzleDb.schemas.setting.name, "system"),
with: { notificationChannel: true },
});
const defaultPolicy = settings?.notificationChannel
? [{
id: null,
notificationChannelId: settings.notificationChannel.id,
enabled: settings.notificationChannel.enabled,
eventKinds: ["error_health_database"]
}]
: [];
const policiesToUse = (database.alertPolicies && database.alertPolicies.length > 0)
? database.alertPolicies.filter(policy => policy.enabled && policy.eventKinds.includes("error_health_database"))
: defaultPolicy;
if (!policiesToUse || policiesToUse.length === 0) {
continue
}
const promises = policiesToUse.map(alertPolicy => {
const payload: EventPayload = {
title: "Database down",
message: `Database ${database.name} is down, (notification number: ${newHealthErrorCount}/3)`,
level: "critical",
event: "error_health_database",
data: {
agent: database.name,
id: database.id,
error: "Database is down",
},
};
log.info({name: "checkDatabasesHealthError", payload: payload},`Database Healthcheck Notification`)
return dispatchNotification(payload, alertPolicy.id == null ? undefined : alertPolicy.id, alertPolicy.id ? undefined : alertPolicy.notificationChannelId, undefined);
});
await Promise.all(promises);
}
}
} }
}
} }
+60 -28
View File
@@ -1,30 +1,62 @@
import {desc, eq} from "drizzle-orm"; "use server";
import {db} from "@/db";
import {
NotificationChannel,
notificationChannel,
organizationNotificationChannel
} from "@/db/schema/09_notification-channel";
import {storageChannel} from "@/db/schema/12_storage-channel";
export async function getOrganizationChannels(organizationId: string) { import { desc, eq, isNull } from "drizzle-orm";
return await db import { db } from "@/db";
.select({ import {
id: notificationChannel.id, NotificationChannel,
name: notificationChannel.name, notificationChannel,
provider: notificationChannel.provider, organizationNotificationChannel,
config: notificationChannel.config, } from "@/db/schema/09_notification-channel";
enabled: notificationChannel.enabled,
updatedAt: notificationChannel.updatedAt, export async function getOrganizationChannels(
createdAt: notificationChannel.createdAt, organizationId: string,
deletedAt: notificationChannel.deletedAt, ): Promise<NotificationChannel[]> {
organizationId: notificationChannel.organizationId const [orgChannels, systemChannels] = await Promise.all([
}) db
.from(organizationNotificationChannel) .select({
.innerJoin( id: notificationChannel.id,
notificationChannel, name: notificationChannel.name,
eq(organizationNotificationChannel.notificationChannelId, notificationChannel.id) provider: notificationChannel.provider,
) config: notificationChannel.config,
.orderBy(desc(notificationChannel.createdAt)) enabled: notificationChannel.enabled,
.where(eq(organizationNotificationChannel.organizationId, organizationId)) as unknown as NotificationChannel[]; updatedAt: notificationChannel.updatedAt,
createdAt: notificationChannel.createdAt,
deletedAt: notificationChannel.deletedAt,
organizationId: notificationChannel.organizationId,
})
.from(organizationNotificationChannel)
.innerJoin(
notificationChannel,
eq(
organizationNotificationChannel.notificationChannelId,
notificationChannel.id,
),
)
.orderBy(desc(notificationChannel.createdAt))
.where(
eq(organizationNotificationChannel.organizationId, organizationId),
),
db
.select({
id: notificationChannel.id,
name: notificationChannel.name,
provider: notificationChannel.provider,
config: notificationChannel.config,
enabled: notificationChannel.enabled,
updatedAt: notificationChannel.updatedAt,
createdAt: notificationChannel.createdAt,
deletedAt: notificationChannel.deletedAt,
organizationId: notificationChannel.organizationId,
})
.from(notificationChannel)
.orderBy(desc(notificationChannel.createdAt))
.where(isNull(notificationChannel.organizationId)),
]);
const seen = new Set<string>();
return [...orgChannels, ...systemChannels].filter((c) => {
if (seen.has(c.id)) return false;
seen.add(c.id);
return true;
}) as NotificationChannel[];
} }
+83 -73
View File
@@ -1,81 +1,91 @@
import {and, desc, eq, gte, lte} from 'drizzle-orm'; "use server";
import {NotificationLevel, notificationLog} from "@/db/schema/11_notification-log";
import {notificationChannel} from "@/db/schema/09_notification-channel"; import { and, desc, eq, gte, lte } from "drizzle-orm";
import {db} from "@/db"; import {
import {Json} from "drizzle-zod"; NotificationLevel,
notificationLog,
} from "@/db/schema/11_notification-log";
import { notificationChannel } from "@/db/schema/09_notification-channel";
import { db } from "@/db";
import { Json } from "drizzle-zod";
export type NotificationLogWithRelations = { export type NotificationLogWithRelations = {
id: string; id: string;
title: string;
level: NotificationLevel;
success: boolean;
error: string | null;
sentAt: Date;
payload: Json | null;
content: {
title: string; title: string;
level: NotificationLevel; message: string;
success: boolean; };
error: string | null; channel: {
sentAt: Date; name: string;
payload: Json | null; provider: string;
content: { } | null;
title: string; policy: {
message: string; event: string | null;
}, } | null;
channel: {
name: string;
provider: string;
} | null;
policy: {
event: string | null;
} | null;
}; };
export async function getNotificationHistory( export async function getNotificationHistory(filters?: {
filters?: { channelId?: string;
channelId?: string; policyId?: string;
policyId?: string; organizationId?: string;
organizationId?: string; level?: NotificationLevel;
level?: NotificationLevel; success?: boolean;
success?: boolean; from?: Date;
from?: Date; to?: Date;
to?: Date; limit?: number;
limit?: number; }): Promise<NotificationLogWithRelations[]> {
} const where = [];
): Promise<NotificationLogWithRelations[]> { if (filters?.channelId)
const where = []; where.push(eq(notificationLog.channelId, filters.channelId));
if (filters?.channelId) where.push(eq(notificationLog.channelId, filters.channelId)); if (filters?.policyId)
if (filters?.policyId) where.push(eq(notificationLog.policyId, filters.policyId)); where.push(eq(notificationLog.policyId, filters.policyId));
if (filters?.organizationId) where.push(eq(notificationLog.organizationId, filters.organizationId)); if (filters?.organizationId)
if (filters?.level) where.push(eq(notificationLog.level, filters.level)); where.push(eq(notificationLog.organizationId, filters.organizationId));
if (typeof filters?.success === 'boolean') where.push(eq(notificationLog.success, filters.success)); if (filters?.level) where.push(eq(notificationLog.level, filters.level));
if (filters?.from) where.push(gte(notificationLog.sentAt, filters.from)); if (typeof filters?.success === "boolean")
if (filters?.to) where.push(lte(notificationLog.sentAt, filters.to)); where.push(eq(notificationLog.success, filters.success));
if (filters?.from) where.push(gte(notificationLog.sentAt, filters.from));
if (filters?.to) where.push(lte(notificationLog.sentAt, filters.to));
const rows = await db const rows = await db
.select({ .select({
id: notificationLog.id, id: notificationLog.id,
title: notificationLog.title, title: notificationLog.title,
level: notificationLog.level, level: notificationLog.level,
success: notificationLog.success, success: notificationLog.success,
error: notificationLog.error, error: notificationLog.error,
sentAt: notificationLog.sentAt, sentAt: notificationLog.sentAt,
payload: notificationLog.payload, payload: notificationLog.payload,
content: { content: {
title: notificationLog.title, title: notificationLog.title,
message: notificationLog.message, message: notificationLog.message,
}, },
channel: { channel: {
name: notificationLog.providerName, name: notificationLog.providerName,
provider: notificationLog.provider, provider: notificationLog.provider,
}, },
policy: { policy: {
event: notificationLog.event, event: notificationLog.event,
}, },
}) })
.from(notificationLog) .from(notificationLog)
.leftJoin(notificationChannel, eq(notificationLog.channelId, notificationChannel.id)) .leftJoin(
// .leftJoin(alertPolicy, eq(notificationLog.policyId, alertPolicy.id)) notificationChannel,
.where(and(...where)) eq(notificationLog.channelId, notificationChannel.id),
.orderBy(desc(notificationLog.sentAt)) )
.limit(filters?.limit || 100); // .leftJoin(alertPolicy, eq(notificationLog.policyId, alertPolicy.id))
.where(and(...where))
.orderBy(desc(notificationLog.sentAt))
.limit(filters?.limit || 100);
return rows.map(row => ({ return rows.map((row) => ({
...row, ...row,
payload: row.payload as Json, payload: row.payload as Json,
})); }));
} }
+10 -8
View File
@@ -1,15 +1,17 @@
"use server";
import { db } from "@/db"; import { db } from "@/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { member } from "@/db/schema/04_member"; import { member } from "@/db/schema/04_member";
import { organization } from "@/db/schema/03_organization"; import { organization } from "@/db/schema/03_organization";
export async function getUserOrganization(userId: string) { export async function getUserOrganization(userId: string) {
const memberRow = await db.query.member.findFirst({ const memberRow = await db.query.member.findFirst({
columns: { organizationId: true }, columns: { organizationId: true },
where: eq(member.userId, userId), where: eq(member.userId, userId),
}); });
if (!memberRow) return null; if (!memberRow) return null;
return db.query.organization.findFirst({ return db.query.organization.findFirst({
where: eq(organization.id, memberRow.organizationId), where: eq(organization.id, memberRow.organizationId),
}); });
} }
+68 -62
View File
@@ -1,71 +1,77 @@
import {getOrganization} from "@/lib/auth/auth"; "use server";
import {db} from "@/db";
import {and, eq} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {project} from "@/db/schema/06_project";
import { getOrganization } from "@/lib/auth/auth";
import { db } from "@/db";
import { and, eq } from "drizzle-orm";
import * as drizzleDb from "@/db";
import { project } from "@/db/schema/06_project";
export async function getOrganizationProject(organizationId: string) { export async function getOrganizationProject(organizationId: string) {
return db.query.project.findFirst({ return db.query.project.findFirst({
where: eq(project.organizationId, organizationId), where: eq(project.organizationId, organizationId),
with: { with: {
databases: true databases: true,
} },
}); });
} }
export const getOrganizationProjectDatabases = async ({organizationSlug, projectId}: { export const getOrganizationProjectDatabases = async ({
organizationSlug: string, projectId: string organizationSlug,
projectId,
}: {
organizationSlug: string;
projectId: string;
}) => { }) => {
try { try {
const organization = await getOrganization({});
const organization = await getOrganization({}); if (!organization) {
return {
if (!organization) { name: "ErrorGettingOrganizationProjectDatabases",
return { message: "No organization found.",
name: "ErrorGettingOrganizationProjectDatabases", status: 400,
message: "No organization found.", cause: "Unknown error occurred.",
status: 400, };
cause: "Unknown error occurred.",
};
}
const databasesProject = await db.query.project.findFirst({
where: and(eq(drizzleDb.schemas.project.organizationId, organization.id), eq(drizzleDb.schemas.project.id, projectId)),
with: {
databases: true
}
});
if (!databasesProject) {
return {
name: "ErrorGettingOrganizationProjectDatabases",
message: "No organization found.",
status: 400,
cause: "Unknown error occurred.",
};
}
return {
data: databasesProject.databases,
ids: databasesProject.databases.map((project) => project.id)
}
} catch (e: any) {
const errorMessage = e?.response?.data?.message || e?.message || "Unknown auth error";
const status = e?.response?.status || 500;
console.error("API GettingOrganizationProjectDatabases error:", {
message: errorMessage,
status,
raw: e,
});
throw {
name: "ErrorGettingOrganizationProjectDatabases",
message: errorMessage,
status,
cause: e,
};
} }
const databasesProject = await db.query.project.findFirst({
where: and(
eq(drizzleDb.schemas.project.organizationId, organization.id),
eq(drizzleDb.schemas.project.id, projectId),
),
with: {
databases: true,
},
});
if (!databasesProject) {
return {
name: "ErrorGettingOrganizationProjectDatabases",
message: "No organization found.",
status: 400,
cause: "Unknown error occurred.",
};
}
return {
data: databasesProject.databases,
ids: databasesProject.databases.map((project) => project.id),
};
} catch (e: any) {
const errorMessage =
e?.response?.data?.message || e?.message || "Unknown auth error";
const status = e?.response?.status || 500;
console.error("API GettingOrganizationProjectDatabases error:", {
message: errorMessage,
status,
raw: e,
});
throw {
name: "ErrorGettingOrganizationProjectDatabases",
message: errorMessage,
status,
cause: e,
};
}
}; };
+8 -1
View File
@@ -1,5 +1,12 @@
"use server";
import { db } from "@/db"; import { db } from "@/db";
export async function getSettings() { export async function getSettings() {
return db.query.setting.findFirst(); return db.query.setting.findFirst();
}
export async function isOnboardingDone(): Promise<boolean> {
const settings = await db.query.setting.findFirst();
return settings?.onboarding ?? false;
} }
+55 -23
View File
@@ -1,25 +1,57 @@
import {desc, eq} from "drizzle-orm"; "use server";
import {db} from "@/db";
import {organizationStorageChannel, StorageChannel, storageChannel} from "@/db/schema/12_storage-channel";
export async function getOrganizationStorageChannels(organizationId: string) { import { desc, eq, isNull } from "drizzle-orm";
return await db import { db } from "@/db";
.select({ import {
id: storageChannel.id, organizationStorageChannel,
name: storageChannel.name, StorageChannel,
provider: storageChannel.provider, storageChannel,
organizationId: storageChannel.organizationId, } from "@/db/schema/12_storage-channel";
config: storageChannel.config,
enabled: storageChannel.enabled, export async function getOrganizationStorageChannels(
updatedAt: storageChannel.updatedAt, organizationId: string,
createdAt: storageChannel.createdAt, ): Promise<StorageChannel[]> {
deletedAt: storageChannel.deletedAt, const [orgChannels, systemChannels] = await Promise.all([
}) db
.from(organizationStorageChannel) .select({
.innerJoin( id: storageChannel.id,
storageChannel, name: storageChannel.name,
eq(organizationStorageChannel.storageChannelId, storageChannel.id) provider: storageChannel.provider,
) organizationId: storageChannel.organizationId,
.orderBy(desc(storageChannel.createdAt)) config: storageChannel.config,
.where(eq(organizationStorageChannel.organizationId, organizationId)) as unknown as StorageChannel[]; enabled: storageChannel.enabled,
updatedAt: storageChannel.updatedAt,
createdAt: storageChannel.createdAt,
deletedAt: storageChannel.deletedAt,
})
.from(organizationStorageChannel)
.innerJoin(
storageChannel,
eq(organizationStorageChannel.storageChannelId, storageChannel.id),
)
.orderBy(desc(storageChannel.createdAt))
.where(eq(organizationStorageChannel.organizationId, organizationId)),
db
.select({
id: storageChannel.id,
name: storageChannel.name,
provider: storageChannel.provider,
organizationId: storageChannel.organizationId,
config: storageChannel.config,
enabled: storageChannel.enabled,
updatedAt: storageChannel.updatedAt,
createdAt: storageChannel.createdAt,
deletedAt: storageChannel.deletedAt,
})
.from(storageChannel)
.orderBy(desc(storageChannel.createdAt))
.where(isNull(storageChannel.organizationId)),
]);
const seen = new Set<string>();
return [...orgChannels, ...systemChannels].filter((c) => {
if (seen.has(c.id)) return false;
seen.add(c.id);
return true;
}) as StorageChannel[];
} }
+38 -34
View File
@@ -1,45 +1,49 @@
import {SignUpUser} from "@/types/auth"; "use server";
import {hashPassword} from "better-auth/crypto";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {User, UserThemeEnum} from "@/db/schema/02_user";
import {assertValidPassword} from "@/utils/password";
import { SignUpUser } from "@/types/auth";
import { hashPassword } from "better-auth/crypto";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import { User, UserThemeEnum } from "@/db/schema/02_user";
import { assertValidPassword } from "@/utils/password";
export async function hasUsers(): Promise<boolean> { export async function hasUsers(): Promise<boolean> {
const result = await db.select().from(drizzleDb.schemas.user).limit(1); const result = await db.select().from(drizzleDb.schemas.user).limit(1);
return result.length > 0; 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);
const now = new Date(); const now = new Date();
const userId = crypto.randomUUID(); const userId = crypto.randomUUID();
const [newUser] = await db.insert(drizzleDb.schemas.user).values({ const [newUser] = await db
...data, .insert(drizzleDb.schemas.user)
id: userId, .values({
name: data.name, ...data,
email: data.email, id: userId,
emailVerified: true, name: data.name,
role: data.role, email: data.email,
createdAt: now, emailVerified: true,
updatedAt: now, role: data.role,
theme: data.theme as UserThemeEnum, createdAt: now,
}).returning(); updatedAt: now,
theme: data.theme as UserThemeEnum,
})
.returning();
if (data.password) { if (data.password) {
const hashedPassword = await hashPassword(data.password); const hashedPassword = await hashPassword(data.password);
await db.insert(drizzleDb.schemas.account).values({ await db.insert(drizzleDb.schemas.account).values({
providerId: "credential", providerId: "credential",
accountId: userId, accountId: userId,
userId: userId, userId: userId,
password: hashedPassword, password: hashedPassword,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}); });
} }
return newUser return newUser;
} }
+4
View File
@@ -88,6 +88,8 @@ export const env = createEnv({
ALLOWED_GROUP: z.string().optional(), ALLOWED_GROUP: z.string().optional(),
SKIP_ONBOARDING: z.string().optional().default("false"),
AUTH_EMAIL_PASSWORD_ENABLED: z.string().optional().default("true"), AUTH_EMAIL_PASSWORD_ENABLED: z.string().optional().default("true"),
AUTH_SIGNUP_ENABLED: z.string().optional().default("true"), AUTH_SIGNUP_ENABLED: z.string().optional().default("true"),
AUTH_PASSKEY_ENABLED: z.string().optional().default("false"), AUTH_PASSKEY_ENABLED: z.string().optional().default("false"),
@@ -174,6 +176,8 @@ export const env = createEnv({
ALLOWED_GROUP: process.env.ALLOWED_GROUP, ALLOWED_GROUP: process.env.ALLOWED_GROUP,
SKIP_ONBOARDING: process.env.SKIP_ONBOARDING,
AUTH_EMAIL_PASSWORD_ENABLED: process.env.AUTH_EMAIL_PASSWORD_ENABLED, AUTH_EMAIL_PASSWORD_ENABLED: process.env.AUTH_EMAIL_PASSWORD_ENABLED,
AUTH_SIGNUP_ENABLED: process.env.AUTH_SIGNUP_ENABLED, AUTH_SIGNUP_ENABLED: process.env.AUTH_SIGNUP_ENABLED,
AUTH_PASSKEY_ENABLED: process.env.AUTH_PASSKEY_ENABLED, AUTH_PASSKEY_ENABLED: process.env.AUTH_PASSKEY_ENABLED,
+2 -1
View File
@@ -34,6 +34,7 @@ export const ChannelCard = (props: ChannelCardProps) => {
const isOwned = data.organizationId ? true : !organization; const isOwned = data.organizationId ? true : !organization;
const isLocalSystem = data.provider == "local"; const isLocalSystem = data.provider == "local";
const isSystemChannel = data.organizationId === null;
return ( return (
<div className="block transition-all duration-200 rounded-xl"> <div className="block transition-all duration-200 rounded-xl">
@@ -69,7 +70,7 @@ export const ChannelCard = (props: ChannelCardProps) => {
channel={data} channel={data}
kind={kind} kind={kind}
/> />
{!isLocalSystem && ( {!isLocalSystem && !isSystemChannel && (
<DeleteChannelButton <DeleteChannelButton
kind={kind} kind={kind}
organizationId={organization?.id} organizationId={organization?.id}
@@ -73,6 +73,24 @@ export const removeNotificationChannelAction = userAction.schema(
const {organizationId, notificationChannelId} = parsedInput; const {organizationId, notificationChannelId} = parsedInput;
try { try {
const existing = await db.query.notificationChannel.findFirst({
where: eq(drizzleDb.schemas.notificationChannel.id, notificationChannelId),
});
if (!existing) {
return {
success: false,
actionError: { message: "Notification channel not found.", status: 404, messageParams: { notificationChannelId } },
};
}
if (existing.organizationId === null) {
return {
success: false,
actionError: { message: "System notification channels cannot be deleted.", status: 403, messageParams: { notificationChannelId } },
};
}
if (organizationId) { if (organizationId) {
await db await db
.delete(drizzleDb.schemas.organizationNotificationChannel) .delete(drizzleDb.schemas.organizationNotificationChannel)
@@ -74,6 +74,24 @@ export const removeStorageChannelAction = userAction.schema(
const {organizationId, id} = parsedInput; const {organizationId, id} = parsedInput;
try { try {
const existing = await db.query.storageChannel.findFirst({
where: eq(drizzleDb.schemas.storageChannel.id, id),
});
if (!existing) {
return {
success: false,
actionError: { message: "Storage channel not found.", status: 404, messageParams: { id } },
};
}
if (existing.organizationId === null) {
return {
success: false,
actionError: { message: "System storage channels cannot be deleted.", status: 403, messageParams: { id } },
};
}
if (organizationId) { if (organizationId) {
await db await db
.delete(drizzleDb.schemas.organizationStorageChannel) .delete(drizzleDb.schemas.organizationStorageChannel)
+135 -170
View File
@@ -1,149 +1,73 @@
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; "use client";
import {InfoIcon, Plus, Trash2} from "lucide-react";
import {useFieldArray} from "react-hook-form"; import { ReactNode } from "react";
import {DatabaseWith} from "@/db/schema/07_database"; import { InfoIcon, Plus, Trash2 } from "lucide-react";
import {NotificationChannel} from "@/db/schema/09_notification-channel"; import { useFieldArray } from "react-hook-form";
import {Label} from "@/components/ui/label"; import { toast } from "sonner";
import {Button} from "@/components/ui/button"; import { Label } from "@/components/ui/label";
import {ButtonWithLoading} from "@/components/common/button-with-loading"; import { Button } from "@/components/ui/button";
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select"; import { ButtonWithLoading } from "@/components/common/button-with-loading";
import {MultiSelect} from "@/components/common/multi-select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import {useMutation, useQueryClient} from "@tanstack/react-query"; import { MultiSelect } from "@/components/common/multi-select";
import {toast} from "sonner"; import { Switch } from "@/components/ui/switch";
import {Switch} from "@/components/ui/switch"; import { Card } from "@/components/ui/card";
import {Card} from "@/components/ui/card"; import { useIsMobile } from "@/hooks/use-mobile";
import Link from "next/link"; import { ChannelKind, getChannelIcon, getChannelTextBasedOnKind } from "@/features/channel/channels-helpers";
import {useIsMobile} from "@/hooks/use-mobile"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
import {useRouter} from "next/navigation";
import {
ChannelKind,
getChannelIcon,
getChannelTextBasedOnKind
} from "@/features/channel/channels-helpers";
import {StorageChannel} from "@/db/schema/12_storage-channel";
import { import {
EVENT_KIND_BACKUP_ONLY_OPTIONS, EVENT_KIND_BACKUP_ONLY_OPTIONS,
EVENT_KIND_OPTIONS, EVENT_KIND_OPTIONS,
PoliciesSchema, PoliciesSchema,
PoliciesType, PoliciesType,
PolicyType PolicyType,
} from "@/features/database/channels-policy.schema"; } from "@/features/database/channels-policy.schema";
import {
createAlertPoliciesAction, createStoragePoliciesAction, deleteAlertPoliciesAction, deleteStoragePoliciesAction, export type ChannelEntry = { id: string; name: string; provider: string };
updateAlertPoliciesAction, updateStoragePoliciesAction
} from "@/features/database/channels-policy.action";
import {backupOnly} from "@/features/database/database-tabs";
type ChannelPoliciesFormProps = { type ChannelPoliciesFormProps = {
onSuccess?: () => void; channels: ChannelEntry[];
channels: NotificationChannel[] | StorageChannel[]; defaultPolicies: PolicyType[];
database: DatabaseWith; kind: ChannelKind;
kind: ChannelKind isBackupOnly?: boolean;
isPending?: boolean;
onSave: (policies: PolicyType[]) => Promise<void>;
onCancel?: () => void;
noChannelsMessage?: ReactNode;
}; };
export const ChannelPoliciesForm = ({ export const ChannelPoliciesForm = ({
database, channels,
channels, defaultPolicies,
onSuccess, kind,
kind isBackupOnly = false,
}: ChannelPoliciesFormProps) => { isPending = false,
const queryClient = useQueryClient(); onSave,
const router = useRouter(); onCancel,
noChannelsMessage,
}: ChannelPoliciesFormProps) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const channelText = getChannelTextBasedOnKind(kind); const channelText = getChannelTextBasedOnKind(kind);
const isBackupOnly = backupOnly.some((type) => database.dbms === type);
const organizationChannels = channels.map(c => c.id);
const filterByChannel = <T, K extends keyof T>(
items: T[] | undefined | null,
channelKey: K
): T[] => items?.filter(item => organizationChannels.includes(item[channelKey] as string)) ?? [];
const formattedAlertPolicies = filterByChannel(database.alertPolicies, "notificationChannelId")
.map(({notificationChannelId, eventKinds, enabled}) => ({
channelId: notificationChannelId,
eventKinds,
enabled
}));
const formattedStoragePolicies = filterByChannel(database.storagePolicies, "storageChannelId")
.map(({storageChannelId, enabled}) => ({
channelId: storageChannelId,
enabled
}));
const defaultPolicies: PolicyType[] =
kind === "notification"
? formattedAlertPolicies
: formattedStoragePolicies.map(({ channelId, enabled }) => ({ channelId, enabled }));
const form = useZodForm({ const form = useZodForm({
schema: PoliciesSchema, schema: PoliciesSchema,
defaultValues: { policies: defaultPolicies }, defaultValues: { policies: defaultPolicies },
context: { kind } context: { kind },
}); });
const {fields, append, remove} = useFieldArray({ control: form.control, name: "policies" }); const { fields, append, remove } = useFieldArray({ control: form.control, name: "policies" });
const addPolicy = () => append({channelId: "", eventKinds: [], enabled: true}); const addPolicy = () => append({ channelId: "", eventKinds: [], enabled: true });
const removePolicyHandler = (index: number) => remove(index);
const onCancel = () => { form.reset(); onSuccess?.(); };
const mutation = useMutation({ const handleCancel = () => {
mutationFn: async ({policies}: PoliciesType) => { form.reset();
const payload = policies.map(p => kind === "notification" ? p : { ...p, eventKinds: undefined }); onCancel?.();
};
const policiesToAdd = payload.filter(
(policy) => !defaultPolicies.some((a) => a.channelId === policy.channelId)
);
const policiesToRemove = defaultPolicies.filter(
(policy) => !payload.some((v) => v.channelId === policy.channelId)
);
const policiesToUpdate = payload.filter((policy) => {
const existing = defaultPolicies.find((a) => a.channelId === policy.channelId);
return existing &&
(existing.eventKinds !== policy.eventKinds || existing.enabled !== policy.enabled);
});
const promises = kind === "notification"
? [
policiesToAdd.length > 0 ? await createAlertPoliciesAction({databaseId: database.id, alertPolicies: policiesToAdd}) : null,
policiesToUpdate.length > 0 ? await updateAlertPoliciesAction({databaseId: database.id, alertPolicies: policiesToUpdate}) : null,
policiesToRemove.length > 0 ? await deleteAlertPoliciesAction({databaseId: database.id, alertPolicies: policiesToRemove}) : null,
]
: [
policiesToAdd.length > 0 ? await createStoragePoliciesAction({databaseId: database.id, storagePolicies: policiesToAdd}) : null,
policiesToUpdate.length > 0 ? await updateStoragePoliciesAction({databaseId: database.id, storagePolicies: policiesToUpdate}) : null,
policiesToRemove.length > 0 ? await deleteStoragePoliciesAction({databaseId: database.id, storagePolicies: policiesToRemove}) : null,
];
const results = await Promise.allSettled(promises);
const rejected = results.find((r): r is PromiseRejectedResult => r.status === "rejected");
if (rejected) throw new Error(rejected.reason?.message || "Network or server error");
const failedActions = results
.filter((r): r is PromiseFulfilledResult<any> => r.status === "fulfilled")
.map(r => r.value)
.filter((v): v is { data: { success: false; actionError: any } } => v !== null && v.data?.success === false);
if (failedActions.length > 0) throw new Error(failedActions[0].data.actionError?.message || "One or more operations failed");
return {success: true};
},
onSuccess: () => {
toast.success("Policies saved successfully");
queryClient.invalidateQueries({queryKey: ["database-data", database.id]});
router.refresh();
},
onError: (error: any) => { toast.error(error.message || "Failed to save policies"); },
});
return ( return (
<Form form={form} className="flex flex-col gap-6" onSubmit={ <Form
async (values) => { form={form}
className="flex flex-col gap-6"
onSubmit={async (values) => {
if (kind === "notification") { if (kind === "notification") {
for (const policy of values.policies) { for (const policy of values.policies) {
if (!policy.eventKinds || policy.eventKinds.length === 0) { if (!policy.eventKinds || policy.eventKinds.length === 0) {
@@ -152,10 +76,9 @@ export const ChannelPoliciesForm = ({
} }
} }
} }
await onSave(values.policies);
await mutation.mutateAsync(values) }}
} >
}>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@@ -175,38 +98,44 @@ export const ChannelPoliciesForm = ({
type="button" type="button"
size="sm" size="sm"
className="h-8" className="h-8"
onClick={addPolicy}> onClick={addPolicy}
<Plus className="w-4 h-4 mr-1.5"/> Add Policy >
<Plus className="w-4 h-4 mr-1.5" /> Add Policy
</Button> </Button>
</div> </div>
<div className="space-y-3 w-full"> <div className="space-y-3 w-full">
{channels.length === 0 ? ( {channels.length === 0 ? (
<div className="flex flex-col items-center justify-center p-8 border border-dashed rounded-xl bg-muted/20 text-center gap-2"> <div className="flex flex-col items-center justify-center p-8 border border-dashed rounded-xl bg-muted/20 text-center gap-2">
<InfoIcon className="h-8 w-8 text-muted-foreground/50"/> <InfoIcon className="h-8 w-8 text-muted-foreground/50" />
<p className="font-medium text-sm text-foreground">No channels</p> <p className="font-medium text-sm text-foreground">No channels</p>
<p className="text-xs text-muted-foreground max-w-xs"> {noChannelsMessage ?? (
Please <Link href={`/dashboard/settings`} className="underline underline-offset-4 hover:text-primary transition-colors"> <p className="text-xs text-muted-foreground max-w-xs">
configure {channelText.toLowerCase()} channels No {channelText.toLowerCase()} channels configured.
</Link> in your organization settings first. </p>
</p> )}
</div> </div>
) : fields.length === 0 ? ( ) : fields.length === 0 ? (
<div className="flex flex-col items-center justify-center p-8 border border-dashed rounded-xl bg-muted/20 text-center gap-2"> <div className="flex flex-col items-center justify-center p-8 border border-dashed rounded-xl bg-muted/20 text-center gap-2">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center"> <div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center">
<Plus className="h-4 w-4 text-primary"/> <Plus className="h-4 w-4 text-primary" />
</div> </div>
<p className="font-medium text-sm text-foreground">No policies</p> <p className="font-medium text-sm text-foreground">No policies</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{kind === "notification" ? `Click "Add Policy" to start receiving notifications.` : `Click "Add Policy" to use this storage.`} {kind === "notification"
? `Click "Add Policy" to start receiving notifications.`
: `Click "Add Policy" to use this storage.`}
</p> </p>
</div> </div>
) : ( ) : (
<div className="grid gap-4"> <div className="grid gap-4">
{fields.map((field, index) => ( {fields.map((field, index) => (
<Card key={field.id} className="p-4 transition-all hover:border-primary/50 relative group min-w-0 overflow-hidden"> <Card
key={field.id}
className="p-4 transition-all hover:border-primary/50 relative group min-w-0 overflow-hidden"
>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div className="flex flex-row gap-2 items-start md:items-end flex-nowrap min-w-0 "> <div className="flex flex-row gap-2 items-start md:items-end flex-nowrap min-w-0">
<div className="flex-1 min-w-0 flex flex-col gap-1.5"> <div className="flex-1 min-w-0 flex flex-col gap-1.5">
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest pl-0.5"> <Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest pl-0.5">
{channelText} Channel {channelText} Channel
@@ -214,29 +143,37 @@ export const ChannelPoliciesForm = ({
<FormField <FormField
control={form.control} control={form.control}
name={`policies.${index}.channelId`} name={`policies.${index}.channelId`}
render={({field}) => { render={({ field }) => {
const selectedIds = form.watch("policies").map((a: PolicyType) => a.channelId).filter(Boolean); const selectedIds = form
const availableChannels = channels.filter( .watch("policies")
(channel) => channel.id.toString() === field.value?.toString() || !selectedIds.includes(channel.id.toString()) .map((a: PolicyType) => a.channelId)
.filter(Boolean);
const available = channels.filter(
(c) =>
c.id.toString() === field.value?.toString() ||
!selectedIds.includes(c.id.toString()),
); );
const selectedChannel = channels.find(c => c.id === field.value); const selected = channels.find((c) => c.id === field.value);
return ( return (
<FormItem className="space-y-0 min-w-0"> <FormItem className="space-y-0 min-w-0">
<Select onValueChange={field.onChange} value={field.value?.toString() || ""}> <Select
onValueChange={field.onChange}
value={field.value?.toString() || ""}
>
<FormControl> <FormControl>
<SelectTrigger className="h-9 w-full bg-background border-input min-w-0"> <SelectTrigger className="h-9 w-full bg-background border-input min-w-0">
<SelectValue placeholder="Select channel"> <SelectValue placeholder="Select channel">
{selectedChannel && ( {selected && (
<div className="flex items-center gap-2 min-w-0 w-full"> <div className="flex items-center gap-2 min-w-0 w-full">
<div className="flex items-center justify-center h-4 w-4 shrink-0"> <div className="flex items-center justify-center h-4 w-4 shrink-0">
{getChannelIcon(selectedChannel.provider)} {getChannelIcon(selected.provider)}
</div> </div>
<span className="truncate font-medium text-sm min-w-0"> <span className="truncate font-medium text-sm min-w-0">
{selectedChannel.name} {selected.name}
</span> </span>
<span className="shrink-0 text-[9px] bg-secondary px-1.5 py-0.5 rounded text-muted-foreground font-mono uppercase"> <span className="shrink-0 text-[9px] bg-secondary px-1.5 py-0.5 rounded text-muted-foreground font-mono uppercase">
{selectedChannel.provider} {selected.provider}
</span> </span>
</div> </div>
)} )}
@@ -244,18 +181,22 @@ export const ChannelPoliciesForm = ({
</SelectTrigger> </SelectTrigger>
</FormControl> </FormControl>
<SelectContent> <SelectContent>
{availableChannels.map(channel => ( {available.map((c) => (
<SelectItem key={channel.id.toString()} value={channel.id.toString()}> <SelectItem key={c.id.toString()} value={c.id.toString()}>
<div className="flex items-center gap-2 w-full min-w-0"> <div className="flex items-center gap-2 w-full min-w-0">
<div className="text-muted-foreground scale-90 shrink-0">{getChannelIcon(channel.provider)}</div> <div className="text-muted-foreground scale-90 shrink-0">
<span className="font-medium truncate min-w-0">{channel.name}</span> {getChannelIcon(c.provider)}
<span className="text-xs text-muted-foreground ml-2 capitalize shrink-0">({channel.provider})</span> </div>
<span className="font-medium truncate min-w-0">{c.name}</span>
<span className="text-xs text-muted-foreground ml-2 capitalize shrink-0">
({c.provider})
</span>
</div> </div>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<FormMessage className="mt-1"/> <FormMessage className="mt-1" />
</FormItem> </FormItem>
); );
}} }}
@@ -263,20 +204,30 @@ export const ChannelPoliciesForm = ({
</div> </div>
<div className="flex flex-col gap-1.5 shrink-0"> <div className="flex flex-col gap-1.5 shrink-0">
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest pl-0.5">Status</Label> <Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest pl-0.5">
Status
</Label>
<FormField <FormField
control={form.control} control={form.control}
name={`policies.${index}.enabled`} name={`policies.${index}.enabled`}
render={({field}) => ( render={({ field }) => (
<FormItem className="space-y-0"> <FormItem className="space-y-0">
<FormControl> <FormControl>
<div className="flex items-center h-9 px-1 md:px-3 rounded-md border border-input bg-background justify-between min-w-0"> <div className="flex items-center h-9 px-1 md:px-3 rounded-md border border-input bg-background justify-between min-w-0">
{!isMobile && ( {!isMobile && (
<Label htmlFor={`switch-${index}`} className="text-xs cursor-pointer font-medium text-foreground mr-2"> <Label
htmlFor={`switch-${index}`}
className="text-xs cursor-pointer font-medium text-foreground mr-2"
>
{field.value ? "Active" : "Off"} {field.value ? "Active" : "Off"}
</Label> </Label>
)} )}
<Switch checked={field.value} onCheckedChange={field.onChange} id={`switch-${index}`} className="scale-75 origin-right"/> <Switch
checked={field.value}
onCheckedChange={field.onChange}
id={`switch-${index}`}
className="scale-75 origin-right"
/>
</div> </div>
</FormControl> </FormControl>
</FormItem> </FormItem>
@@ -285,10 +236,14 @@ export const ChannelPoliciesForm = ({
</div> </div>
<div className="flex flex-col gap-1.5 shrink-0 mt-auto"> <div className="flex flex-col gap-1.5 shrink-0 mt-auto">
<Button type="button" variant="outline" size="icon" <Button
className="h-9 w-9 text-muted-foreground hover:text-destructive hover:border-destructive/50 hover:bg-destructive/10 transition-colors border-input bg-background" type="button"
onClick={() => removePolicyHandler(index)}> variant="outline"
<Trash2 className="w-4 h-4"/> size="icon"
className="h-9 w-9 text-muted-foreground hover:text-destructive hover:border-destructive/50 hover:bg-destructive/10 transition-colors border-input bg-background"
onClick={() => remove(index)}
>
<Trash2 className="w-4 h-4" />
</Button> </Button>
</div> </div>
</div> </div>
@@ -297,23 +252,29 @@ export const ChannelPoliciesForm = ({
<FormField <FormField
control={form.control} control={form.control}
name={`policies.${index}.eventKinds`} name={`policies.${index}.eventKinds`}
render={({field}) => ( render={({ field }) => (
<FormItem className="space-y-1.5 min-w-0"> <FormItem className="space-y-1.5 min-w-0">
<FormLabel className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">Trigger Events</FormLabel> <FormLabel className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Trigger Events
</FormLabel>
<FormControl> <FormControl>
<div className="max-w-full overflow-hidden"> <div className="max-w-full overflow-hidden">
<MultiSelect <MultiSelect
options={isBackupOnly ? EVENT_KIND_BACKUP_ONLY_OPTIONS : EVENT_KIND_OPTIONS} options={isBackupOnly ? EVENT_KIND_BACKUP_ONLY_OPTIONS : EVENT_KIND_OPTIONS}
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value ?? []} defaultValue={field.value ?? []}
placeholder={isMobile ? "Select events..." : "Select events to trigger notifications..."} placeholder={
isMobile
? "Select events..."
: "Select events to trigger notifications..."
}
variant="inverted" variant="inverted"
animation={0} animation={0}
className="bg-background/50 w-full min-w-0 flex-wrap" className="bg-background/50 w-full min-w-0 flex-wrap"
/> />
</div> </div>
</FormControl> </FormControl>
<FormMessage/> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
@@ -327,9 +288,13 @@ export const ChannelPoliciesForm = ({
</div> </div>
<div className="flex gap-3 justify-end pt-2 border-t mt-2"> <div className="flex gap-3 justify-end pt-2 border-t mt-2">
<ButtonWithLoading variant="outline" type="button" onClick={onCancel}>Cancel</ButtonWithLoading> {onCancel && (
<ButtonWithLoading isPending={mutation.isPending}>Save Changes</ButtonWithLoading> <ButtonWithLoading variant="outline" type="button" onClick={handleCancel}>
Cancel
</ButtonWithLoading>
)}
<ButtonWithLoading isPending={isPending}>Save Changes</ButtonWithLoading>
</div> </div>
</Form> </Form>
); );
}; };
+128 -32
View File
@@ -1,22 +1,36 @@
"use client" "use client";
import {ReactNode, useState} from "react";
import { ReactNode, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogDescription,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger DialogTrigger,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {Button} from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {DatabaseWith} from "@/db/schema/07_database"; import { Separator } from "@/components/ui/separator";
import {NotificationChannel} from "@/db/schema/09_notification-channel"; import { Badge } from "@/components/ui/badge";
import {Separator} from "@/components/ui/separator"; import Link from "next/link";
import {Badge} from "@/components/ui/badge"; import { DatabaseWith } from "@/db/schema/07_database";
import {StorageChannel} from "@/db/schema/12_storage-channel"; import { NotificationChannel } from "@/db/schema/09_notification-channel";
import {ChannelKind, getChannelTextBasedOnKind} from "@/features/channel/channels-helpers"; import { StorageChannel } from "@/db/schema/12_storage-channel";
import {ChannelPoliciesForm} from "@/features/database/channels-policy-form"; import { ChannelKind, getChannelTextBasedOnKind } from "@/features/channel/channels-helpers";
import { ChannelPoliciesForm } from "@/features/database/channels-policy-form";
import { PolicyType } from "@/features/database/channels-policy.schema";
import {
createAlertPoliciesAction,
createStoragePoliciesAction,
deleteAlertPoliciesAction,
deleteStoragePoliciesAction,
updateAlertPoliciesAction,
updateStoragePoliciesAction,
} from "@/features/database/channels-policy.action";
import { backupOnly } from "@/features/database/database-tabs";
type ChannelPoliciesModalProps = { type ChannelPoliciesModalProps = {
database: DatabaseWith; database: DatabaseWith;
@@ -24,24 +38,93 @@ type ChannelPoliciesModalProps = {
organizationId: string; organizationId: string;
kind: ChannelKind; kind: ChannelKind;
icon: ReactNode; icon: ReactNode;
};
} export const ChannelPoliciesModal = ({ icon, kind, database, channels, organizationId }: ChannelPoliciesModalProps) => {
export const ChannelPoliciesModal = ({icon, kind, database, channels, organizationId}: ChannelPoliciesModalProps) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const channelText = getChannelTextBasedOnKind(kind) const queryClient = useQueryClient();
const router = useRouter();
const channelText = getChannelTextBasedOnKind(kind);
const channelsFiltered = channels.filter((c) => c.enabled);
const channelIds = channelsFiltered.map((c) => c.id);
const channelsFiltered = channels const defaultPolicies: PolicyType[] =
.filter((channel) => channel.enabled) kind === "notification"
? (database.alertPolicies ?? [])
.filter((p) => channelIds.includes(p.notificationChannelId))
.map(({ notificationChannelId, eventKinds, enabled }) => ({
channelId: notificationChannelId,
eventKinds,
enabled,
}))
: (database.storagePolicies ?? [])
.filter((p) => channelIds.includes(p.storageChannelId))
.map(({ storageChannelId, enabled }) => ({ channelId: storageChannelId, enabled }));
const channelsIds = channelsFiltered const activePolicies = kind === "notification"
.map(channel => channel.id); ? database.alertPolicies?.filter((p) => channelIds.includes(p.notificationChannelId))
const activeAlertPolicies = database.alertPolicies?.filter((policy) => channelsIds.includes(policy.notificationChannelId)); : database.storagePolicies?.filter((p) => channelIds.includes(p.storageChannelId));
const activeStoragePolicies = database.storagePolicies?.filter((policy) => channelsIds.includes(policy.storageChannelId));
const mutation = useMutation({
mutationFn: async (policies: PolicyType[]) => {
const payload = policies.map((p) =>
kind === "notification" ? p : { ...p, eventKinds: undefined },
);
const activePolicies = kind === "notification" ? activeAlertPolicies : activeStoragePolicies; const toAdd = payload.filter((p) => !defaultPolicies.some((d) => d.channelId === p.channelId));
const toRemove = defaultPolicies.filter((d) => !payload.some((p) => p.channelId === d.channelId));
const toUpdate = payload.filter((p) => {
const existing = defaultPolicies.find((d) => d.channelId === p.channelId);
return existing && (existing.eventKinds !== p.eventKinds || existing.enabled !== p.enabled);
});
const results = await Promise.allSettled(
kind === "notification"
? [
toAdd.length > 0
? createAlertPoliciesAction({ databaseId: database.id, alertPolicies: toAdd })
: null,
toUpdate.length > 0
? updateAlertPoliciesAction({ databaseId: database.id, alertPolicies: toUpdate })
: null,
toRemove.length > 0
? deleteAlertPoliciesAction({ databaseId: database.id, alertPolicies: toRemove })
: null,
]
: [
toAdd.length > 0
? createStoragePoliciesAction({ databaseId: database.id, storagePolicies: toAdd })
: null,
toUpdate.length > 0
? updateStoragePoliciesAction({ databaseId: database.id, storagePolicies: toUpdate })
: null,
toRemove.length > 0
? deleteStoragePoliciesAction({ databaseId: database.id, storagePolicies: toRemove })
: null,
],
);
const rejected = results.find((r): r is PromiseRejectedResult => r.status === "rejected");
if (rejected) throw new Error(rejected.reason?.message || "Network or server error");
const failed = results
.filter((r): r is PromiseFulfilledResult<any> => r.status === "fulfilled")
.map((r) => r.value)
.filter((v): v is { data: { success: false; actionError: any } } => v !== null && v.data?.success === false);
if (failed.length > 0) throw new Error(failed[0].data.actionError?.message || "One or more operations failed");
},
onSuccess: () => {
toast.success("Policies saved successfully");
queryClient.invalidateQueries({ queryKey: ["database-data", database.id] });
router.refresh();
setOpen(false);
},
onError: (error: any) => {
toast.error(error.message || "Failed to save policies");
},
});
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
@@ -49,9 +132,7 @@ export const ChannelPoliciesModal = ({icon, kind, database, channels, organizati
<Button variant="outline" onClick={() => setOpen(true)} className="relative"> <Button variant="outline" onClick={() => setOpen(true)} className="relative">
{icon} {icon}
{activePolicies && activePolicies.length > 0 && ( {activePolicies && activePolicies.length > 0 && (
<Badge <Badge className="absolute -top-1.5 -right-1.5 h-4 w-4 rounded-full p-0 text-[10px] flex items-center justify-center">
className="absolute -top-1.5 -right-1.5 h-4 w-4 rounded-full p-0 text-[10px] flex items-center justify-center"
>
{activePolicies.length} {activePolicies.length}
</Badge> </Badge>
)} )}
@@ -63,15 +144,30 @@ export const ChannelPoliciesModal = ({icon, kind, database, channels, organizati
<DialogDescription> <DialogDescription>
Add and manage your database {channelText.toLowerCase()} policies Add and manage your database {channelText.toLowerCase()} policies
</DialogDescription> </DialogDescription>
<Separator className="mt-3 mb-3"/> <Separator className="mt-3 mb-3" />
<ChannelPoliciesForm <ChannelPoliciesForm
channels={channels} channels={channelsFiltered.map((c) => ({ id: c.id, name: c.name, provider: c.provider }))}
database={database} defaultPolicies={defaultPolicies}
onSuccess={() => setOpen(false)}
kind={kind} kind={kind}
isBackupOnly={backupOnly.some((t) => database.dbms === t)}
isPending={mutation.isPending}
onSave={mutation.mutateAsync}
onCancel={() => setOpen(false)}
noChannelsMessage={
<p className="text-xs text-muted-foreground max-w-xs">
Please{" "}
<Link
href="/dashboard/settings"
className="underline underline-offset-4 hover:text-primary transition-colors"
>
configure {channelText.toLowerCase()} channels
</Link>{" "}
in your organization settings first.
</p>
}
/> />
</DialogHeader> </DialogHeader>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) );
} };
+280 -292
View File
@@ -1,306 +1,294 @@
"use client" "use client";
import { import {
Form, Form,
FormControl, FormControl,
FormDescription, FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
FormMessage, FormMessage,
useZodForm useZodForm,
} from "@/components/ui/form"; } from "@/components/ui/form";
import {RetentionSettings, RetentionSettingsSchema} from "@/features/database/retention-policy.schema"; import {
import {useMutation, useQueryClient} from "@tanstack/react-query"; RetentionSettings,
import {useRouter} from "next/navigation"; RetentionSettingsSchema,
import {updateOrCreateBackupRetentionPolicyAction} from "@/features/database/retention-policy.action"; } from "@/features/database/retention-policy.schema";
import {DatabaseWith, RetentionPolicy} from "@/db/schema/07_database"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import {toast} from "sonner"; import { Badge } from "@/components/ui/badge";
import {RadioGroup, RadioGroupItem} from "@/components/ui/radio-group"; import { Separator } from "@/components/ui/separator";
import {Badge} from "@/components/ui/badge"; import { Input } from "@/components/ui/input";
import {Separator} from "@/components/ui/separator"; import { Button } from "@/components/ui/button";
import {Input} from "@/components/ui/input"; import { Calendar, Save } from "lucide-react";
import {Button} from "@/components/ui/button";
import {Calendar, Save} from "lucide-react";
export type BackupRetentionSettingsFormProps = { export type BackupRetentionSettingsFormProps = {
defaultValues?: RetentionPolicy; defaultValues?: RetentionSettings;
database: DatabaseWith; currentType?: string;
isPending?: boolean;
onSave: (values: RetentionSettings) => Promise<void>;
}; };
export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRetentionSettingsFormProps) => { export const BackupRetentionSettingsForm = ({
const queryClient = useQueryClient(); defaultValues,
const router = useRouter(); currentType,
isPending = false,
onSave,
}: BackupRetentionSettingsFormProps) => {
const form = useZodForm({
schema: RetentionSettingsSchema,
defaultValues: defaultValues ?? {
count: 7,
days: 30,
gfs: { daily: 7, weekly: 4, monthly: 12, yearly: 3 },
},
});
const defaultValuesFormatted: RetentionSettings = { const calculateTotalFiles = (values: RetentionSettings) => {
type: defaultValues?.type, if (values.type === "gfs" && values.gfs) {
count: defaultValues?.count ?? 7, return (
days: defaultValues?.days ?? 30, (values.gfs.daily ?? 0) +
gfs: { (values.gfs.weekly ?? 0) +
daily: defaultValues?.gfsDaily ?? 7, (values.gfs.monthly ?? 0) +
weekly: defaultValues?.gfsWeekly ?? 4, (values.gfs.yearly ?? 0)
monthly: defaultValues?.gfsMonthly ?? 12, );
yearly: defaultValues?.gfsYearly ?? 3, }
}, return values.type === "count"
}; ? (values.count ?? 0)
: values.type === "days"
? (values.days ?? 0)
: 0;
};
const form = useZodForm({ const getStorageEstimate = (totalFiles: number) => {
schema: RetentionSettingsSchema, if (totalFiles <= 10) return "Low";
defaultValues: defaultValuesFormatted, if (totalFiles <= 30) return "Medium";
}); return "High";
};
const mutation = useMutation({ return (
mutationFn: async (payload: RetentionSettings) => <div className="flex flex-col gap-3 py-0">
await updateOrCreateBackupRetentionPolicyAction({ <div className="px-3">
databaseId: database.id, <Form
settings: payload, form={form}
}), className="flex flex-col gap-6 mt-0"
onSuccess: () => { onSubmit={async (values) => {
toast.success("Retention policy updated successfully."); await onSave(values);
queryClient.invalidateQueries({queryKey: ["database-data", database.id]}); }}
router.refresh(); >
}, <FormField
onError: () => { control={form.control}
toast.error("An error occurred while updating retention policy."); name="type"
}, render={({ field }) => (
}); <FormItem>
<FormLabel>Retention Policy Type</FormLabel>
const calculateTotalFiles = (values: RetentionSettings) => { <FormControl>
if (values.type === "gfs" && values.gfs) { <RadioGroup
return ( value={field.value ?? ""}
(values.gfs.daily ?? 0) + onValueChange={field.onChange}
(values.gfs.weekly ?? 0) + className="grid grid-cols-1 gap-4"
(values.gfs.monthly ?? 0) + >
(values.gfs.yearly ?? 0) {[
); {
} id: "count",
return values.type === "count" ? values.count ?? 0 : values.type === "days" ? values.days ?? 0 : 0; label: "Keep last N backups",
}; desc: "Simple count-based retention (e.g., keep last 10 backups)",
},
const getStorageEstimate = (totalFiles: number) => { {
if (totalFiles <= 10) return "Low"; id: "days",
if (totalFiles <= 30) return "Medium"; label: "Keep backups for X days",
return "High"; desc: "Time-based retention (e.g., keep backups for 30 days)",
}; },
{
return ( id: "gfs",
<div className="flex flex-col gap-3 py-0"> label: "GFS Rotation",
<div className="px-3"> desc: "Grandfather-Father-Son rotation for enterprise/critical systems",
<Form badge: "Recommended",
form={form} },
className="flex flex-col gap-6 mt-0" ].map((opt) => (
onSubmit={async (values) => { <FormLabel
await mutation.mutateAsync(values); key={opt.id}
}} htmlFor={opt.id}
> className={`flex items-center space-x-3 rounded-lg border p-4 transition-colors cursor-pointer ${
<FormField field.value === opt.id
control={form.control} ? "border-primary bg-primary/5"
name="type" : "hover:bg-muted/50"
render={({field}) => ( }`}
<FormItem> >
<FormLabel>Retention Policy Type</FormLabel> <RadioGroupItem value={opt.id} id={opt.id} />
<FormControl> <div className="flex-1">
<RadioGroup <span className="font-medium flex items-center gap-2">
value={field.value ?? ""} {opt.label}
onValueChange={field.onChange} {opt.badge && (
className="grid grid-cols-1 gap-4" <Badge variant="secondary" className="text-xs">
> {opt.badge}
{[ </Badge>
{
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) => (
<FormLabel
key={opt.id}
htmlFor={opt.id}
className={`flex items-center space-x-3 rounded-lg border p-4 transition-colors cursor-pointer ${
field.value === 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>
{database.retentionPolicy?.type === opt.id && (
<Badge variant="secondary" className="text-xs">
Actual
</Badge>
)}
</FormLabel>
))}
</RadioGroup>
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
{form.watch("type") && (
<Separator/>
)}
{form.watch("type") === "count" && (
<FormField
control={form.control}
name="count"
render={({field}) => (
<FormItem>
<FormLabel>Number of backups to keep</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={100}
className="w-32"
{...field}
onChange={(e) => field.onChange(e.target.valueAsNumber)}
/>
</FormControl>
<FormDescription>
Older backups beyond this count will be automatically deleted.
</FormDescription>
<FormMessage/>
</FormItem>
)} )}
/> </span>
)} <p className="text-sm text-muted-foreground">
{opt.desc}
{form.watch("type") === "days" && ( </p>
<FormField
control={form.control}
name="days"
render={({field}) => (
<FormItem>
<FormLabel>Retention period (days)</FormLabel>
<FormControl>
<Input
type="number"
min={1}
max={3650}
className="w-32"
{...field}
onChange={(e) => field.onChange(e.target.valueAsNumber)}
/>
</FormControl>
<FormDescription>
Backups older than {field.value} days will be automatically deleted.
</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
)}
{form.watch("type") === "gfs" && (
<div className="space-y-4">
{["daily", "weekly", "monthly", "yearly"].map((key) => (
<FormField
key={key}
control={form.control}
name={`gfs.${key}` as const}
render={({field}) => (
<FormItem>
<FormLabel>
{key.charAt(0).toUpperCase() + key.slice(1)} backups
</FormLabel>
<FormControl>
<Input
type="number"
min={0}
max={key === "yearly" ? 50 : key === "monthly" ? 120 : key === "weekly" ? 52 : 31}
{...field}
onChange={(e) => field.onChange(e.target.valueAsNumber)}
/>
</FormControl>
<FormDescription>
Keep N {key} backups
</FormDescription>
<FormMessage/>
</FormItem>
)}
/>
))}
</div> </div>
)} {currentType === opt.id && (
<Badge variant="secondary" className="text-xs">
Actual
</Badge>
)}
</FormLabel>
))}
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{form.watch("type") && ( {form.watch("type") && <Separator />}
<>
<Separator/> {form.watch("type") === "count" && (
<div className="rounded-lg border p-4 space-y-3 bg-card"> <FormField
<div className="flex items-center justify-between"> control={form.control}
<div className="flex items-center gap-2"> name="count"
<Calendar className="h-4 w-4 text-muted-foreground"/> render={({ field }) => (
<span className="font-medium">Storage Impact Summary</span> <FormItem>
</div> <FormLabel>Number of backups to keep</FormLabel>
{(() => { <FormControl>
const totalFiles = calculateTotalFiles(form.getValues()); <Input
const estimate = getStorageEstimate(totalFiles); type="number"
return ( min={1}
<Badge max={100}
variant={ className="w-32"
estimate === "Low" {...field}
? "default" onChange={(e) => field.onChange(e.target.valueAsNumber)}
: estimate === "Medium" />
? "secondary" </FormControl>
: "destructive" <FormDescription>
} Older backups beyond this count will be automatically
> deleted.
{estimate} Usage </FormDescription>
</Badge> <FormMessage />
); </FormItem>
})()} )}
</div> />
<div className="grid grid-cols-2 gap-4 text-sm"> )}
<div>
<span className="text-muted-foreground">Estimated files per database:</span> {form.watch("type") === "days" && (
<p className="font-medium"> <FormField
{calculateTotalFiles(form.getValues())} backup files control={form.control}
</p> name="days"
</div> render={({ field }) => (
<div> <FormItem>
<span className="text-muted-foreground">Policy type:</span> <FormLabel>Retention period (days)</FormLabel>
<p className="font-medium capitalize"> <FormControl>
{form.watch("type") === "gfs" <Input
? "GFS Rotation" type="number"
: form.watch("type") === "count" min={1}
? "Count-based" max={3650}
: "Time-based"} className="w-32"
</p> {...field}
</div> onChange={(e) => field.onChange(e.target.valueAsNumber)}
</div> />
</div> </FormControl>
<Button type="submit" disabled={mutation.isPending} className="w-full"> <FormDescription>
<Save className="h-4 w-4 mr-2"/> Backups older than {field.value} days will be automatically
{mutation.isPending ? "Saving Policy..." : "Save Retention Policy"} deleted.
</Button> </FormDescription>
</> <FormMessage />
)} </FormItem>
</Form> )}
/>
)}
{form.watch("type") === "gfs" && (
<div className="space-y-4">
{[
{ key: "daily" as const, label: "Daily", max: 31 },
{ key: "weekly" as const, label: "Weekly", max: 52 },
{ key: "monthly" as const, label: "Monthly", max: 120 },
{ key: "yearly" as const, label: "Yearly", max: 50 },
].map(({ key, label, max }) => (
<FormField
key={key}
control={form.control}
name={`gfs.${key}`}
render={({ field }) => (
<FormItem>
<FormLabel>{label} backups</FormLabel>
<FormControl>
<Input
type="number"
min={0}
max={max}
{...field}
onChange={(e) =>
field.onChange(e.target.valueAsNumber)
}
/>
</FormControl>
<FormDescription>Keep N {key} backups</FormDescription>
<FormMessage />
</FormItem>
)}
/>
))}
</div> </div>
</div> )}
);
{form.watch("type") && (
<>
<Separator />
<div className="rounded-lg border p-4 space-y-3 bg-card">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Calendar className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">Storage Impact Summary</span>
</div>
{(() => {
const totalFiles = calculateTotalFiles(form.getValues());
const estimate = getStorageEstimate(totalFiles);
return (
<Badge
variant={
estimate === "Low"
? "default"
: estimate === "Medium"
? "secondary"
: "destructive"
}
>
{estimate} Usage
</Badge>
);
})()}
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">
Estimated files per database:
</span>
<p className="font-medium">
{calculateTotalFiles(form.getValues())} backup files
</p>
</div>
<div>
<span className="text-muted-foreground">Policy type:</span>
<p className="font-medium capitalize">
{form.watch("type") === "gfs"
? "GFS Rotation"
: form.watch("type") === "count"
? "Count-based"
: "Time-based"}
</p>
</div>
</div>
</div>
<Button type="submit" disabled={isPending} className="w-full">
<Save className="h-4 w-4 mr-2" />
{isPending ? "Saving Policy..." : "Save Retention Policy"}
</Button>
</>
)}
</Form>
</div>
</div>
);
}; };
@@ -1,16 +1,52 @@
import {Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger} from "@/components/ui/sheet"; "use client";
import {Button} from "@/components/ui/button";
import {Database, Ruler} from "lucide-react"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import {DatabaseWith as DbSchema, RetentionPolicy} from "@/db/schema/07_database"; import { useRouter } from "next/navigation";
import { import { toast } from "sonner";
BackupRetentionSettingsForm import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
} from "@/features/database/retention-policy-form"; import { Button } from "@/components/ui/button";
import { Database, Ruler } from "lucide-react";
import { DatabaseWith, RetentionPolicy } from "@/db/schema/07_database";
import { BackupRetentionSettingsForm } from "@/features/database/retention-policy-form";
import { RetentionSettings } from "@/features/database/retention-policy.schema";
import { updateOrCreateBackupRetentionPolicyAction } from "@/features/database/retention-policy.action";
type RetentionPolicySheetProps = { type RetentionPolicySheetProps = {
database: DbSchema database: DatabaseWith;
} };
const toRetentionSettings = (rp: RetentionPolicy | undefined | null): RetentionSettings | undefined => {
if (!rp) return undefined;
return {
type: rp.type,
count: rp.count ?? 7,
days: rp.days ?? 30,
gfs: {
daily: rp.gfsDaily ?? 7,
weekly: rp.gfsWeekly ?? 4,
monthly: rp.gfsMonthly ?? 12,
yearly: rp.gfsYearly ?? 3,
},
};
};
export const RetentionPolicySheet = ({ database }: RetentionPolicySheetProps) => {
const queryClient = useQueryClient();
const router = useRouter();
const mutation = useMutation({
mutationFn: async (payload: RetentionSettings) =>
updateOrCreateBackupRetentionPolicyAction({ databaseId: database.id, settings: payload }),
onSuccess: () => {
toast.success("Retention policy updated successfully.");
queryClient.invalidateQueries({ queryKey: ["database-data", database.id] });
router.refresh();
},
onError: () => {
toast.error("An error occurred while updating retention policy.");
},
});
export const RetentionPolicySheet = ({database}: RetentionPolicySheetProps) => {
return ( return (
<Sheet> <Sheet>
<SheetTrigger asChild> <SheetTrigger asChild>
@@ -18,32 +54,32 @@ export const RetentionPolicySheet = ({database}: RetentionPolicySheetProps) => {
<Ruler /> <Ruler />
</Button> </Button>
</SheetTrigger> </SheetTrigger>
<SheetContent <SheetContent className="flex gap-4 p-4 w-full md:w-[800px] max-w-[800px] max-h-screen overflow-y-scroll">
className="flex gap-4 p-4 w-full md:w-[800px] max-w-[800px] max-h-screen overflow-y-scroll"
>
<SheetHeader> <SheetHeader>
<SheetTitle className="flex items-center gap-2 text-balance"> <SheetTitle className="flex items-center gap-2 text-balance">
<Database className="h-5 w-5"/> <Database className="h-5 w-5" />
Backup Retention Policy Backup Retention Policy
</SheetTitle> </SheetTitle>
<SheetDescription className="text-pretty"> <SheetDescription className="text-pretty">
Configure how long to keep your .dump backup files. Choose from simple count-based, time-based, Configure how long to keep your .dump backup files. Choose from simple count-based, time-based,
or or enterprise GFS rotation strategies.
enterprise GFS rotation strategies.
</SheetDescription> </SheetDescription>
</SheetHeader> </SheetHeader>
{database.backupPolicy !== null ? {database.backupPolicy !== null ? (
<BackupRetentionSettingsForm database={database} <BackupRetentionSettingsForm
defaultValues={database.retentionPolicy as RetentionPolicy}/> defaultValues={toRetentionSettings(database.retentionPolicy as RetentionPolicy)}
: currentType={database.retentionPolicy?.type}
<div isPending={mutation.isPending}
className="flex flex-col items-center justify-center text-center py-12 gap-4 border rounded-lg"> onSave={async (values) => { await mutation.mutateAsync(values); }}
/>
) : (
<div className="flex flex-col items-center justify-center text-center py-12 gap-4 border rounded-lg">
<p className="text-muted-foreground"> <p className="text-muted-foreground">
No backup policy configured yet. Please configure one ! No backup policy configured yet. Please configure one!
</p> </p>
</div> </div>
} )}
</SheetContent> </SheetContent>
</Sheet> </Sheet>
) );
} };
@@ -3,16 +3,20 @@ import { getAccounts, getSession, getSessions } from "@/lib/auth/auth";
import { LoggedInButtonClient } from "./logged-in-button"; import { LoggedInButtonClient } from "./logged-in-button";
import { SUPPORTED_PROVIDERS } from "@/lib/auth/config"; import { SUPPORTED_PROVIDERS } from "@/lib/auth/config";
import { env } from "@/env.mjs"; import { env } from "@/env.mjs";
import { getSettings } from "@/db/services/setting";
import { resolveAvatarUrl } from "@/utils/resolve-avatar-url";
export const LoggedInButton = async () => { export const LoggedInButton = async () => {
const user = await currentUser(); const [user, sessions, currentSession, accounts, settings] = await Promise.all([
const sessions = await getSessions(); currentUser(),
const currentSession = await getSession(); getSessions(),
const accounts = await getAccounts(); getSession(),
getAccounts(),
getSettings(),
]);
if (!user) return null; if (!user) return null;
return ( return (
<LoggedInButtonClient <LoggedInButtonClient
user={user} user={user}
@@ -22,6 +26,8 @@ export const LoggedInButton = async () => {
accounts={accounts} accounts={accounts}
providers={SUPPORTED_PROVIDERS.filter((p) => p.isActive)} providers={SUPPORTED_PROVIDERS.filter((p) => p.isActive)}
apiEnabled={env.API_ENABLED} apiEnabled={env.API_ENABLED}
avatarMode={settings?.avatarMode ?? "internal"}
avatarUrl={resolveAvatarUrl(user, settings)}
/> />
); );
}; };
+7 -2
View File
@@ -7,6 +7,7 @@ import { LoggedInDropdown } from "./logged-in-dropdown";
import { Account, Session } from "better-auth"; import { Account, Session } from "better-auth";
import { AuthProviderConfig } from "@/lib/auth/config"; import { AuthProviderConfig } from "@/lib/auth/config";
import {User} from "@/db/schema/02_user"; import {User} from "@/db/schema/02_user";
import type { AvatarMode } from "@/features/onboarding/types";
type LoggedInButtonClientProps = { type LoggedInButtonClientProps = {
user: User; user: User;
@@ -15,9 +16,11 @@ type LoggedInButtonClientProps = {
accounts: Account[]; accounts: Account[];
providers: AuthProviderConfig[]; providers: AuthProviderConfig[];
apiEnabled: boolean; apiEnabled: boolean;
avatarMode?: AvatarMode;
avatarUrl?: string;
}; };
export const LoggedInButtonClient = ({ user, sessions, currentSession, accounts, providers, apiEnabled }: LoggedInButtonClientProps) => { export const LoggedInButtonClient = ({ user, sessions, currentSession, accounts, providers, apiEnabled, avatarMode, avatarUrl }: LoggedInButtonClientProps) => {
return ( return (
<LoggedInDropdown <LoggedInDropdown
user={user} user={user}
@@ -29,12 +32,14 @@ export const LoggedInButtonClient = ({ user, sessions, currentSession, accounts,
accounts={accounts} accounts={accounts}
providers={providers} providers={providers}
apiEnabled={apiEnabled} apiEnabled={apiEnabled}
avatarMode={avatarMode}
avatarUrl={avatarUrl}
> >
<SidebarMenuButton type="button" className="h-auto justify-between py-2" data-testid="profile-dropdown"> <SidebarMenuButton type="button" className="h-auto justify-between py-2" data-testid="profile-dropdown">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Avatar className="size-6"> <Avatar className="size-6">
<AvatarFallback>{(user.name?.[0] ?? user.email?.[0] ?? "?").toUpperCase()}</AvatarFallback> <AvatarFallback>{(user.name?.[0] ?? user.email?.[0] ?? "?").toUpperCase()}</AvatarFallback>
{user.image && <AvatarImage src={user.image} />} {avatarUrl && <AvatarImage src={avatarUrl} />}
</Avatar> </Avatar>
<div className="flex flex-col items-start"> <div className="flex flex-col items-start">
<span className="text-sm font-medium first-letter:capitalize max-w-[170px] truncate">{user.name}</span> <span className="text-sm font-medium first-letter:capitalize max-w-[170px] truncate">{user.name}</span>
+6 -1
View File
@@ -8,6 +8,7 @@ import { signOut } from "@/lib/auth/auth-client";
import { ProfileModal } from "@/features/layout/profile-modal"; import { ProfileModal } from "@/features/layout/profile-modal";
import { Account, Session, User as UserType } from "@/db/schema/02_user"; import { Account, Session, User as UserType } from "@/db/schema/02_user";
import { AuthProviderConfig } from "@/lib/auth/config"; import { AuthProviderConfig } from "@/lib/auth/config";
import type { AvatarMode } from "@/features/onboarding/types";
export type LoggedInDropdownProps = PropsWithChildren<{ export type LoggedInDropdownProps = PropsWithChildren<{
user: UserType; user: UserType;
@@ -17,9 +18,11 @@ export type LoggedInDropdownProps = PropsWithChildren<{
children: ReactNode; children: ReactNode;
providers: AuthProviderConfig[]; providers: AuthProviderConfig[];
apiEnabled: boolean; apiEnabled: boolean;
avatarMode?: AvatarMode;
avatarUrl?: string;
}>; }>;
export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children, providers, apiEnabled }: LoggedInDropdownProps) => { export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, children, providers, apiEnabled, avatarMode, avatarUrl }: LoggedInDropdownProps) => {
const router = useRouter(); const router = useRouter();
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
@@ -35,6 +38,8 @@ export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, chi
onOpenChange={setIsModalOpen} onOpenChange={setIsModalOpen}
providers={providers} providers={providers}
apiEnabled={apiEnabled} apiEnabled={apiEnabled}
avatarMode={avatarMode}
avatarUrl={avatarUrl}
/> />
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger> <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
+5 -2
View File
@@ -6,6 +6,7 @@ import { ProfileSidebar } from "./profile-sidebar";
import type { AuthProviderConfig } from "@/lib/auth/config"; import type { AuthProviderConfig } from "@/lib/auth/config";
import { User, Session, Account } from "@/db/schema/02_user"; import { User, Session, Account } from "@/db/schema/02_user";
import { ProfileGeneral } from "@/features/profile/profile-general"; import { ProfileGeneral } from "@/features/profile/profile-general";
import type { AvatarMode } from "@/features/onboarding/types";
import { ProfileSecurity } from "@/features/profile/profile-security"; import { ProfileSecurity } from "@/features/profile/profile-security";
import { ProfileProviders } from "@/features/profile/profile-providers"; import { ProfileProviders } from "@/features/profile/profile-providers";
import { ProfileAccount } from "@/features/profile/profile-account"; import { ProfileAccount } from "@/features/profile/profile-account";
@@ -20,9 +21,11 @@ type ProfileModalProps = {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
providers: AuthProviderConfig[]; providers: AuthProviderConfig[];
apiEnabled: boolean; apiEnabled: boolean;
avatarMode?: AvatarMode;
avatarUrl?: string;
}; };
export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange, providers, apiEnabled }: ProfileModalProps) => { export const ProfileModal = ({ user, sessions, currentSession, accounts, open, onOpenChange, providers, apiEnabled, avatarMode, avatarUrl }: ProfileModalProps) => {
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="w-[95vw] h-[90vh] max-w-md lg:max-w-[1000px] lg:h-[800px] pb-6 p-0 overflow-hidden flex flex-col outline-none gap-0 rounded-xl bg-background"> <DialogContent className="w-[95vw] h-[90vh] max-w-md lg:max-w-[1000px] lg:h-[800px] pb-6 p-0 overflow-hidden flex flex-col outline-none gap-0 rounded-xl bg-background">
@@ -35,7 +38,7 @@ export const ProfileModal = ({ user, sessions, currentSession, accounts, open, o
<div className="flex-1 overflow-y-auto bg-background h-full scroll-smooth"> <div className="flex-1 overflow-y-auto bg-background h-full scroll-smooth">
<TabsContent value="profile" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0"> <TabsContent value="profile" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0">
<ProfileGeneral user={user} /> <ProfileGeneral user={user} avatarMode={avatarMode} avatarUrl={avatarUrl} />
</TabsContent> </TabsContent>
<TabsContent value="security" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0"> <TabsContent value="security" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0">
@@ -1,62 +1,13 @@
"use server"; "use server";
import { z } from "zod";
import { db } from "@/db"; import { db } from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { userAction } from "@/lib/safe-actions/actions"; import { userAction } from "@/lib/safe-actions/actions";
import { ApplyDbSettingsSchema } from "@/features/onboarding/schemas/db-settings.schema";
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),
}),
});
const EventKindSchema = z.enum([
"error_backup",
"error_restore",
"success_restore",
"success_backup",
"weekly_report",
"error_health_agent",
"error_health_database",
]);
const NotifPolicySchema = z.object({
channelId: z.string().min(1),
eventKinds: z.array(EventKindSchema),
enabled: z.boolean(),
});
const StoragePolicyInputSchema = z.object({
channelId: z.string().min(1),
enabled: z.boolean(),
});
export const applyOnboardingDbSettingsAction = userAction export const applyOnboardingDbSettingsAction = userAction
.schema( .schema(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(),
}),
)
.action(async ({ parsedInput }) => { .action(async ({ parsedInput }) => {
const { const {
databaseId, databaseId,
@@ -1,17 +1,11 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { import { cn } from "@/lib/utils";
Select, import { isValidCronPart } from "@/utils/cron";
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import { AdvancedCronSelect } from "@/features/database/cron-advanced-select";
export type BackupScheduleValue = { export type BackupScheduleValue = {
method: "manual" | "automatic"; method: "manual" | "automatic";
@@ -19,34 +13,38 @@ export type BackupScheduleValue = {
}; };
const PRESETS = [ const PRESETS = [
{ label: "Every hour", cron: "0 * * * *" }, { label: "Hourly", sub: "Every hour", cron: "0 * * * *" },
{ label: "Every day", cron: "0 0 * * *" }, { label: "Every 6h", sub: "4× per day", cron: "0 */6 * * *" },
{ label: "Every week", cron: "0 0 * * 0" }, { label: "Every 12h", sub: "2× per day", cron: "0 */12 * * *"},
{ label: "Custom", cron: "custom" }, { label: "Daily", sub: "Every day at midnight",cron: "0 0 * * *" },
{ label: "Weekly", sub: "Every Sunday", cron: "0 0 * * 0" },
{ label: "Monthly", sub: "1st of each month", cron: "0 0 1 * *" },
] as const; ] as const;
type PresetCron = (typeof PRESETS)[number]["cron"]; type PresetCron = (typeof PRESETS)[number]["cron"];
function detectPreset(cron: string | undefined): PresetCron { function isPresetCron(cron: string | undefined): cron is PresetCron {
const match = PRESETS.find((p) => p.cron !== "custom" && p.cron === cron); return PRESETS.some((p) => p.cron === cron);
return match ? match.cron : "custom";
} }
type BackupScheduleSelectorProps = { function validateCron(expr: string): boolean {
const parts = expr.trim().split(/\s+/);
if (parts.length !== 5) return false;
const types = ["minute", "hour", "day-of-month", "month", "day-of-week"] as const;
return parts.every((p, i) => isValidCronPart(types[i], p));
}
type Props = {
value: BackupScheduleValue; value: BackupScheduleValue;
onChange: (value: BackupScheduleValue) => void; onChange: (value: BackupScheduleValue) => void;
}; };
export const BackupScheduleSelector = ({ export const BackupScheduleSelector = ({ value, onChange }: Props) => {
value, const isCustom = !!value.cron && !isPresetCron(value.cron);
onChange, const [customInput, setCustomInput] = useState(
}: BackupScheduleSelectorProps) => { isCustom ? (value.cron ?? "") : "",
const [customCron, setCustomCron] = useState<string>(
value.cron ?? "0 0 * * *",
); );
const [customError, setCustomError] = useState<string | null>(null);
const selectedPreset = detectPreset(value.cron);
const isCustom = selectedPreset === "custom";
const handleMethodChange = (method: "manual" | "automatic") => { const handleMethodChange = (method: "manual" | "automatic") => {
onChange({ onChange({
@@ -55,180 +53,110 @@ export const BackupScheduleSelector = ({
}); });
}; };
const handlePresetChange = (preset: PresetCron) => { const handlePresetClick = (cron: PresetCron) => {
if (preset === "custom") { setCustomError(null);
onChange({ ...value, cron: customCron }); onChange({ ...value, cron });
};
const handleCustomChange = (raw: string) => {
setCustomInput(raw);
if (validateCron(raw)) {
setCustomError(null);
onChange({ ...value, cron: raw.trim() });
} else { } else {
onChange({ ...value, cron: preset }); setCustomError("Invalid cron expression");
} }
}; };
const handleCronPartChange = ( const handleCustomFocus = () => {
type: "minute" | "hour" | "day-of-month" | "month" | "day-of-week", if (!customInput && value.cron && isPresetCron(value.cron)) {
part: string, setCustomInput(value.cron);
) => { }
const indexMap: Record<typeof type, number> = {
minute: 0,
hour: 1,
"day-of-month": 2,
month: 3,
"day-of-week": 4,
};
const parts = (customCron || "0 0 * * *").split(" ");
parts[indexMap[type]] = part;
const newCron = parts.join(" ");
setCustomCron(newCron);
onChange({ ...value, cron: newCron });
}; };
const cronParts = (value.cron ?? customCron).split(" ");
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<RadioGroup <RadioGroup
value={value.method} value={value.method}
onValueChange={(m) => handleMethodChange(m as "manual" | "automatic")} onValueChange={(m) => handleMethodChange(m as "manual" | "automatic")}
className="grid grid-cols-1 gap-3" className="grid grid-cols-2 gap-3"
> >
{( {(
[ [
{ { id: "manual", label: "Manual", desc: "Trigger backups manually" },
id: "manual", { id: "automatic", label: "Automatic", desc: "Scheduled via cron" },
label: "Manual",
desc: "Backups triggered manually only",
},
{
id: "automatic",
label: "Automatic",
desc: "Scheduled via cron expression",
},
] as const ] as const
).map((opt) => ( ).map((opt) => (
<Label <Label
key={opt.id} key={opt.id}
htmlFor={opt.id} htmlFor={opt.id}
className={`flex items-center space-x-3 rounded-lg border p-4 cursor-pointer transition-colors ${ className={cn(
"flex items-center gap-3 rounded-lg border p-3 cursor-pointer transition-colors",
value.method === opt.id value.method === opt.id
? "border-primary bg-primary/5" ? "border-primary bg-primary/5"
: "hover:bg-muted/50" : "hover:bg-muted/50",
}`} )}
> >
<RadioGroupItem value={opt.id} id={opt.id} /> <RadioGroupItem value={opt.id} id={opt.id} />
<div className="flex-1"> <div>
<span className="font-medium">{opt.label}</span> <p className="font-medium text-sm">{opt.label}</p>
<p className="text-sm text-muted-foreground">{opt.desc}</p> <p className="text-xs text-muted-foreground">{opt.desc}</p>
</div> </div>
</Label> </Label>
))} ))}
</RadioGroup> </RadioGroup>
{value.method === "automatic" && ( {value.method === "automatic" && (
<> <div className="flex flex-col gap-3">
<Separator /> <Label className="text-xs font-medium text-muted-foreground uppercase tracking-widest">
<div className="flex flex-col gap-3"> Frequency
<div className="flex flex-col gap-1.5"> </Label>
<Label>Frequency</Label>
<Select <div className="grid grid-cols-3 gap-2">
value={selectedPreset} {PRESETS.map((p) => (
onValueChange={(v) => handlePresetChange(v as PresetCron)} <button
key={p.cron}
type="button"
onClick={() => handlePresetClick(p.cron)}
className={cn(
"flex flex-col items-start rounded-lg border px-3 py-2 text-left text-sm transition-all",
value.cron === p.cron && !customError
? "border-primary bg-primary/5 text-primary"
: "border-border hover:bg-accent/50 hover:border-primary/20",
)}
> >
<SelectTrigger> <span className="font-medium">{p.label}</span>
<SelectValue /> <span className="text-[11px] text-muted-foreground">{p.sub}</span>
</SelectTrigger> </button>
<SelectContent> ))}
{PRESETS.map((p) => (
<SelectItem key={p.cron} value={p.cron}>
{p.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{isCustom && (
<div className="flex flex-col gap-2 pl-1">
{(
[
{
type: "minute",
label: "Minute",
options: Array.from({ length: 60 }, (_, i) =>
String(i).padStart(2, "0"),
),
partIdx: 0,
},
{
type: "hour",
label: "Hour",
options: Array.from({ length: 24 }, (_, i) =>
String(i).padStart(2, "0"),
),
partIdx: 1,
},
{
type: "day-of-month",
label: "Day of Month",
options: Array.from({ length: 31 }, (_, i) =>
String(i + 1).padStart(2, "0"),
),
partIdx: 2,
},
{
type: "month",
label: "Month",
options: [
"01",
"02",
"03",
"04",
"05",
"06",
"07",
"08",
"09",
"10",
"11",
"12",
],
partIdx: 3,
},
{
type: "day-of-week",
label: "Day of Week",
options: ["0", "1", "2", "3", "4", "5", "6"],
partIdx: 4,
},
] as const
).map(({ type, label, options, partIdx }) => (
<AdvancedCronSelect
key={type}
id={type}
label={label}
options={[...options]}
type={type}
value={cronParts[partIdx] ?? "*"}
defaultValue={cronParts[partIdx] ?? "*"}
onValueChange={(val) =>
handleCronPartChange(
type as
| "minute"
| "hour"
| "day-of-month"
| "month"
| "day-of-week",
val,
)
}
/>
))}
</div>
)}
<div className="rounded-md bg-muted/50 px-3 py-2 text-xs font-mono text-muted-foreground">
{value.cron ?? "0 0 * * *"}
</div>
</div> </div>
</>
<div className="flex flex-col gap-1.5">
<Label className="text-xs text-muted-foreground">
Custom expression
</Label>
<Input
placeholder="e.g. 0 */4 * * *"
value={customInput}
onFocus={handleCustomFocus}
onChange={(e) => handleCustomChange(e.target.value)}
className={cn(
"font-mono text-sm",
isCustom && !customError && "border-primary",
customError && "border-destructive",
)}
/>
{customError ? (
<p className="text-xs text-destructive">{customError}</p>
) : isCustom ? (
<p className="text-xs text-muted-foreground font-mono">{value.cron}</p>
) : null}
</div>
<div className="rounded-md bg-muted/50 px-3 py-2 text-xs font-mono text-muted-foreground">
{value.cron ?? "0 0 * * *"}
</div>
</div>
)} )}
</div> </div>
); );
@@ -16,29 +16,37 @@ import type {
SectionKind, SectionKind,
} from "@/features/onboarding/types"; } from "@/features/onboarding/types";
const SECTIONS: { kind: SectionKind; label: string; icon: React.ReactNode }[] = const SECTIONS: {
[ kind: SectionKind;
{ label: string;
kind: "retention", description: string;
label: "Retention Policy", icon: React.ReactNode;
icon: <Shield className="size-4 text-muted-foreground" />, }[] = [
}, {
{ kind: "retention",
kind: "scheduling", label: "Retention Policy",
label: "Scheduling", description: "Configure how long your data is retained",
icon: <Clock className="size-4 text-muted-foreground" />, icon: <Shield className="size-4 text-muted-foreground" />,
}, },
{ {
kind: "notifications", kind: "scheduling",
label: "Notifications", label: "Scheduling",
icon: <Bell className="size-4 text-muted-foreground" />, description: "Set up backup schedules for your database",
}, icon: <Clock className="size-4 text-muted-foreground" />,
{ },
kind: "storage", {
label: "Storage", kind: "notifications",
icon: <HardDrive className="size-4 text-muted-foreground" />, label: "Notifications",
}, description: "Get notified about backup status and issues",
]; icon: <Bell className="size-4 text-muted-foreground" />,
},
{
kind: "storage",
label: "Storage",
description: "Choose where to store your backups",
icon: <HardDrive className="size-4 text-muted-foreground" />,
},
];
type DbDetailProps = { type DbDetailProps = {
db: OnboardingDatabase | undefined; db: OnboardingDatabase | undefined;
@@ -80,8 +88,8 @@ export const DbDetail = ({
</Button> </Button>
</div> </div>
<div className="flex flex-col gap-2"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{SECTIONS.map(({ kind, label, icon }) => ( {SECTIONS.map(({ kind, label, description, icon }) => (
<button <button
key={kind} key={kind}
type="button" type="button"
@@ -91,7 +99,14 @@ export const DbDetail = ({
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0"> <div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
{icon} {icon}
</div> </div>
<span className="flex-1 font-medium">{label}</span> <div className="flex-col gap-1">
<div className="flex flex-col gap-1">
<span className="font-medium">{label}</span>
<span className="text-muted-foreground text-xs">
{description}
</span>
</div>
</div>
{isSectionConfigured(kind) && ( {isSectionConfigured(kind) && (
<div className="size-5 rounded-full bg-primary flex items-center justify-center ml-auto shrink-0"> <div className="size-5 rounded-full bg-primary flex items-center justify-center ml-auto shrink-0">
<Check <Check
@@ -27,7 +27,7 @@ export const DbGrid = ({
Optional configure backup policies for each database. Optional configure backup policies for each database.
</p> </p>
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2 max-h-52 sm:max-h-72 md:max-h-96 lg:max-h-[28rem] overflow-y-auto scrollbar-hide">
{databaseIds.map((dbId) => { {databaseIds.map((dbId) => {
const db = getDb(dbId); const db = getDb(dbId);
return ( return (
@@ -16,10 +16,10 @@ import type {
import type { useApplyDbSettings } from "@/features/onboarding/hooks/use-apply-db-settings"; import type { useApplyDbSettings } from "@/features/onboarding/hooks/use-apply-db-settings";
const SECTION_LABELS: Record<SectionKind, string> = { const SECTION_LABELS: Record<SectionKind, string> = {
retention: "Retention Policy", retention: "Retention Policy",
scheduling: "Scheduling", scheduling: "Scheduling",
notifications: "Notifications", notifications: "Notifications",
storage: "Storage", storage: "Storage",
}; };
type DbSectionProps = { type DbSectionProps = {
@@ -32,7 +32,10 @@ type DbSectionProps = {
storages: OnboardingChannel[]; storages: OnboardingChannel[];
onBack: () => void; onBack: () => void;
onSaved: () => void; onSaved: () => void;
updateDbSettings: (dbId: string, patch: Partial<OnboardingDbSettings>) => Promise<void>; updateDbSettings: (
dbId: string,
patch: Partial<OnboardingDbSettings>,
) => Promise<void>;
}; };
export const DbSection = ({ export const DbSection = ({
@@ -51,7 +54,9 @@ export const DbSection = ({
<div className="flex items-center gap-3 p-3 bg-secondary/30 rounded-lg border border-border"> <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"> <p className="flex-1 text-sm font-medium">
{SECTION_LABELS[section]}{" "} {SECTION_LABELS[section]}{" "}
<span className="text-muted-foreground font-normal"> {db?.name ?? dbId}</span> <span className="text-muted-foreground font-normal">
{db?.name ?? dbId}
</span>
</p> </p>
<Button type="button" variant="ghost" size="sm" onClick={onBack}> <Button type="button" variant="ghost" size="sm" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" /> <ArrowLeft className="size-4 mr-1" />
@@ -63,9 +68,12 @@ export const DbSection = ({
<RetentionSection <RetentionSection
initial={settings.retention} initial={settings.retention}
isPending={applyMutation.isPending} isPending={applyMutation.isPending}
onBack={onBack}
onSave={async (retention) => { onSave={async (retention) => {
await applyMutation.mutateAsync({ databaseId: dbId, section: "retention", retention }); await applyMutation.mutateAsync({
databaseId: dbId,
section: "retention",
retention,
});
await updateDbSettings(dbId, { retention }); await updateDbSettings(dbId, { retention });
toast.success("Retention policy saved."); toast.success("Retention policy saved.");
onSaved(); onSaved();
@@ -75,11 +83,18 @@ export const DbSection = ({
{section === "scheduling" && ( {section === "scheduling" && (
<SchedulingSection <SchedulingSection
initial={{ backupMethod: settings.backupMethod, backupCron: settings.backupCron }} initial={{
backupMethod: settings.backupMethod,
backupCron: settings.backupCron,
}}
isPending={applyMutation.isPending} isPending={applyMutation.isPending}
onBack={onBack}
onSave={async (backupMethod, backupCron) => { onSave={async (backupMethod, backupCron) => {
await applyMutation.mutateAsync({ databaseId: dbId, section: "scheduling", backupMethod, backupCron }); await applyMutation.mutateAsync({
databaseId: dbId,
section: "scheduling",
backupMethod,
backupCron,
});
await updateDbSettings(dbId, { backupMethod, backupCron }); await updateDbSettings(dbId, { backupMethod, backupCron });
toast.success("Schedule saved."); toast.success("Schedule saved.");
onSaved(); onSaved();
@@ -92,9 +107,12 @@ export const DbSection = ({
initial={settings.notificationPolicies ?? []} initial={settings.notificationPolicies ?? []}
notifiers={notifiers} notifiers={notifiers}
isPending={applyMutation.isPending} isPending={applyMutation.isPending}
onBack={onBack}
onSave={async (notificationPolicies) => { onSave={async (notificationPolicies) => {
await applyMutation.mutateAsync({ databaseId: dbId, section: "notifications", notificationPolicies }); await applyMutation.mutateAsync({
databaseId: dbId,
section: "notifications",
notificationPolicies,
});
await updateDbSettings(dbId, { notificationPolicies }); await updateDbSettings(dbId, { notificationPolicies });
toast.success("Notification policies saved."); toast.success("Notification policies saved.");
onSaved(); onSaved();
@@ -107,9 +125,12 @@ export const DbSection = ({
initial={settings.storagePolicies ?? []} initial={settings.storagePolicies ?? []}
storages={storages} storages={storages}
isPending={applyMutation.isPending} isPending={applyMutation.isPending}
onBack={onBack}
onSave={async (storagePolicies) => { onSave={async (storagePolicies) => {
await applyMutation.mutateAsync({ databaseId: dbId, section: "storage", storagePolicies }); await applyMutation.mutateAsync({
databaseId: dbId,
section: "storage",
storagePolicies,
});
await updateDbSettings(dbId, { storagePolicies }); await updateDbSettings(dbId, { storagePolicies });
toast.success("Storage policies saved."); toast.success("Storage policies saved.");
onSaved(); onSaved();
@@ -1,223 +1,35 @@
"use client"; "use client";
import { useState } from "react"; import { ChannelPoliciesForm } from "@/features/database/channels-policy-form";
import { ArrowLeft, Bell, Plus, Trash2 } from "lucide-react"; import type { EventKind, OnboardingChannel, OnboardingNotificationPolicy } from "@/features/onboarding/types";
import { Button } from "@/components/ui/button"; import type { PolicyType } from "@/features/database/channels-policy.schema";
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 = { type NotificationsSectionProps = {
initial: OnboardingNotificationPolicy[]; initial: OnboardingNotificationPolicy[];
notifiers: OnboardingChannel[]; notifiers: OnboardingChannel[];
onSave: (policies: OnboardingNotificationPolicy[]) => Promise<void>; onSave: (policies: OnboardingNotificationPolicy[]) => Promise<void>;
onBack: () => void; isPending: boolean;
isPending: boolean;
}; };
export const NotificationsSection = ({ export const NotificationsSection = ({ initial, notifiers, onSave, isPending }: NotificationsSectionProps) => (
initial, <ChannelPoliciesForm
notifiers, channels={notifiers}
onSave, defaultPolicies={initial as PolicyType[]}
onBack, kind="notification"
isPending, isPending={isPending}
}: NotificationsSectionProps) => { onSave={async (policies: PolicyType[]) =>
const [policies, setPolicies] = onSave(
useState<OnboardingNotificationPolicy[]>(initial); policies.map((p) => ({
channelId: p.channelId,
const addPolicy = () => eventKinds: (p.eventKinds ?? []) as EventKind[],
setPolicies((prev) => [ enabled: p.enabled,
...prev, })),
{ channelId: "", eventKinds: [], enabled: true }, )
]); }
noChannelsMessage={
const removePolicy = (index: number) => <p className="text-xs text-muted-foreground">
setPolicies((prev) => prev.filter((_, i) => i !== index)); Go back and configure notifiers in the &quot;Connect a notifier&quot; step first.
</p>
const updatePolicy = ( }
index: number, />
patch: Partial<OnboardingNotificationPolicy>, );
) =>
setPolicies((prev) =>
prev.map((p, i) => (i === index ? { ...p, ...patch } : p)),
);
const selectedChannelIds = policies.map((p) => p.channelId).filter(Boolean);
if (notifiers.length === 0) {
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col items-center justify-center p-8 border border-dashed rounded-xl bg-muted/20 text-center gap-2">
<Bell className="h-8 w-8 text-muted-foreground/50" />
<p className="font-medium text-sm">No notifiers configured</p>
<p className="text-xs text-muted-foreground">
Go back and configure notifiers in the &quot;Connect a
notifier&quot; step first.
</p>
</div>
<Button type="button" variant="outline" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" />
Back
</Button>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Notification Policies</Label>
<Button
type="button"
size="sm"
variant="outline"
disabled={policies.length >= notifiers.length}
onClick={addPolicy}
>
<Plus className="size-4 mr-1" />
Add Policy
</Button>
</div>
{policies.length === 0 ? (
<div className="flex flex-col items-center justify-center p-6 border border-dashed rounded-xl bg-muted/20 text-center gap-1">
<p className="text-sm text-muted-foreground">
Click &quot;Add Policy&quot; to start receiving notifications.
</p>
</div>
) : (
<div className="flex flex-col gap-3">
{policies.map((policy, index) => {
const available = notifiers.filter(
(n) =>
n.id === policy.channelId || !selectedChannelIds.includes(n.id),
);
const selected = notifiers.find((n) => n.id === policy.channelId);
return (
<Card
key={policy.channelId || index}
className="p-4 flex flex-col gap-3"
>
<div className="flex items-end gap-2">
<div className="flex-1 flex flex-col gap-1.5">
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
Channel
</Label>
<Select
value={policy.channelId}
onValueChange={(v) =>
updatePolicy(index, { channelId: v })
}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select channel">
{selected && (
<div className="flex items-center gap-2">
{getChannelIcon(selected.provider)}
<span className="truncate font-medium text-sm">
{selected.name}
</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{available.map((n) => (
<SelectItem key={n.id} value={n.id}>
<div className="flex items-center gap-2">
{getChannelIcon(n.provider)}
<span>{n.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5 shrink-0">
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
Status
</Label>
<div className="flex items-center h-9 px-3 rounded-md border border-input bg-background gap-2">
<Label className="text-xs cursor-pointer">
{policy.enabled ? "Active" : "Off"}
</Label>
<Switch
checked={policy.enabled}
onCheckedChange={(v) =>
updatePolicy(index, { enabled: v })
}
className="scale-75 origin-right"
/>
</div>
</div>
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9 text-muted-foreground hover:text-destructive hover:border-destructive/50 shrink-0"
onClick={() => removePolicy(index)}
>
<Trash2 className="size-4" />
</Button>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Trigger Events
</Label>
<MultiSelect
options={EVENT_KIND_OPTIONS}
onValueChange={(v) =>
updatePolicy(index, { eventKinds: v as EventKind[] })
}
defaultValue={policy.eventKinds}
placeholder="Select events…"
variant="inverted"
animation={0}
className="bg-background/50 w-full"
/>
</div>
</Card>
);
})}
</div>
)}
<div className="flex gap-2 pt-2">
<Button type="button" variant="outline" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" />
Back
</Button>
<Button
type="button"
disabled={
isPending ||
policies.some((p) => !p.channelId || p.eventKinds.length === 0)
}
onClick={() => onSave(policies)}
className="ml-auto"
>
{isPending ? "Saving…" : "Save"}
</Button>
</div>
</div>
);
};
@@ -1,232 +1,31 @@
"use client"; "use client";
import { useState } from "react"; import { BackupRetentionSettingsForm } from "@/features/database/retention-policy-form";
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 { DEFAULT_RETENTION } from "@/features/onboarding/constants/db-settings";
import type { OnboardingDbSettings } from "@/features/onboarding/types"; import type { OnboardingDbSettings } from "@/features/onboarding/types";
type RetentionSectionProps = { type RetentionSectionProps = {
initial: OnboardingDbSettings["retention"]; initial: OnboardingDbSettings["retention"];
onSave: ( onSave: (value: NonNullable<OnboardingDbSettings["retention"]>) => Promise<void>;
value: NonNullable<OnboardingDbSettings["retention"]>, isPending: boolean;
) => Promise<void>;
onBack: () => void;
isPending: boolean;
}; };
export const RetentionSection = ({ export const RetentionSection = ({ initial, onSave, isPending }: RetentionSectionProps) => (
initial, <BackupRetentionSettingsForm
onSave, defaultValues={initial ?? DEFAULT_RETENTION}
onBack, isPending={isPending}
isPending, onSave={async (values) =>
}: RetentionSectionProps) => { onSave({
const [settings, setSettings] = useState< type: values.type,
NonNullable<OnboardingDbSettings["retention"]> count: values.count ?? DEFAULT_RETENTION.count,
>(initial ?? DEFAULT_RETENTION); days: values.days ?? DEFAULT_RETENTION.days,
gfs: {
const totalFiles = () => { daily: values.gfs?.daily ?? DEFAULT_RETENTION.gfs.daily,
if (settings.type === "gfs") { weekly: values.gfs?.weekly ?? DEFAULT_RETENTION.gfs.weekly,
return ( monthly: values.gfs?.monthly ?? DEFAULT_RETENTION.gfs.monthly,
settings.gfs.daily + yearly: values.gfs?.yearly ?? DEFAULT_RETENTION.gfs.yearly,
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>
);
};
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { ArrowLeft } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
BackupScheduleSelector, BackupScheduleSelector,
@@ -13,14 +12,12 @@ import type { OnboardingDbSettings } from "@/features/onboarding/types";
type SchedulingSectionProps = { type SchedulingSectionProps = {
initial: Pick<OnboardingDbSettings, "backupMethod" | "backupCron">; initial: Pick<OnboardingDbSettings, "backupMethod" | "backupCron">;
onSave: (method: "manual" | "automatic", cron?: string) => Promise<void>; onSave: (method: "manual" | "automatic", cron?: string) => Promise<void>;
onBack: () => void;
isPending: boolean; isPending: boolean;
}; };
export const SchedulingSection = ({ export const SchedulingSection = ({
initial, initial,
onSave, onSave,
onBack,
isPending, isPending,
}: SchedulingSectionProps) => { }: SchedulingSectionProps) => {
const [schedule, setSchedule] = useState<BackupScheduleValue>({ const [schedule, setSchedule] = useState<BackupScheduleValue>({
@@ -32,10 +29,6 @@ export const SchedulingSection = ({
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<BackupScheduleSelector value={schedule} onChange={setSchedule} /> <BackupScheduleSelector value={schedule} onChange={setSchedule} />
<div className="flex gap-2 pt-2"> <div className="flex gap-2 pt-2">
<Button type="button" variant="outline" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" />
Back
</Button>
<Button <Button
type="button" type="button"
disabled={isPending} disabled={isPending}
@@ -1,192 +1,34 @@
"use client"; "use client";
import { useState } from "react"; import { ChannelPoliciesForm } from "@/features/database/channels-policy-form";
import { ArrowLeft, HardDrive, Plus, Trash2 } from "lucide-react"; import type { OnboardingChannel, OnboardingStoragePolicy } from "@/features/onboarding/types";
import { Button } from "@/components/ui/button"; import type { PolicyType } from "@/features/database/channels-policy.schema";
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 = { type StorageSectionProps = {
initial: OnboardingStoragePolicy[]; initial: OnboardingStoragePolicy[];
storages: OnboardingChannel[]; storages: OnboardingChannel[];
onSave: (policies: OnboardingStoragePolicy[]) => Promise<void>; onSave: (policies: OnboardingStoragePolicy[]) => Promise<void>;
onBack: () => void; isPending: boolean;
isPending: boolean;
}; };
export const StorageSection = ({ export const StorageSection = ({ initial, storages, onSave, isPending }: StorageSectionProps) => (
initial, <ChannelPoliciesForm
storages, channels={storages}
onSave, defaultPolicies={initial}
onBack, kind="storage"
isPending, isPending={isPending}
}: StorageSectionProps) => { onSave={async (policies: PolicyType[]) =>
const [policies, setPolicies] = useState<OnboardingStoragePolicy[]>(initial); onSave(
policies.map((p) => ({
const addPolicy = () => channelId: p.channelId,
setPolicies((prev) => [...prev, { channelId: "", enabled: true }]); enabled: p.enabled,
})),
const removePolicy = (index: number) => )
setPolicies((prev) => prev.filter((_, i) => i !== index)); }
noChannelsMessage={
const updatePolicy = ( <p className="text-xs text-muted-foreground">
index: number, Go back and configure storages in the &quot;Connect a storage&quot; step first.
patch: Partial<OnboardingStoragePolicy>, </p>
) => }
setPolicies((prev) => />
prev.map((p, i) => (i === index ? { ...p, ...patch } : p)), );
);
const selectedChannelIds = policies.map((p) => p.channelId).filter(Boolean);
if (storages.length === 0) {
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col items-center justify-center p-8 border border-dashed rounded-xl bg-muted/20 text-center gap-2">
<HardDrive className="h-8 w-8 text-muted-foreground/50" />
<p className="font-medium text-sm">No storages configured</p>
<p className="text-xs text-muted-foreground">
Go back and configure storages in the &quot;Connect a storage&quot;
step first.
</p>
</div>
<Button type="button" variant="outline" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" />
Back
</Button>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Storage Policies</Label>
<Button
type="button"
size="sm"
variant="outline"
disabled={policies.length >= storages.length}
onClick={addPolicy}
>
<Plus className="size-4 mr-1" />
Add Policy
</Button>
</div>
{policies.length === 0 ? (
<div className="flex flex-col items-center justify-center p-6 border border-dashed rounded-xl bg-muted/20 text-center gap-1">
<p className="text-sm text-muted-foreground">
Click &quot;Add Policy&quot; to assign a storage to this database.
</p>
</div>
) : (
<div className="flex flex-col gap-3">
{policies.map((policy, index) => {
const available = storages.filter(
(s) =>
s.id === policy.channelId || !selectedChannelIds.includes(s.id),
);
const selected = storages.find((s) => s.id === policy.channelId);
return (
<Card
key={policy.channelId || index}
className="p-4 flex items-end gap-2"
>
<div className="flex-1 flex flex-col gap-1.5">
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
Storage Channel
</Label>
<Select
value={policy.channelId}
onValueChange={(v) => updatePolicy(index, { channelId: v })}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select storage">
{selected && (
<div className="flex items-center gap-2">
{getChannelIcon(selected.provider)}
<span className="truncate font-medium text-sm">
{selected.name}
</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{available.map((s) => (
<SelectItem key={s.id} value={s.id}>
<div className="flex items-center gap-2">
{getChannelIcon(s.provider)}
<span>{s.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5 shrink-0">
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
Status
</Label>
<div className="flex items-center h-9 px-3 rounded-md border border-input bg-background gap-2">
<Label className="text-xs cursor-pointer">
{policy.enabled ? "Active" : "Off"}
</Label>
<Switch
checked={policy.enabled}
onCheckedChange={(v) =>
updatePolicy(index, { enabled: v })
}
className="scale-75 origin-right"
/>
</div>
</div>
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9 text-muted-foreground hover:text-destructive hover:border-destructive/50 shrink-0"
onClick={() => removePolicy(index)}
>
<Trash2 className="size-4" />
</Button>
</Card>
);
})}
</div>
)}
<div className="flex gap-2 pt-2">
<Button type="button" variant="outline" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" />
Back
</Button>
<Button
type="button"
disabled={isPending || policies.some((p) => !p.channelId)}
onClick={() => onSave(policies)}
className="ml-auto"
>
{isPending ? "Saving…" : "Save"}
</Button>
</div>
</div>
);
};
@@ -40,6 +40,7 @@ export const useAddStorage = () => {
label, label,
name, name,
config, config,
organizationId: orgId ?? null,
}; };
const storages = [ const storages = [
...((state?.context.flowData.storages ?? []) as OnboardingChannel[]), ...((state?.context.flowData.storages ?? []) as OnboardingChannel[]),
@@ -5,6 +5,7 @@ import { useOnboarding } from "@onboardjs/react";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
createOrganizationAction, createOrganizationAction,
getMyOrganizationAction,
updateOrganizationAction, updateOrganizationAction,
} from "@/features/organizations/organization.action"; } from "@/features/organizations/organization.action";
import { slugify } from "@/utils/slugify"; import { slugify } from "@/utils/slugify";
@@ -16,7 +17,18 @@ export const useCreateOrg = () => {
mutationFn: async (name: string) => { mutationFn: async (name: string) => {
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed) throw new Error("Organisation name is required"); if (!trimmed) throw new Error("Organisation name is required");
const existingOrg = state?.context.flowData.org;
let existingOrg = state?.context.flowData.org as
| { id: string; name: string }
| undefined;
if (!existingOrg) {
const fetchResult = await getMyOrganizationAction({});
const fetchData = fetchResult?.data;
if (fetchData?.success && fetchData.value) {
existingOrg = { id: fetchData.value.id, name: fetchData.value.name };
}
}
if (existingOrg) { if (existingOrg) {
const result = await updateOrganizationAction({ const result = await updateOrganizationAction({
@@ -25,23 +37,34 @@ export const useCreateOrg = () => {
}); });
const updateData = result?.data; const updateData = result?.data;
if (!updateData?.success) { if (!updateData?.success) {
throw new Error(updateData?.actionError?.message ?? "Failed to update organisation"); throw new Error(
updateData?.actionError?.message ?? "Failed to update organisation",
);
} }
await updateContext({ await updateContext({
flowData: { ...state?.context.flowData, org: { id: existingOrg.id, name: trimmed } }, flowData: {
...state?.context.flowData,
org: { id: existingOrg.id, name: trimmed },
},
}); });
} else { } else {
const result = await createOrganizationAction({ name: trimmed }); const result = await createOrganizationAction({ name: trimmed });
const createData = result?.data; const createData = result?.data;
if (!createData?.success) { if (!createData?.success) {
throw new Error(createData?.actionError?.message ?? "Failed to create organisation"); throw new Error(
createData?.actionError?.message ?? "Failed to create organisation",
);
} }
const org = createData.value; const org = createData.value;
if (!org) throw new Error("Failed to create organisation"); if (!org) throw new Error("Failed to create organisation");
await updateContext({ await updateContext({
flowData: { ...state?.context.flowData, org: { id: org.id, name: org.name } }, flowData: {
...state?.context.flowData,
org: { id: org.id, name: org.name },
},
}); });
} }
await next(); await next();
}, },
onError: (err: Error) => toast.error(err.message), onError: (err: Error) => toast.error(err.message),
@@ -9,13 +9,13 @@ import {
} from "@/features/projects/projects.action"; } from "@/features/projects/projects.action";
import type { OnboardingProjectData } from "@/features/onboarding/types"; import type { OnboardingProjectData } from "@/features/onboarding/types";
type ProjectInput = { name: string; description: string; databaseIds: string[] }; type ProjectInput = { name: string; databaseIds: string[] };
export const useCreateProject = () => { export const useCreateProject = () => {
const { state, updateContext } = useOnboarding(); const { state, updateContext } = useOnboarding();
return useMutation({ return useMutation({
mutationFn: async ({ name, description, databaseIds }: ProjectInput) => { mutationFn: async ({ name, databaseIds }: ProjectInput) => {
const trimmed = name.trim(); const trimmed = name.trim();
if (!trimmed) throw new Error("Project name is required"); if (!trimmed) throw new Error("Project name is required");
const orgId = (state?.context.flowData.org as any)?.id as string | undefined; const orgId = (state?.context.flowData.org as any)?.id as string | undefined;
@@ -35,7 +35,7 @@ export const useCreateProject = () => {
await updateContext({ await updateContext({
flowData: { flowData: {
...state?.context.flowData, ...state?.context.flowData,
project: { id: existingProject.id, name: trimmed, description, databaseIds }, project: { id: existingProject.id, name: trimmed, databaseIds },
}, },
}); });
} else { } else {
@@ -52,7 +52,7 @@ export const useCreateProject = () => {
await updateContext({ await updateContext({
flowData: { flowData: {
...state?.context.flowData, ...state?.context.flowData,
project: { id: project.id, name: project.name, description, databaseIds }, project: { id: project.id, name: project.name, databaseIds },
}, },
}); });
} }
@@ -1,4 +1,3 @@
// src/features/onboarding/hooks/use-remove-notifier.ts
"use client"; "use client";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
@@ -12,16 +11,21 @@ export const useRemoveNotifier = () => {
return useMutation({ return useMutation({
mutationFn: async (id: string) => { mutationFn: async (id: string) => {
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 removeNotificationChannelAction({ const result = await removeNotificationChannelAction({
organizationId: orgId, organizationId: orgId,
notificationChannelId: id, notificationChannelId: id,
}); });
if (result?.data?.success === false) throw new Error("Failed to remove channel"); if (result?.data?.success === false)
throw new Error("Failed to remove channel");
const notifiers = ( const notifiers = (
(state?.context.flowData.notifiers ?? []) as OnboardingChannel[] (state?.context.flowData.notifiers ?? []) as OnboardingChannel[]
).filter((c) => c.id !== id); ).filter((c) => c.id !== id);
await updateContext({ flowData: { ...state?.context.flowData, notifiers } }); await updateContext({
flowData: { ...state?.context.flowData, notifiers },
});
}, },
onError: (err: Error) => toast.error(err.message), onError: (err: Error) => toast.error(err.message),
}); });
@@ -1,4 +1,3 @@
// src/features/onboarding/hooks/use-remove-storage.ts
"use client"; "use client";
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
@@ -12,13 +11,21 @@ export const useRemoveStorage = () => {
return useMutation({ return useMutation({
mutationFn: async (id: string) => { mutationFn: async (id: string) => {
const orgId = (state?.context.flowData.org as any)?.id as string | undefined; const orgId = (state?.context.flowData.org as any)?.id as
const result = await removeStorageChannelAction({ organizationId: orgId, id }); | string
if (result?.data?.success === false) throw new Error("Failed to remove storage"); | undefined;
const result = await removeStorageChannelAction({
organizationId: orgId,
id,
});
if (result?.data?.success === false)
throw new Error("Failed to remove storage");
const storages = ( const storages = (
(state?.context.flowData.storages ?? []) as OnboardingChannel[] (state?.context.flowData.storages ?? []) as OnboardingChannel[]
).filter((c) => c.id !== id); ).filter((c) => c.id !== id);
await updateContext({ flowData: { ...state?.context.flowData, storages } }); await updateContext({
flowData: { ...state?.context.flowData, storages },
});
}, },
onError: (err: Error) => toast.error(err.message), onError: (err: Error) => toast.error(err.message),
}); });
+14 -7
View File
@@ -28,7 +28,7 @@ export const OnboardingShell = () => {
const isGoingBack = currentIndex < latestIndex; const isGoingBack = currentIndex < latestIndex;
const BLOCKED_STEPS = ["login", "account-info", "security"]; const BLOCKED_STEPS = ["security"];
const prevStepId = STEP_ORDER[currentIndex - 1] ?? ""; const prevStepId = STEP_ORDER[currentIndex - 1] ?? "";
const canGoBack = const canGoBack =
!BLOCKED_STEPS.includes(currentStepId) && !BLOCKED_STEPS.includes(currentStepId) &&
@@ -38,7 +38,7 @@ export const OnboardingShell = () => {
return ( return (
<div className="min-h-screen bg-background text-foreground flex flex-col items-center justify-center p-4 gap-4"> <div className="min-h-screen bg-background text-foreground flex flex-col items-center justify-center p-4 gap-4">
<AuthLogoSection /> <AuthLogoSection />
<div className="w-full max-w-4xl rounded-2xl bg-card border border-border shadow-2xl overflow-hidden flex flex-col md:flex-row min-h-[560px]"> <div className="w-full max-w-4xl rounded-2xl bg-card border border-border shadow-2xl overflow-hidden flex flex-col md:flex-row min-h-140">
<div className="flex-1 flex flex-col gap-6 p-8"> <div className="flex-1 flex flex-col gap-6 p-8">
<OnboardingStepper /> <OnboardingStepper />
<div className="flex-1">{renderStep()}</div> <div className="flex-1">{renderStep()}</div>
@@ -60,11 +60,18 @@ export const OnboardingShell = () => {
prevId = "storage"; prevId = "storage";
} }
} else if (currentStepId === "finish") { } else if (currentStepId === "finish") {
const agents = (state.context.flowData.agents as any[]) || []; const agents =
const isAgentConnected = agents.some((a) => a.connected); (state.context.flowData.agents as any[]) || [];
const databaseIds = (state.context.flowData.project as any)?.databaseIds || []; if (agents.length === 0) {
if (!isAgentConnected || databaseIds.length === 0) { prevId = "agent-create";
prevId = "project-create"; } else {
const isAgentConnected = agents.some((a) => a.connected);
const databaseIds =
(state.context.flowData.project as any)?.databaseIds ||
[];
if (!isAgentConnected || databaseIds.length === 0) {
prevId = "project-create";
}
} }
} }
if (prevId) goToStep(prevId); if (prevId) goToStep(prevId);
+38 -11
View File
@@ -1,11 +1,16 @@
import "server-only"; import "server-only";
import { eq } from "drizzle-orm";
import { db } from "@/db";
import { organization } from "@/db/schema/03_organization";
import { member } from "@/db/schema/04_member";
import { currentUser } from "@/lib/auth/current-user"; import { currentUser } from "@/lib/auth/current-user";
import { getSettings } from "@/db/services/setting"; import { getSettings } from "@/db/services/setting";
import { hasUsers } from "@/db/services/user"; import { hasUsers } from "@/db/services/user";
import { getUserOrganization } from "@/db/services/organization"; import { getUserOrganization } from "@/db/services/organization";
import { getOrganizationProject } from "@/db/services/project"; import { getOrganizationProject } from "@/db/services/project";
import { getOrganizationAgents } from "@/db/services/agent"; import { getOrganizationAgents } from "@/db/services/agent";
import { getDatabasesSettings } from "@/db/services/database";
import { getOrganizationChannels } from "@/db/services/notification-channel"; import { getOrganizationChannels } from "@/db/services/notification-channel";
import { getOrganizationStorageChannels } from "@/db/services/storage-channel"; import { getOrganizationStorageChannels } from "@/db/services/storage-channel";
import type { AgentWith } from "@/db/schema/08_agent"; import type { AgentWith } from "@/db/schema/08_agent";
@@ -51,10 +56,22 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
return { stepId: "login", flowData: { meta } }; return { stepId: "login", flowData: { meta } };
} }
const org = await getUserOrganization(user.id); let org = await getUserOrganization(user.id);
if (!org) { if (!org) {
meta.resumeStepId = "preferences"; const defaultOrg = await db.query.organization.findFirst({
return { stepId: "preferences", flowData: { meta } }; where: eq(organization.slug, "default"),
});
if (defaultOrg) {
await db.insert(member).values({
userId: user.id,
organizationId: defaultOrg.id,
role: "owner",
});
org = defaultOrg;
} else {
meta.resumeStepId = "preferences";
return { stepId: "preferences", flowData: { meta } };
}
} }
const orgData = { id: org.id, name: org.name }; const orgData = { id: org.id, name: org.name };
@@ -73,6 +90,7 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
label: n.provider, label: n.provider,
name: n.name, name: n.name,
config: (n.config as Record<string, unknown>) ?? {}, config: (n.config as Record<string, unknown>) ?? {},
organizationId: n.organizationId ?? null,
})); }));
const storages = storageChannels.map((s) => ({ const storages = storageChannels.map((s) => ({
@@ -81,11 +99,14 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
label: s.provider, label: s.provider,
name: s.name, name: s.name,
config: (s.config as Record<string, unknown>) ?? {}, config: (s.config as Record<string, unknown>) ?? {},
organizationId: s.organizationId ?? null,
})); }));
const defaults = { const defaults = {
notifierId: settings?.defaultNotificationChannelId ?? undefined, notifierId: settings?.defaultNotificationChannelId ?? undefined,
storageId: settings?.defaultStorageChannelId ?? undefined, storageId: settings?.defaultStorageChannelId ?? undefined,
avatarMode: settings?.avatarMode ?? "internal",
dicebearStyle: settings?.dicebearStyle ?? "thumbs",
}; };
const agentData = await Promise.all( const agentData = await Promise.all(
@@ -94,7 +115,7 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
name: a.name, name: a.name,
edgeKey: await generateEdgeKey(getServerUrl(), a.id), edgeKey: await generateEdgeKey(getServerUrl(), a.id),
connected: !!a.lastContact, connected: !!a.lastContact,
})) })),
); );
const databases = (agents as AgentWith[]).flatMap((a) => const databases = (agents as AgentWith[]).flatMap((a) =>
@@ -107,6 +128,8 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
})), })),
); );
const dbSettings = await getDatabasesSettings(databases.map((d) => d.id));
const fullData: Partial<OnboardingFlowData> = { const fullData: Partial<OnboardingFlowData> = {
meta, meta,
org: orgData, org: orgData,
@@ -115,13 +138,15 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
defaults, defaults,
agents: agentData, agents: agentData,
databases, databases,
dbSettings,
...(project ...(project
? { ? {
project: { project: {
id: project.id, id: project.id,
name: project.name, name: project.name,
description: "", description: "",
databaseIds: (project as any).databases?.map((db: any) => db.id) ?? [], databaseIds:
(project as any).databases?.map((db: any) => db.id) ?? [],
}, },
} }
: {}), : {}),
@@ -129,10 +154,8 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
const hasAgents = agents && agents.length > 0; const hasAgents = agents && agents.length > 0;
// Has project → late stage (project was created after agent-key)
if (project) { if (project) {
if (!hasAgents) { if (!hasAgents) {
// Project without agents: missed earlier steps
if (notifiers.length === 0) { if (notifiers.length === 0) {
meta.resumeStepId = "notifier"; meta.resumeStepId = "notifier";
return { stepId: "notifier", flowData: fullData }; return { stepId: "notifier", flowData: fullData };
@@ -151,11 +174,16 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
return { stepId: "agent-key", flowData: fullData }; return { stepId: "agent-key", flowData: fullData };
} }
meta.resumeStepId = "finish"; const projectDatabaseIds: string[] =
return { stepId: "finish", flowData: fullData }; (project as any).databases?.map((db: any) => db.id) ?? [];
if (projectDatabaseIds.length > 0) {
meta.resumeStepId = "db-settings";
return { stepId: "db-settings", flowData: fullData };
}
meta.resumeStepId = "project-create";
return { stepId: "project-create", flowData: fullData };
} }
// Has agents but no project → past notifier/storage, waiting on project
if (hasAgents) { if (hasAgents) {
const agentHasPinged = !!agents[0]?.lastContact; const agentHasPinged = !!agents[0]?.lastContact;
const stepId = agentHasPinged ? "project-create" : "agent-key"; const stepId = agentHasPinged ? "project-create" : "agent-key";
@@ -163,7 +191,6 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
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 };
+2 -2
View File
@@ -120,14 +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"; if (agents.length === 0) return "finish";
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"; if (agents.length === 0) return "finish";
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";
@@ -1,17 +1,22 @@
"use client"; "use client";
import { useState } from "react";
import { useOnboarding } from "@onboardjs/react"; import { useOnboarding } from "@onboardjs/react";
import { Loader2 } from "lucide-react"; import { Server } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { CodeSnippet } from "@/components/common/code-snippet"; import { AgentCardKey } from "@/features/agents/agent-card-key";
import type { OnboardingAgent } from "@/features/onboarding/types"; import type { OnboardingAgent } from "@/features/onboarding/types";
import { cn } from "@/lib/utils";
export const StepAgentKey = () => { export const StepAgentKey = () => {
const { next, state } = useOnboarding(); const { next, state } = useOnboarding();
const agents = (state?.context.flowData.agents ?? []) as OnboardingAgent[]; const agents = (state?.context.flowData.agents ?? []) as OnboardingAgent[];
const [selectedId, setSelectedId] = useState<string>(agents[0]?.id ?? "");
const selected = agents.find((a) => a.id === selectedId) ?? agents[0];
return ( return (
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-4">
<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">
@@ -19,33 +24,40 @@ export const StepAgentKey = () => {
agent to connect. agent to connect.
</p> </p>
</div> </div>
{agents.map((agent) => (
<AgentKeyBlock key={agent.id} agent={agent} /> {agents.length > 1 && (
))} <div className="flex flex-col gap-1.5">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-widest">
Agents
</p>
<div className="flex flex-col gap-1.5 max-h-36 overflow-y-auto scrollbar-hide">
{agents.map((agent) => (
<button
key={agent.id}
type="button"
onClick={() => setSelectedId(agent.id)}
className={cn(
"flex items-center gap-2.5 rounded-lg border px-3 py-2 text-sm transition-all text-left",
selectedId === agent.id
? "border-primary/20 bg-primary/10 text-primary"
: "border-border hover:bg-accent/50 hover:border-primary/20",
)}
>
<Server className="size-3.5 shrink-0" />
<span className="font-medium truncate">{agent.name}</span>
</button>
))}
</div>
</div>
)}
{selected?.edgeKey && (
<AgentCardKey edgeKey={selected.edgeKey} agentName={selected.name} />
)}
<Button type="button" onClick={() => next()}> <Button type="button" onClick={() => next()}>
I&apos;ve run the command I&apos;ve run the command
</Button> </Button>
</div> </div>
); );
}; };
const AgentKeyBlock = ({ agent }: { agent: OnboardingAgent }) => {
if (!agent.edgeKey) {
return (
<div className="flex items-center gap-2 text-sm text-muted-foreground p-4 rounded-lg border border-border bg-muted/50">
<Loader2 className="size-4 animate-spin" />
Generating key for {agent.name}
</div>
);
}
const command = `portabase agent "${agent.name}" --key ${agent.edgeKey}`;
return (
<div className="flex flex-col gap-3 p-4 rounded-lg border border-border">
<p className="text-sm font-medium">{agent.name}</p>
<CodeSnippet title="Installation Command" code={command} />
<CodeSnippet title="Agent Key (manual)" code={agent.edgeKey} />
</div>
);
};
@@ -1,21 +1,23 @@
"use client"; "use client";
import { useEffect } from "react"; import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { useOnboarding } from "@onboardjs/react"; import { useOnboarding } from "@onboardjs/react";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { useAgentStatus } from "@/features/onboarding/hooks/use-agent-status"; import { useAgentStatus } from "@/features/onboarding/hooks/use-agent-status";
import type { OnboardingAgent } from "@/features/onboarding/types"; import type { OnboardingAgent } from "@/features/onboarding/types";
export const StepAgentWaiting = () => { export const StepAgentWaiting = () => {
const { next, state } = useOnboarding(); const { state } = useOnboarding();
const router = useRouter();
const agents = (state?.context.flowData.agents ?? []) as OnboardingAgent[]; const agents = (state?.context.flowData.agents ?? []) as OnboardingAgent[];
const { data, isLoading } = useAgentStatus(); const { data, isLoading } = useAgentStatus();
useEffect(() => { useEffect(() => {
if (data?.connected) { if (data?.connected) {
next(); router.refresh();
} }
}, [data?.connected, next]); }, [data?.connected, router]);
if (isLoading || data?.connected) return null; if (isLoading || data?.connected) return null;
@@ -52,11 +52,11 @@ export const StepDbSettings = () => {
case "retention": case "retention":
return !!s.retention; return !!s.retention;
case "scheduling": case "scheduling":
return s.backupMethod !== undefined; return s.backupMethod === "automatic";
case "notifications": case "notifications":
return s.notificationPolicies !== undefined; return (s.notificationPolicies?.length ?? 0) > 0;
case "storage": case "storage":
return s.storagePolicies !== undefined; return (s.storagePolicies?.length ?? 0) > 0;
} }
}; };
@@ -131,7 +131,7 @@ export const StepDbSettings = () => {
getDb={getDb} getDb={getDb}
isDbConfigured={isDbConfigured} isDbConfigured={isDbConfigured}
onSelectDb={(dbId) => setPhase({ kind: "db", dbId })} onSelectDb={(dbId) => setPhase({ kind: "db", dbId })}
onContinue={next} onContinue={() => next()}
/> />
); );
+110 -32
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useState } 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";
@@ -12,12 +11,17 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { import type {
AvatarMode,
OnboardingChannel, OnboardingChannel,
OnboardingDefaultsData, OnboardingDefaultsData,
} from "@/features/onboarding/types"; } from "@/features/onboarding/types";
import { getChannelIcon } from "@/features/channel/channels-helpers";
import { updateNotificationSettingsAction } from "@/features/settings/notification.action"; import { updateNotificationSettingsAction } from "@/features/settings/notification.action";
import { updateStorageSettingsAction } from "@/features/settings/storage.action"; import { updateStorageSettingsAction } from "@/features/settings/storage.action";
import { updateAvatarModeAction } from "@/features/settings/avatar.action";
import { AvatarModeSelector } from "@/features/settings/avatar-mode-selector";
import { DicebearStylePicker } from "@/features/settings/dicebear-style-picker";
export const StepDefaults = () => { export const StepDefaults = () => {
const { next, updateContext, state } = useOnboarding(); const { next, updateContext, state } = useOnboarding();
@@ -27,12 +31,19 @@ export const StepDefaults = () => {
[]) as OnboardingChannel[]; []) as OnboardingChannel[];
const existingDefaults = (state?.context.flowData.defaults ?? const existingDefaults = (state?.context.flowData.defaults ??
{}) as OnboardingDefaultsData; {}) as OnboardingDefaultsData;
const [notifierId, setNotifierId] = useState<string | undefined>( const [notifierId, setNotifierId] = useState<string | undefined>(
existingDefaults.notifierId || undefined, existingDefaults.notifierId || undefined,
); );
const [storageId, setStorageId] = useState<string | undefined>( const [storageId, setStorageId] = useState<string | undefined>(
existingDefaults.storageId || undefined, existingDefaults.storageId || undefined,
); );
const [avatarMode, setAvatarMode] = useState<AvatarMode>(
existingDefaults.avatarMode ?? "internal",
);
const [dicebearStyle, setDicebearStyle] = useState<string>(
existingDefaults.dicebearStyle ?? "thumbs",
);
const selectNotifier = async (value: string) => { const selectNotifier = async (value: string) => {
setNotifierId(value); setNotifierId(value);
@@ -43,22 +54,12 @@ export const StepDefaults = () => {
await updateContext({ await updateContext({
flowData: { flowData: {
...state?.context.flowData, ...state?.context.flowData,
defaults: { notifierId: value, storageId }, defaults: { notifierId: value, storageId, avatarMode },
}, },
}); });
}; };
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",
@@ -67,7 +68,37 @@ export const StepDefaults = () => {
await updateContext({ await updateContext({
flowData: { flowData: {
...state?.context.flowData, ...state?.context.flowData,
defaults: { notifierId, storageId: value }, defaults: { notifierId, storageId: value, avatarMode },
},
});
};
const selectAvatarMode = async (mode: AvatarMode) => {
setAvatarMode(mode);
await updateAvatarModeAction({
name: "system",
avatarMode: mode,
dicebearStyle,
});
await updateContext({
flowData: {
...state?.context.flowData,
defaults: { notifierId, storageId, avatarMode: mode, dicebearStyle },
},
});
};
const selectDicebearStyle = async (style: string) => {
setDicebearStyle(style);
await updateAvatarModeAction({
name: "system",
avatarMode: "dicebear",
dicebearStyle: style,
});
await updateContext({
flowData: {
...state?.context.flowData,
defaults: { notifierId, storageId, avatarMode, dicebearStyle: style },
}, },
}); });
}; };
@@ -76,26 +107,28 @@ export const StepDefaults = () => {
await updateContext({ await updateContext({
flowData: { flowData: {
...state?.context.flowData, ...state?.context.flowData,
defaults: { notifierId, storageId }, defaults: { notifierId, storageId, avatarMode, dicebearStyle },
}, },
}); });
await next(); await next();
}; };
const selectedNotifier = notifiers.find((n) => n.id === notifierId);
const selectedStorage = storages.find((s) => s.id === storageId);
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div> <div>
<h1 className="text-2xl font-semibold">Set your defaults</h1> <h1 className="text-2xl font-semibold">Set your defaults</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
Optional choose the default notifier and storage for new agents. Optional choose the default notifier, storage and avatar mode.
</p> </p>
</div> </div>
<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={ value={selectedNotifier ? notifierId : undefined}
notifiers.some((n) => n.id === notifierId) ? notifierId : undefined
}
onValueChange={selectNotifier} onValueChange={selectNotifier}
disabled={notifiers.length === 0} disabled={notifiers.length === 0}
> >
@@ -106,43 +139,88 @@ export const StepDefaults = () => {
? "No notifier connected" ? "No notifier connected"
: "Choose a notifier" : "Choose a notifier"
} }
/> >
{selectedNotifier && (
<div className="flex items-center gap-2 min-w-0">
<div className="text-muted-foreground scale-90 shrink-0">
{getChannelIcon(selectedNotifier.provider)}
</div>
<span className="truncate font-medium">
{selectedNotifier.name}
</span>
</div>
)}
</SelectValue>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{notifiers.map((n) => ( {notifiers.map((n) => (
<SelectItem key={n.id} value={n.id}> <SelectItem key={n.id} value={n.id}>
{n.label} <div className="flex items-center gap-2 w-full min-w-0">
<div className="text-muted-foreground scale-90 shrink-0">
{getChannelIcon(n.provider)}
</div>
<span className="font-medium truncate min-w-0">{n.name}</span>
<span className="text-xs text-muted-foreground ml-2 capitalize shrink-0">
({n.provider})
</span>
</div>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<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={ value={selectedStorage ? storageId : undefined}
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"
}
>
{selectedStorage && (
<div className="flex items-center gap-2 min-w-0">
<div className="text-muted-foreground scale-90 shrink-0">
{getChannelIcon(selectedStorage.provider)}
</div>
<span className="truncate font-medium">
{selectedStorage.name}
</span>
</div>
)}
</SelectValue>
</SelectTrigger> </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} <div className="flex items-center gap-2 w-full min-w-0">
<div className="text-muted-foreground scale-90 shrink-0">
{getChannelIcon(s.provider)}
</div>
<span className="font-medium truncate min-w-0">{s.name}</span>
<span className="text-xs text-muted-foreground ml-2 capitalize shrink-0">
({s.provider})
</span>
</div>
</SelectItem> </SelectItem>
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
<AvatarModeSelector value={avatarMode} onChange={selectAvatarMode} />
{avatarMode === "dicebear" && (
<DicebearStylePicker value={dicebearStyle} onChange={selectDicebearStyle} />
)}
<Button type="button" onClick={onContinue}> <Button type="button" onClick={onContinue}>
Continue Continue
</Button> </Button>
@@ -1,16 +1,17 @@
"use client"; "use client";
import { useEffect, useRef } from "react"; import { useEffect, useRef, useState } from "react";
import { useOnboarding } from "@onboardjs/react"; import { useRouter } from "next/navigation";
import confetti from "canvas-confetti"; import confetti from "canvas-confetti";
import { CheckCircle2 } from "lucide-react"; import { CheckCircle2 } from "lucide-react";
import { Button } from "@/components/ui/button"; 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 router = useRouter();
const fired = useRef(false); const fired = useRef(false);
const mutation = useMarkOnboardingDone(); const mutation = useMarkOnboardingDone();
const [isRedirecting, setIsRedirecting] = useState(false);
useEffect(() => { useEffect(() => {
if (fired.current) return; if (fired.current) return;
@@ -27,10 +28,11 @@ export const StepFinish = () => {
</p> </p>
<Button <Button
type="button" type="button"
disabled={mutation.isPending} disabled={mutation.isPending || isRedirecting}
onClick={async () => { onClick={async () => {
await mutation.mutateAsync(); await mutation.mutateAsync();
await next(); setIsRedirecting(true);
router.push("/dashboard/home");
}} }}
> >
Go to dashboard Go to dashboard
@@ -5,10 +5,7 @@ import { useTheme } from "next-themes";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { authClient } from "@/lib/auth/auth-client"; import { authClient } from "@/lib/auth/auth-client";
import type { import type { OnboardingAccountData } from "@/features/onboarding/types";
OnboardingAccountData,
OnboardingMeta,
} from "@/features/onboarding/types";
import { ThemeKey, ThemeSelector } from "@/components/common/theme-selector"; import { ThemeKey, ThemeSelector } from "@/components/common/theme-selector";
const AVATAR_COLORS = [ const AVATAR_COLORS = [
@@ -5,7 +5,6 @@ import { useOnboarding } from "@onboardjs/react";
import { Check, Database, Loader2 } from "lucide-react"; import { Check, Database, Loader2 } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { useCreateProject } from "@/features/onboarding/hooks/use-create-project"; import { useCreateProject } from "@/features/onboarding/hooks/use-create-project";
import type { import type {
@@ -23,9 +22,6 @@ export const StepProjectCreate = () => {
const isUpdateMode = !!existingProject; const isUpdateMode = !!existingProject;
const [name, setName] = useState(existingProject?.name ?? ""); const [name, setName] = useState(existingProject?.name ?? "");
const [description, setDescription] = useState(
existingProject?.description ?? "",
);
const [databaseIds, setDatabaseIds] = useState<string[]>( const [databaseIds, setDatabaseIds] = useState<string[]>(
existingProject?.databaseIds ?? [], existingProject?.databaseIds ?? [],
); );
@@ -40,7 +36,7 @@ export const StepProjectCreate = () => {
: [...databaseIds, id]; : [...databaseIds, id];
setDatabaseIds(newDbIds); setDatabaseIds(newDbIds);
mutation.mutate( mutation.mutate(
{ name: name || "My project", description, databaseIds: newDbIds }, { name: name || "My project", databaseIds: newDbIds },
{ onSettled: () => setLoadingDbId(null) } { onSettled: () => setLoadingDbId(null) }
); );
}; };
@@ -64,23 +60,14 @@ export const StepProjectCreate = () => {
placeholder="My project" placeholder="My project"
/> />
</div> </div>
<div className="flex flex-col gap-2">
<Label htmlFor="project-description">Description</Label>
<Textarea
id="project-description"
value={description}
style={{ resize: "none" }}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
{databases.length > 0 && ( {databases.length > 0 && (
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Databases</Label> <Label>Databases</Label>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2 max-h-52 sm:max-h-64 md:max-h-80 overflow-y-auto scrollbar-hide">
{databases.map((db) => { {databases.map((db) => {
const isSelected = databaseIds.includes(db.id); const isSelected = databaseIds.includes(db.id);
const isCurrentLoading = loadingDbId === db.id; const isCurrentLoading = loadingDbId === db.id;
return ( return (
<button <button
key={db.id} key={db.id}
@@ -124,7 +111,7 @@ export const StepProjectCreate = () => {
type="button" type="button"
onClick={() => onClick={() =>
mutation.mutate( mutation.mutate(
{ name: name || "My project", description, databaseIds }, { name: name || "My project", databaseIds },
{ onSuccess: () => next() }, { onSuccess: () => next() },
) )
} }
+85 -50
View File
@@ -1,64 +1,99 @@
"use client"; "use client";
import { useEffect } from "react"; import { useState } from "react";
import { useOnboarding } from "@onboardjs/react"; import { useOnboarding } from "@onboardjs/react";
import { KeyRound, ShieldCheck } from "lucide-react"; import { KeyRound, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Loader2 } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { authClient } from "@/lib/auth/auth-client";
import { TwoFactorSetupContent } from "@/features/profile/two-factor-setup-content";
import type { OnboardingMeta } from "@/features/onboarding/types"; import type { OnboardingMeta } from "@/features/onboarding/types";
export const StepSecurity = () => { export const StepSecurity = () => {
const { next, updateContext, state } = useOnboarding(); const { next, updateContext, state } = useOnboarding();
const meta = state?.context.flowData.meta as OnboardingMeta | undefined; const meta = state?.context.flowData.meta as OnboardingMeta | undefined;
const passkeyEnabled = meta?.passkeyEnabled ?? false; const passkeyEnabled = meta?.passkeyEnabled ?? false;
const alreadySecured = !!state?.context.flowData.security;
useEffect(() => { const [phase, setPhase] = useState<"choose" | "two-factor">("choose");
if (alreadySecured) next();
}, [alreadySecured]);
const choose = async (method: "passkey" | "two-factor") => { const { mutate: addPasskey, isPending: isAddingPasskey } = useMutation({
await updateContext({ flowData: { ...state?.context.flowData, security: { method } } }); mutationFn: async () => {
await next(); const result = await authClient.passkey.addPasskey({ name: "My Passkey" });
}; if (result?.error) throw result.error;
return result;
},
onSuccess: async () => {
toast.success("Passkey added successfully.");
await updateContext({ flowData: { ...state?.context.flowData, security: { method: "passkey" } } });
await next();
},
onError: (e: any) => toast.error(e.message || "Failed to add passkey"),
});
const handleTwoFactorSuccess = async () => {
await updateContext({ flowData: { ...state?.context.flowData, security: { method: "two-factor" } } });
await next();
};
if (phase === "two-factor") {
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<div> <div>
<h1 className="text-2xl font-semibold">Secure your account</h1> <h1 className="text-2xl font-semibold">Set up two-factor</h1>
<p className="text-sm text-muted-foreground mt-1"> <p className="text-sm text-muted-foreground mt-1">
{passkeyEnabled Add an extra layer of security to your account.
? "Set up a passkey for faster, safer sign-in." </p>
: "Set up two-factor authentication to protect your account."}
</p>
</div>
{passkeyEnabled ? (
<button
type="button"
onClick={() => choose("passkey")}
className="flex items-center gap-3 rounded-lg border border-border p-4 text-sm hover:bg-accent/50 hover:border-primary/20 transition-colors w-full text-left"
>
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
<KeyRound className="size-4 text-muted-foreground" />
</div>
<div className="flex flex-col gap-0.5">
<span className="font-medium">Set up passkey</span>
<span className="text-xs text-muted-foreground">Faster, safer sign-in</span>
</div>
</button>
) : (
<button
type="button"
onClick={() => choose("two-factor")}
className="flex items-center gap-3 rounded-lg border border-border p-4 text-sm hover:bg-accent/50 hover:border-primary/20 transition-colors w-full text-left"
>
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
<ShieldCheck className="size-4 text-muted-foreground" />
</div>
<div className="flex flex-col gap-0.5">
<span className="font-medium">Set up two-factor</span>
<span className="text-xs text-muted-foreground">Add an extra layer of security</span>
</div>
</button>
)}
</div> </div>
<TwoFactorSetupContent onSuccess={handleTwoFactorSuccess} />
</div>
); );
}
return (
<div className="flex flex-col gap-4">
<div>
<h1 className="text-2xl font-semibold">Secure your account</h1>
<p className="text-sm text-muted-foreground mt-1">
Choose a method to protect your account.
</p>
</div>
{passkeyEnabled && (
<button
type="button"
disabled={isAddingPasskey}
onClick={() => addPasskey()}
className="flex items-center gap-3 rounded-lg border border-border p-4 text-sm hover:bg-accent/50 hover:border-primary/20 transition-colors w-full text-left disabled:opacity-50"
>
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
{isAddingPasskey ? <Loader2 className="size-4 animate-spin" /> : <KeyRound className="size-4 text-muted-foreground" />}
</div>
<div className="flex flex-col gap-0.5">
<span className="font-medium">Set up passkey</span>
<span className="text-xs text-muted-foreground">Faster, safer sign-in with biometrics</span>
</div>
</button>
)}
<button
type="button"
onClick={() => setPhase("two-factor")}
className="flex items-center gap-3 rounded-lg border border-border p-4 text-sm hover:bg-accent/50 hover:border-primary/20 transition-colors w-full text-left"
>
<div className="size-9 rounded-md border bg-muted/50 shadow-sm flex items-center justify-center shrink-0">
<ShieldCheck className="size-4 text-muted-foreground" />
</div>
<div className="flex flex-col gap-0.5">
<span className="font-medium">Set up two-factor authentication</span>
<span className="text-xs text-muted-foreground">Secure your account with a TOTP app</span>
</div>
</button>
<Button type="button" variant="ghost" onClick={() => next()}>
Skip for now
</Button>
</div>
);
}; };
+10 -8
View File
@@ -161,14 +161,16 @@ export const StepStorage = () => {
<span className="flex-1 truncate"> <span className="flex-1 truncate">
{ch.name} <span className="opacity-60">({ch.label})</span> {ch.name} <span className="opacity-60">({ch.label})</span>
</span> </span>
<button {ch.organizationId !== null && (
type="button" <button
onClick={() => removeStorage.mutate(ch.id)} type="button"
disabled={removeStorage.isPending} onClick={() => removeStorage.mutate(ch.id)}
className="opacity-50 hover:opacity-100 transition-opacity" disabled={removeStorage.isPending}
> className="opacity-50 hover:opacity-100 transition-opacity"
<X className="size-4" /> >
</button> <X className="size-4" />
</button>
)}
</div> </div>
); );
})} })}
+5
View File
@@ -40,11 +40,16 @@ export type OnboardingChannel = {
label: string; label: string;
name: string; name: string;
config: Record<string, unknown>; config: Record<string, unknown>;
organizationId?: string | null;
}; };
export type AvatarMode = 'internal' | 'gravatar' | 'dicebear';
export type OnboardingDefaultsData = { export type OnboardingDefaultsData = {
notifierId?: string; notifierId?: string;
storageId?: string; storageId?: string;
avatarMode?: AvatarMode;
dicebearStyle?: string;
}; };
export type OnboardingAgent = { export type OnboardingAgent = {
@@ -10,6 +10,15 @@ import {auth, checkSlugOrganization, createOrganization} from "@/lib/auth/auth";
import {slugify} from "@/utils/slugify"; import {slugify} from "@/utils/slugify";
import {Organization} from "@/db/schema/03_organization"; import {Organization} from "@/db/schema/03_organization";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {getUserOrganization} from "@/db/services/organization";
export const getMyOrganizationAction = userAction.schema(z.object({})).action(async ({ ctx }): Promise<ServerActionResult<Organization>> => {
const org = await getUserOrganization(ctx.user.id);
if (!org) {
return { success: false, actionError: { message: "No organisation found.", status: 404 } };
}
return { success: true, value: org as Organization };
});
export const createOrganizationAction = userAction.schema(CreateOrganizationSchema).action(async ({parsedInput}): Promise<ServerActionResult<Organization>> => { export const createOrganizationAction = userAction.schema(CreateOrganizationSchema).action(async ({parsedInput}): Promise<ServerActionResult<Organization>> => {
try { try {
@@ -1,14 +1,14 @@
import { z } from "zod"; import { z } from "zod";
export const CreateOrganizationSchema = z.object({ export const CreateOrganizationSchema = z.object({
name: z.string().min(5, "Name must be at least 5 characters long").max(40, "Name must be at most 40 characters long"), name: z.string().min(2, "Name must be at least 2 characters long").max(40, "Name must be at most 40 characters long"),
}); });
export const UpdateOrganizationSchema = z.object({ export const UpdateOrganizationSchema = z.object({
name: z.string().min(5, 'Name must be at least 5 characters long').max(40, 'Name must be at most 40 characters long'), name: z.string().min(2, 'Name must be at least 2 characters long').max(40, 'Name must be at most 40 characters long'),
slug: z.string() slug: z.string()
.regex(/^[a-zA-Z0-9_-]*$/, 'Slug can only contain letters, numbers, underscores, and hyphens') .regex(/^[a-zA-Z0-9_-]*$/, 'Slug can only contain letters, numbers, underscores, and hyphens')
.min(5, 'Slug must be at least 5 characters long') .min(2, 'Slug must be at least 2 characters long')
.max(20, 'Slug must be at most 20 characters long'), .max(20, 'Slug must be at most 20 characters long'),
users: z.array(z.string()), users: z.array(z.string()),
}); });
+21 -14
View File
@@ -8,13 +8,18 @@ import {updateImageUserAction} from "@/features/profile/avatar.action";
import {useRouter} from "next/navigation"; import {useRouter} from "next/navigation";
import {User} from "@/db/schema/02_user"; import {User} from "@/db/schema/02_user";
import React, {ChangeEvent} from "react"; import React, {ChangeEvent} from "react";
import type {AvatarMode} from "@/features/onboarding/types";
export type AvatarWithUploadProps = { export type AvatarWithUploadProps = {
user: User; user: User;
avatarMode?: AvatarMode;
avatarUrl?: string;
}; };
export const AvatarWithUpload = (props: AvatarWithUploadProps) => { export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
const user = props.user; const user = props.user;
const canUpload = !props.avatarMode || props.avatarMode === "internal";
const src = props.avatarUrl ?? user.image ?? undefined;
const router = useRouter(); const router = useRouter();
const submitImage = useMutation({ const submitImage = useMutation({
@@ -71,23 +76,25 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
<div className="relative "> <div className="relative ">
<Avatar className="w-24 h-24 lg:w-32 lg:h-32 border-4 border-muted/20"> <Avatar className="w-24 h-24 lg:w-32 lg:h-32 border-4 border-muted/20">
<AvatarImage className="object-cover" src={user.image || undefined}/> <AvatarImage className="object-cover" src={src}/>
<AvatarFallback className="text-3xl">{(user.name?.charAt(0) ?? user.email?.charAt(0) ?? "?").toUpperCase()}</AvatarFallback> <AvatarFallback className="text-3xl">{(user.name?.charAt(0) ?? user.email?.charAt(0) ?? "?").toUpperCase()}</AvatarFallback>
</Avatar> </Avatar>
<div {canUpload && (
onClick={() => { <div
const fileInput = document.createElement("input"); onClick={() => {
fileInput.type = "file"; const fileInput = document.createElement("input");
fileInput.accept = ".jpg,.jpeg,.png,.webp"; fileInput.type = "file";
fileInput.onchange = (e: Event) => fileInput.accept = ".jpg,.jpeg,.png,.webp";
handleImageUpload(e as unknown as React.ChangeEvent<HTMLInputElement>); fileInput.onchange = (e: Event) =>
fileInput.click(); handleImageUpload(e as unknown as React.ChangeEvent<HTMLInputElement>);
}} fileInput.click();
className="cursor-pointer absolute inset-0 flex justify-center items-center opacity-0 transition-opacity hover:opacity-30 hover:bg-gray-500 hover:bg-opacity-50 rounded-full w-24 h-24 lg:w-32 lg:h-32" }}
> className="cursor-pointer absolute inset-0 flex justify-center items-center opacity-0 transition-opacity hover:opacity-30 hover:bg-gray-500 hover:bg-opacity-50 rounded-full w-24 h-24 lg:w-32 lg:h-32"
<UploadIcon className="w-12 h-12 lg:w-16 lg:h-16 text-primary"/> >
</div> <UploadIcon className="w-12 h-12 lg:w-16 lg:h-16 text-primary"/>
</div>
)}
</div> </div>
); );
}; };
+6 -1
View File
@@ -22,12 +22,15 @@ import {updateProfileSettingsAction} from "./profile.action";
import {User} from "@/db/schema/02_user"; import {User} from "@/db/schema/02_user";
import {ProfileSchema, ProfileSchemaType} from "./general.schema"; import {ProfileSchema, ProfileSchemaType} from "./general.schema";
import {AvatarWithUpload} from "@/features/profile/avatar-with-upload"; import {AvatarWithUpload} from "@/features/profile/avatar-with-upload";
import type { AvatarMode } from "@/features/onboarding/types";
interface ProfileGeneralProps { interface ProfileGeneralProps {
user: User; user: User;
avatarMode?: AvatarMode;
avatarUrl?: string;
} }
export function ProfileGeneral({user}: ProfileGeneralProps) { export function ProfileGeneral({user, avatarMode, avatarUrl}: ProfileGeneralProps) {
const router = useRouter(); const router = useRouter();
const profileForm = useZodForm({ const profileForm = useZodForm({
@@ -63,6 +66,8 @@ export function ProfileGeneral({user}: ProfileGeneralProps) {
<div className="flex flex-col items-center gap-4"> <div className="flex flex-col items-center gap-4">
<AvatarWithUpload <AvatarWithUpload
user={user} user={user}
avatarMode={avatarMode}
avatarUrl={avatarUrl}
/> />
</div> </div>
+34 -245
View File
@@ -1,256 +1,45 @@
"use client"; "use client";
import React, {useState} from "react"; import { useRouter } from "next/navigation";
import {Button} from "@/components/ui/button"; import { ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
DialogDescription, DialogHeader,
DialogHeader, DialogTitle,
DialogTitle, DialogTrigger,
DialogTrigger
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form"; import { TwoFactorSetupContent } from "./two-factor-setup-content";
import {Loader2, Copy, CheckCircle2, ShieldCheck} from "lucide-react";
import {useMutation} from "@tanstack/react-query";
import {useRouter} from "next/navigation";
import {Setup2FASecuritySchema, Setup2FASecuritySchemaType} from "./security.schema";
import {toast} from "sonner";
import {authClient} from "@/lib/auth/auth-client";
import {Alert, AlertDescription} from "@/components/ui/alert";
import {InputOTP, InputOTPGroup, InputOTPSlot} from "@/components/ui/input-otp";
import QRCode from "react-qr-code";
import z from "zod";
import {zPassword} from "@/lib/zod";
import {BackupCodesList} from "./backup-codes-list";
import {PasswordInput} from "@/components/ui/password-input";
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from "@/components/ui/tooltip";
const PasswordSchema = z.object({
password: zPassword(),
});
type Password = z.infer<typeof PasswordSchema>;
type Setup2FAModalProps = { type Setup2FAModalProps = {
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
open: boolean; open: boolean;
disabled: boolean; disabled: boolean;
}; };
export function Setup2FAProfileProviderModal({onOpenChange, open, disabled}: Setup2FAModalProps) { export function Setup2FAProfileProviderModal({ onOpenChange, open, disabled }: Setup2FAModalProps) {
const router = useRouter();
const router = useRouter(); const handleSuccess = () => {
const [step, setStep] = useState<"PASSWORD" | "QR" | "BACKUP">("PASSWORD"); router.refresh();
onOpenChange(false);
};
const [totpURI, setTotpURI] = useState<string>(""); return (
const [secret, setSecret] = useState<string>(""); <Dialog open={open} onOpenChange={onOpenChange}>
const [backupCodes, setBackupCodes] = useState<string[]>([]); <DialogTrigger asChild disabled={disabled}>
<Button variant="outline" size="sm">
const form = useZodForm({ <ShieldCheck className="w-4 h-4 mr-2" />
schema: Setup2FASecuritySchema, Enable Two-Factor
defaultValues: { </Button>
code: "", </DialogTrigger>
}, <DialogContent className="max-w-md">
}); <DialogHeader>
<DialogTitle>Enable Two-Factor Authentication</DialogTitle>
const passwordForm = useZodForm({ </DialogHeader>
schema: PasswordSchema, <TwoFactorSetupContent onSuccess={handleSuccess} />
defaultValues: { </DialogContent>
password: "", </Dialog>
}, );
});
const {mutate: enable2FA, isPending: isEnabling} = useMutation({
mutationFn: async (values: Password) => {
const {data, error} = await authClient.twoFactor.enable({
password: values.password,
});
if (error) throw error;
return data;
},
onSuccess: (data) => {
setTotpURI(data.totpURI);
setSecret(data.totpURI.split("secret=")[1].split("&")[0]);
setBackupCodes(data.backupCodes || []);
setStep("QR");
},
onError: () => {
toast.error("Failed to enable two-factor authentication.");
},
});
const {mutate: verify2FA, isPending: isVerifying} = useMutation({
mutationFn: async (values: Setup2FASecuritySchemaType) => {
const {data, error} = await authClient.twoFactor.verifyTotp({
code: values.code,
trustDevice: true,
});
if (error) throw error;
return data;
},
onSuccess: () => {
toast.success("Two-factor authentication enabled successfully.");
setStep("BACKUP");
},
onError: () => {
toast.error("The provided code is invalid.");
form.reset();
},
});
const handleCopySecret = () => {
navigator.clipboard.writeText(secret);
toast.success("Secret copied to clipboard");
};
const handleClose = () => {
router.refresh();
onOpenChange(false);
setStep("PASSWORD");
form.reset();
passwordForm.reset();
};
return (
<Dialog open={open} onOpenChange={(v) => (!v ? handleClose() : onOpenChange(v))}>
<DialogTrigger asChild disabled={disabled}>
<Button variant="outline" size="sm">
<ShieldCheck className="w-4 h-4 mr-2"/>
Enable Two-Factor
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Enable Two-Factor Authentication</DialogTitle>
<DialogDescription>
{step === "PASSWORD" && ""}
{step === "QR" && "Scan the QR code below with your authentication app or enter the secret key manually."}
{step === "BACKUP" && "Save these backup codes in a secure location. They can be used to access your account if you lose access to your authentication device."}
</DialogDescription>
</DialogHeader>
{step === "PASSWORD" && (
<Form form={passwordForm} onSubmit={async (values) => enable2FA(values)}>
<div className="space-y-4 py-4">
<div className="space-y-2">
<FormField
control={passwordForm.control}
name="password"
render={({field}) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormControl>
<PasswordInput placeholder="Fill your current password" {...field} />
</FormControl>
<FormMessage/>
</FormItem>
)}
/>
</div>
<div className="flex justify-end">
<Button type="submit" disabled={isEnabling || !passwordForm.formState.isDirty}>
{isEnabling && <Loader2 className="mr-2 h-4 w-4 animate-spin"/>}
Continue
</Button>
</div>
</div>
</Form>
)}
{step === "QR" && (
<Form
form={form}
onSubmit={async (values) => {
verify2FA(values);
}}
>
<div className="flex flex-col items-center justify-center space-y-6 py-4">
<div className="p-4 bg-white rounded-xl shadow-sm border">
{totpURI && (
<QRCode value={totpURI} size={180}
style={{height: "auto", maxWidth: "100%", width: "100%"}}
viewBox={`0 0 256 256`}/>
)}
</div>
<div className="w-full space-y-2">
<p className="text-xs text-muted-foreground text-center">If you are unable to scan the
QR code, you can manually enter the secret key into your authentication app :</p>
<div className="flex items-center gap-2">
<code
className="flex-1 bg-muted p-2 rounded text-xs font-mono break-all text-center">{secret}</code>
<Button type="button" size="icon" variant="ghost" onClick={handleCopySecret}>
<Copy className="h-4 w-4"/>
</Button>
</div>
</div>
<div className="w-full border-t pt-4">
<FormField
control={form.control}
name="code"
render={({field}) => (
<FormItem className="flex flex-col items-center">
<FormLabel className="mb-2">Verification Code</FormLabel>
<FormControl>
<InputOTP
maxLength={6}
{...field}
autoFocus
onChange={(value) => {
field.onChange(value);
if (value.length === 6) {
verify2FA(form.getValues());
}
}}
>
<InputOTPGroup>
<InputOTPSlot index={0}/>
<InputOTPSlot index={1}/>
<InputOTPSlot index={2}/>
<InputOTPSlot index={3}/>
<InputOTPSlot index={4}/>
<InputOTPSlot index={5}/>
</InputOTPGroup>
</InputOTP>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="flex justify-end w-full">
<Button disabled={isVerifying} type="submit">
{isVerifying && <Loader2 className="mr-2 h-4 w-4 animate-spin"/>}
I've Configured My App
</Button>
</div>
</div>
</Form>
)}
{step === "BACKUP" && (
<div className="space-y-6 py-4">
<Alert variant="default"
className="border-green-200 bg-green-50 dark:bg-green-900/20 dark:border-green-900">
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400"/>
<AlertDescription
className="text-green-700 dark:text-green-400">Two Factor Authentication is now enabled
on your account.</AlertDescription>
</Alert>
<BackupCodesList codes={backupCodes}/>
<div className="flex justify-end pt-2">
<Button onClick={handleClose}>Finish Setup</Button>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
} }
@@ -0,0 +1,171 @@
"use client";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
import { Loader2, Copy, CheckCircle2 } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { Setup2FASecuritySchema, Setup2FASecuritySchemaType } from "./security.schema";
import { toast } from "sonner";
import { authClient } from "@/lib/auth/auth-client";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp";
import QRCode from "react-qr-code";
import z from "zod";
import { zPassword } from "@/lib/zod";
import { BackupCodesList } from "./backup-codes-list";
import { PasswordInput } from "@/components/ui/password-input";
const PasswordSchema = z.object({ password: zPassword() });
type Password = z.infer<typeof PasswordSchema>;
type Props = {
onSuccess: () => void;
};
export function TwoFactorSetupContent({ onSuccess }: Props) {
const [step, setStep] = useState<"PASSWORD" | "QR" | "BACKUP">("PASSWORD");
const [totpURI, setTotpURI] = useState("");
const [secret, setSecret] = useState("");
const [backupCodes, setBackupCodes] = useState<string[]>([]);
const otpForm = useZodForm({ schema: Setup2FASecuritySchema, defaultValues: { code: "" } });
const passwordForm = useZodForm({ schema: PasswordSchema, defaultValues: { password: "" } });
const { mutate: enable2FA, isPending: isEnabling } = useMutation({
mutationFn: async (values: Password) => {
const { data, error } = await authClient.twoFactor.enable({ password: values.password });
if (error) throw error;
return data;
},
onSuccess: (data) => {
setTotpURI(data.totpURI);
setSecret(data.totpURI.split("secret=")[1].split("&")[0]);
setBackupCodes(data.backupCodes || []);
setStep("QR");
},
onError: () => toast.error("Failed to enable two-factor authentication."),
});
const { mutate: verify2FA, isPending: isVerifying } = useMutation({
mutationFn: async (values: Setup2FASecuritySchemaType) => {
const { data, error } = await authClient.twoFactor.verifyTotp({ code: values.code, trustDevice: true });
if (error) throw error;
return data;
},
onSuccess: () => {
toast.success("Two-factor authentication enabled successfully.");
setStep("BACKUP");
},
onError: () => {
toast.error("The provided code is invalid.");
otpForm.reset();
},
});
const handleCopySecret = () => {
navigator.clipboard.writeText(secret);
toast.success("Secret copied to clipboard");
};
if (step === "PASSWORD") {
return (
<Form form={passwordForm} onSubmit={(values) => enable2FA(values)}>
<div className="space-y-4">
<FormField
control={passwordForm.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel>Current Password</FormLabel>
<FormControl>
<PasswordInput placeholder="Fill your current password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex justify-end">
<Button type="submit" disabled={isEnabling || !passwordForm.formState.isDirty}>
{isEnabling && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Continue
</Button>
</div>
</div>
</Form>
);
}
if (step === "QR") {
return (
<Form form={otpForm} onSubmit={(values) => verify2FA(values)}>
<div className="flex flex-col items-center space-y-4">
<p className="text-sm text-muted-foreground text-center">
Scan the QR code with your authentication app or enter the secret key manually.
</p>
<div className="p-4 bg-white rounded-xl shadow-sm border">
{totpURI && (
<QRCode value={totpURI} size={160} style={{ height: "auto", maxWidth: "100%", width: "100%" }} viewBox="0 0 256 256" />
)}
</div>
<div className="w-full space-y-1">
<p className="text-xs text-muted-foreground text-center">Secret key:</p>
<div className="flex items-center gap-2">
<code className="flex-1 bg-muted p-2 rounded text-xs font-mono break-all text-center">{secret}</code>
<Button type="button" size="icon" variant="ghost" onClick={handleCopySecret}>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
<div className="w-full border-t pt-4">
<FormField
control={otpForm.control}
name="code"
render={({ field }) => (
<FormItem className="flex flex-col items-center">
<FormLabel className="mb-2">Verification Code</FormLabel>
<FormControl>
<InputOTP
maxLength={6}
{...field}
autoFocus
onChange={(value) => {
field.onChange(value);
if (value.length === 6) verify2FA(otpForm.getValues());
}}
>
<InputOTPGroup>
{[0, 1, 2, 3, 4, 5].map((i) => <InputOTPSlot key={i} index={i} />)}
</InputOTPGroup>
</InputOTP>
</FormControl>
</FormItem>
)}
/>
</div>
<div className="flex justify-end w-full">
<Button disabled={isVerifying} type="submit">
{isVerifying && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
I've Configured My App
</Button>
</div>
</div>
</Form>
);
}
return (
<div className="space-y-4">
<Alert variant="default" className="border-green-200 bg-green-50 dark:bg-green-900/20 dark:border-green-900">
<CheckCircle2 className="h-4 w-4 text-green-600 dark:text-green-400" />
<AlertDescription className="text-green-700 dark:text-green-400">
Two Factor Authentication is now enabled on your account.
</AlertDescription>
</Alert>
<BackupCodesList codes={backupCodes} />
<div className="flex justify-end pt-2">
<Button onClick={onSuccess}>Finish Setup</Button>
</div>
</div>
);
}
+1
View File
@@ -123,6 +123,7 @@ export const ProjectForm = (props: projectFormProps) => {
placeholder="Select databases" placeholder="Select databases"
variant="inverted" variant="inverted"
animation={2} animation={2}
modalPopover={true}
/> />
</FormControl> </FormControl>
<FormDescription>Select databases you want to add to this project</FormDescription> <FormDescription>Select databases you want to add to this project</FormDescription>
@@ -0,0 +1,54 @@
"use client";
import { Dices, Globe, Upload } from "lucide-react";
import { cn } from "@/lib/utils";
import { Label } from "@/components/ui/label";
import type { AvatarMode } from "@/features/onboarding/types";
const MODES: { value: AvatarMode; label: string; description: string; icon: React.ReactNode }[] = [
{ value: "internal", label: "Internal", description: "Users upload their own avatar", icon: <Upload className="size-4" /> },
{ value: "gravatar", label: "Gravatar", description: "Avatar fetched from gravatar.com by email", icon: <Globe className="size-4" /> },
{ value: "dicebear", label: "DiceBear", description: "Auto-generated avatar via DiceBear", icon: <Dices className="size-4" /> },
];
type Props = {
value: AvatarMode;
onChange: (mode: AvatarMode) => void;
disabled?: boolean;
};
export const AvatarModeSelector = ({ value, onChange, disabled }: Props) => (
<div className="flex flex-col gap-2">
<Label>Avatar mode</Label>
<div className="grid grid-cols-3 gap-2">
{MODES.map((mode) => {
const isActive = value === mode.value;
return (
<button
key={mode.value}
type="button"
disabled={disabled}
onClick={() => onChange(mode.value)}
className={cn(
"flex flex-col gap-2 rounded-xl border-2 p-3 text-left transition-all hover:bg-accent/50 disabled:opacity-50",
isActive ? "border-primary bg-primary/5" : "border-muted/40",
)}
>
<div className="flex items-center justify-between">
<div className={cn("p-1.5 rounded-md", isActive ? "bg-primary/10 text-primary" : "bg-muted text-muted-foreground")}>
{mode.icon}
</div>
<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>
<p className="text-sm font-medium">{mode.label}</p>
<p className="text-xs text-muted-foreground mt-0.5">{mode.description}</p>
</div>
</button>
);
})}
</div>
</div>
);
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Setting } from "@/db/schema/01_setting";
import { updateAvatarModeAction } from "@/features/settings/avatar.action";
import { AvatarModeSelector } from "@/features/settings/avatar-mode-selector";
import { DicebearStylePicker } from "@/features/settings/dicebear-style-picker";
import type { AvatarMode } from "@/features/onboarding/types";
type Props = { settings: Setting };
export const SettingsAvatarSection = ({ settings }: Props) => {
const router = useRouter();
const [avatarMode, setAvatarMode] = useState<AvatarMode>(settings.avatarMode ?? "internal");
const [dicebearStyle, setDicebearStyle] = useState<string>(settings.dicebearStyle ?? "thumbs");
const mutation = useMutation({
mutationFn: async ({ mode, style }: { mode: AvatarMode; style: string }) => {
const result = await updateAvatarModeAction({ name: "system", avatarMode: mode, dicebearStyle: style });
if (result?.data?.success === false || result?.serverError) {
throw new Error(result?.serverError ?? "Failed to update");
}
},
onSuccess: () => {
toast.success("Avatar settings saved");
router.refresh();
},
onError: (e: Error) => toast.error(e.message),
});
const handleModeChange = (mode: AvatarMode) => {
setAvatarMode(mode);
mutation.mutate({ mode, style: dicebearStyle });
};
const handleStyleChange = (style: string) => {
setDicebearStyle(style);
mutation.mutate({ mode: avatarMode, style });
};
return (
<div className="flex flex-col gap-6 max-w-2xl">
<div>
<h2 className="text-lg font-semibold">Avatar</h2>
<p className="text-sm text-muted-foreground mt-1">
Choose how user avatars are generated across the platform.
</p>
</div>
<AvatarModeSelector value={avatarMode} onChange={handleModeChange} disabled={mutation.isPending} />
{avatarMode === "dicebear" && (
<DicebearStylePicker value={dicebearStyle} onChange={handleStyleChange} disabled={mutation.isPending} />
)}
</div>
);
};
+42
View File
@@ -0,0 +1,42 @@
"use server";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import { userAction } from "@/lib/safe-actions/actions";
import { withUpdatedAt } from "@/db/utils";
import { ServerActionResult } from "@/types/action-type";
import { Setting } from "@/db/schema/01_setting";
const AVATAR_MODES = ['internal', 'gravatar', 'dicebear'] as const;
export const updateAvatarModeAction = userAction
.schema(z.object({
name: z.string(),
avatarMode: z.enum(AVATAR_MODES),
dicebearStyle: z.string().optional(),
}))
.action(async ({ parsedInput }): Promise<ServerActionResult<Setting>> => {
const { name, avatarMode, dicebearStyle } = parsedInput;
try {
const [updated] = await db
.update(drizzleDb.schemas.setting)
.set(withUpdatedAt({
avatarMode,
...(dicebearStyle ? { dicebearStyle } : {}),
}))
.where(eq(drizzleDb.schemas.setting.name, name))
.returning();
return { success: true, value: updated, actionSuccess: { message: "Avatar mode updated." } };
} catch (_error) {
return {
success: false,
actionError: {
message: "Failed to update avatar mode.",
status: 500,
cause: _error instanceof Error ? _error.message : "Unknown error",
},
};
}
});
@@ -0,0 +1,83 @@
"use client";
import { AlertCircle, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useDiceBearStyles } from "@/features/settings/use-dicebear-styles";
const DEMO_SEED = "portabase";
type Props = {
value: string;
onChange: (style: string) => void;
disabled?: boolean;
};
export const DicebearStylePicker = ({ value, onChange, disabled }: Props) => {
const dicebearState = useDiceBearStyles();
return (
<div className="flex flex-col gap-2 mt-1 p-3 rounded-xl border border-border bg-muted/20">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
{dicebearState.status === "error" && (
<span className="flex items-center gap-1 text-[10px] text-amber-500">
<AlertCircle className="size-3" /> offline
</span>
)}
</div>
</div>
{dicebearState.status === "loading" && (
<div className="flex items-center justify-center h-32 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
)}
{dicebearState.status === "error" && (
<div className="flex flex-col items-center justify-center h-32 gap-2 text-muted-foreground">
<AlertCircle className="size-5 text-amber-500" />
<span className="text-xs">
Unable to load styles check your connection
</span>
</div>
)}
{dicebearState.status === "success" && (
<div className="max-h-52 overflow-y-auto scrollbar-hide">
<div className="grid grid-cols-5 gap-2">
{dicebearState.styles.map((style) => {
const isActive = value === style;
return (
<button
key={style}
type="button"
disabled={disabled}
onClick={() => onChange(style)}
title={style}
className={cn(
"flex flex-col items-center gap-1 rounded-lg border-2 p-1.5 transition-all hover:bg-accent/50 disabled:opacity-50",
isActive
? "border-primary bg-primary/5"
: "border-transparent hover:border-muted",
)}
>
<img
src={`https://api.dicebear.com/10.x/${style}/svg?seed=${DEMO_SEED}`}
alt={style}
width={36}
height={36}
loading="lazy"
className="size-9 rounded bg-muted"
/>
<span className="text-[10px] text-muted-foreground truncate w-full text-center leading-tight">
{style}
</span>
</button>
);
})}
</div>
</div>
)}
</div>
);
};
+8 -1
View File
@@ -54,7 +54,14 @@ export const EmailForm = (props: EmailFormProps) => {
return; return;
} }
toast.success(`Success updating email informations`); toast.success(`Success updating email informations`);
form.reset(data); form.reset({
smtpPassword: data.smtpPassword ?? undefined,
smtpFrom: data.smtpFrom ?? undefined,
smtpHost: data.smtpHost ?? undefined,
smtpPort: data.smtpPort ?? undefined,
smtpUser: data.smtpUser ?? undefined,
smtpSecure: data.smtpSecure ?? undefined,
});
router.refresh(); router.refresh();
}, },
}); });
+10 -1
View File
@@ -7,11 +7,12 @@ import {Setting} from "@/db/schema/01_setting";
import {SettingsEmailSection} from "@/features/settings/email-section"; import {SettingsEmailSection} from "@/features/settings/email-section";
import {SettingsStorageSection} from "@/features/settings/storage-section"; import {SettingsStorageSection} from "@/features/settings/storage-section";
import {StorageChannelWith} from "@/db/schema/12_storage-channel"; import {StorageChannelWith} from "@/db/schema/12_storage-channel";
import {AlarmClock, MailboxIcon, Save} from "lucide-react"; import {AlarmClock, MailboxIcon, Save, UserCircle} from "lucide-react";
import { import {
SettingsNotificationSection SettingsNotificationSection
} from "@/features/settings/notification-section"; } from "@/features/settings/notification-section";
import {NotificationChannelWith} from "@/db/schema/09_notification-channel"; import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
import {SettingsAvatarSection} from "@/features/settings/avatar-section";
export type SettingsTabsProps = { export type SettingsTabsProps = {
settings: Setting settings: Setting
@@ -63,6 +64,14 @@ export const SettingsTabs = ({settings, storageChannels, notificationChannels}:
<SettingsNotificationSection notificationChannels={notificationChannels} settings={settings}/> <SettingsNotificationSection notificationChannels={notificationChannels} settings={settings}/>
) )
},
{
name: 'Avatar',
value: 'avatar',
icon: UserCircle,
content: (
<SettingsAvatarSection settings={settings}/>
)
} }
] ]
@@ -0,0 +1,35 @@
"use client";
import { useEffect, useState } from "react";
export type DiceBearStylesState =
| { status: "loading" }
| { status: "error" }
| { status: "success"; styles: string[] };
export function useDiceBearStyles(): DiceBearStylesState {
const [state, setState] = useState<DiceBearStylesState>({ status: "loading" });
useEffect(() => {
let cancelled = false;
fetch("https://api.dicebear.com/10.x", {
headers: { Accept: "application/json" },
})
.then((r) => {
if (!r.ok) throw new Error("api error");
return r.json();
})
.then((data: { styles?: string[] }) => {
if (cancelled) return;
const styles = data?.styles;
if (!Array.isArray(styles) || styles.length === 0) throw new Error("empty");
setState({ status: "success", styles });
})
.catch(() => {
if (!cancelled) setState({ status: "error" });
});
return () => { cancelled = true; };
}, []);
return state;
}
+3 -2
View File
@@ -6,8 +6,9 @@ import {User} from "@/db/schema/02_user";
type AdminUserListProps = { type AdminUserListProps = {
users: User[]; users: User[];
isPasswordAuthEnabled: boolean; isPasswordAuthEnabled: boolean;
avatarUrls?: Record<string, string | undefined>;
}; };
export const AdminUserList = ({ users, isPasswordAuthEnabled }: AdminUserListProps) => { export const AdminUserList = ({ users, isPasswordAuthEnabled, avatarUrls }: AdminUserListProps) => {
return <DataTable columns={usersListColumns({ isPasswordAuthEnabled })} data={users} enablePagination={true} enableSelect={false} />; return <DataTable columns={usersListColumns({ isPasswordAuthEnabled, avatarUrls })} data={users} enablePagination={true} enableSelect={false} />;
}; };
+3 -2
View File
@@ -11,9 +11,10 @@ import {UserActionsCell} from "@/features/users/user-actions-cell";
type UsersListColumnsProps = { type UsersListColumnsProps = {
isPasswordAuthEnabled: boolean; isPasswordAuthEnabled: boolean;
avatarUrls?: Record<string, string | undefined>;
} }
export function usersListColumns({ isPasswordAuthEnabled }: UsersListColumnsProps): ColumnDef<User>[] { export function usersListColumns({ isPasswordAuthEnabled, avatarUrls }: UsersListColumnsProps): ColumnDef<User>[] {
return [ return [
{ {
@@ -27,7 +28,7 @@ export function usersListColumns({ isPasswordAuthEnabled }: UsersListColumnsProp
<TooltipTrigger> <TooltipTrigger>
<div className="flex flex-row items-center gap-x-2"> <div className="flex flex-row items-center gap-x-2">
<Avatar> <Avatar>
<AvatarImage src={row.original.image ?? ""} alt={row.original.name}/> <AvatarImage src={avatarUrls?.[row.original.id] ?? row.original.image ?? ""} alt={row.original.name}/>
<AvatarFallback> <AvatarFallback>
{row.original.name {row.original.name
.split(" ") .split(" ")
+17 -16
View File
@@ -1,24 +1,25 @@
import { z } from "zod"; import { z } from "zod";
export const zString = () => export const zString = () => z.string();
z.string();
export const zEnum = <T extends [string, ...string[]]>(values: T) => z.enum(values, { message: "Field required" }); export const zEnum = <T extends [string, ...string[]]>(values: T) =>
z.enum(values, { message: "Field required" });
export const zEmail = () => z.string().email({ message: "Invalid email" }); export const zEmail = () => z.email({ message: "Invalid email" });
const passwordRegex = /^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-])/; const passwordRegex =
/^(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])(?=.*?[#?!@$%^&*-])/;
export const zPassword = () => zString().min(8, { message: "New Password too short" }).regex(passwordRegex, { message: "New password too weak" }); export const zPassword = () =>
zString()
.min(8, { message: "New Password too short" })
.regex(passwordRegex, { message: "New password too weak" });
export const zDate = () => export const zDate = () =>
z.preprocess( z.preprocess((arg) => {
(arg) => { if (typeof arg === "string" || arg instanceof Date) {
if (typeof arg === "string" || arg instanceof Date) { const date = new Date(arg);
const date = new Date(arg); return isNaN(date.getTime()) ? undefined : date;
return isNaN(date.getTime()) ? undefined : date; }
} return undefined;
return undefined; }, z.date());
},
z.date()
);
-115
View File
@@ -1,115 +0,0 @@
export const organizations = [
{
slug: "default",
name: "Default Organization",
createdAt: "2024-01-10T10:00:00.000Z",
projects: [],
},
{
slug: "tech-corp",
name: "Tech Corp",
createdAt: "2024-01-10T10:00:00.000Z",
projects: [],
},
{
slug: "design-studio",
name: "Design Studio",
createdAt: "2023-09-15T15:45:00.000Z",
projects: [],
},
];
export const projects = [
{
slug: "backend-system",
name: "Backend System",
createdAt: "2024-02-20T11:30:00.000Z",
organizationId: "org-1a2b3c4d",
databases: [],
},
{
slug: "creative-suite",
name: "Creative Suite",
createdAt: "2023-10-10T08:20:00.000Z",
organizationId: "org-2e3f4g5h",
databases: [],
},
];
export const databases = [
{
name: "Main Production DB",
dbms: "postgresql",
generatedId: "prod-db-1",
description: "Primary database for production environment",
backupPolicy: "daily",
createdAt: "2024-11-01T12:00:00.000Z",
agentId: "agent-1234",
lastContact: "2024-11-28T08:30:00.000Z",
projectId: "proj-789",
backups: [],
restorations: [],
},
{
name: "Staging DB",
dbms: "mysql",
generatedId: "staging-db-2",
description: "Database for testing and staging environment",
backupPolicy: "weekly",
createdAt: "2024-10-15T09:45:00.000Z",
agentId: "agent-5678",
lastContact: "2024-11-25T10:15:00.000Z",
projectId: null,
backups: [],
restorations: [],
},
{
name: "Development DB",
dbms: "mongodb",
generatedId: "dev-db-3",
description: null,
backupPolicy: null,
createdAt: "2024-09-20T16:20:00.000Z",
agentId: "agent-9012",
lastContact: null,
projectId: "proj-456",
backups: [],
restorations: [],
},
];
export const backups = [
{ createdAt: "2023-11-27T11:26:54.870914Z", status: "pending" },
{ createdAt: "2023-12-03T11:26:54.870927Z", status: "failed" },
{ createdAt: "2023-12-12T11:26:54.870937Z", status: "success" },
{ createdAt: "2023-11-23T11:26:54.870946Z", status: "processing" },
{ createdAt: "2023-12-09T11:26:54.870955Z", status: "pending" },
{ createdAt: "2023-11-29T11:26:54.870964Z", status: "failed" },
{ createdAt: "2023-12-07T11:26:54.870973Z", status: "success" },
{ createdAt: "2023-11-18T11:26:54.870982Z", status: "processing" },
{ createdAt: "2023-12-01T11:26:54.870991Z", status: "pending" },
{ createdAt: "2023-11-25T11:26:54.871000Z", status: "failed" },
{ createdAt: "2023-11-27T11:26:54.870914Z", status: "pending" },
{ createdAt: "2023-12-03T11:26:54.870927Z", status: "failed" },
{ createdAt: "2023-12-12T11:26:54.870937Z", status: "success" },
{ createdAt: "2023-11-23T11:26:54.870946Z", status: "processing" },
{ createdAt: "2023-12-09T11:26:54.870955Z", status: "pending" },
{ createdAt: "2023-11-29T11:26:54.870964Z", status: "failed" },
{ createdAt: "2023-12-07T11:26:54.870973Z", status: "success" },
{ createdAt: "2023-11-18T11:26:54.870982Z", status: "processing" },
{ createdAt: "2023-12-01T11:26:54.870991Z", status: "pending" },
{ createdAt: "2023-11-25T11:26:54.871000Z", status: "failed" },
];
export const restorations = [
{ backupId: "backup-1", createdAt: "2023-11-27T11:26:54.870914Z", status: "pending" },
{ backupId: "backup-2", createdAt: "2023-12-03T11:26:54.870927Z", status: "failed" },
{ backupId: "backup-3", createdAt: "2023-12-12T11:26:54.870937Z", status: "success" },
{ backupId: "backup-4", createdAt: "2023-11-23T11:26:54.870946Z", status: "processing" },
{ backupId: "backup-5", createdAt: "2023-12-09T11:26:54.870955Z", status: "pending" },
{ backupId: "backup-6", createdAt: "2023-11-29T11:26:54.870964Z", status: "failed" },
{ backupId: "backup-7", createdAt: "2023-12-07T11:26:54.870973Z", status: "success" },
{ backupId: "backup-8", createdAt: "2023-11-18T11:26:54.870982Z", status: "processing" },
{ backupId: "backup-9", createdAt: "2023-12-01T11:26:54.870991Z", status: "pending" },
{ backupId: "backup-10", createdAt: "2023-11-25T11:26:54.871000Z", status: "failed" },
];
+8 -8
View File
@@ -1,11 +1,11 @@
export function extractNameFromEmail(email: string): string { export function extractNameFromEmail(email: string): string {
const localPart = email.split("@")[0]; const localPart = email.split("@")[0];
const nameParts = localPart const nameParts = localPart
.replace(/[_\.\-]/g, " ") // Replace underscores, dots, and hyphens with spaces .replace(/[_\.\-]/g, " ")
.split(" ") // Split into parts .split(" ")
.filter(Boolean); // Remove empty strings .filter(Boolean);
return nameParts return nameParts
.map((part) => part.charAt(0).toUpperCase() + part.slice(1)) // Capitalize each part .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" "); .join(" ");
} }
-148
View File
@@ -1,148 +0,0 @@
export default function detectOSWithUA(userAgent: string) {
const osList = [
{
name: "Windows",
keywords: ["Win", "NT", "Windows"],
icon: {
name: "microsoft-windows-icon",
size: {
width: 24,
height: 0,
},
},
showText: true,
},
{
name: "Ubuntu",
keywords: ["Ubuntu"],
icon: {
name: "ubuntu",
size: {
width: 24,
height: 0,
},
},
showText: true,
},
{
name: "iOS",
keywords: ["iOS"],
icon: {
name: "ios",
size: {
width: 24,
height: 0,
},
},
showText: false,
},
{
name: "iPadOS",
keywords: ["iPadOS", "iPad"],
icon: {
name: "ios",
size: {
width: 24,
height: 0,
},
},
showText: true,
},
{
name: "MacOS",
keywords: ["MacOS", "Macintosh", "Mac OS", "Mac OS X"],
icon: {
name: "macos",
size: {
width: 0,
height: 16,
},
},
showText: false,
},
{
name: "Android",
keywords: ["Android"],
icon: {
name: "android-icon",
size: {
width: 24,
height: 0,
},
},
showText: true,
},
{
name: "Linux",
keywords: ["X11", "Linux"],
icon: {
name: "linux-tux",
size: {
width: 24,
height: 0,
},
},
showText: true,
},
{
name: "Playstation 4",
keywords: ["PlayStation 4"],
showText: true,
},
{
name: "Playstation 5",
keywords: ["PlayStation 5"],
showText: true,
},
{
name: "Xbox Series X",
keywords: ["Xbox Series X"],
showText: true,
},
{
name: "Xbox One S",
keywords: ["XBOX_ONE_ED"],
showText: true,
},
{
name: "Xbox One",
keywords: ["Xbox One"],
showText: true,
},
{
name: "Nintendo Switch",
keywords: ["Nintendo Switch"],
showText: true,
},
{
name: "AppleTV",
keywords: ["AppleTV"],
icon: {
name: "apple",
size: {
width: 24,
height: 0,
},
},
showText: true,
},
];
for (let os of osList) {
if (os.keywords.some((keyword) => userAgent.includes(keyword))) {
return os;
}
}
return {
name: "Unknown OS",
icon: {
name: "unknown",
size: {
width: 24,
height: 0,
},
},
showText: true,
};
}
+27
View File
@@ -0,0 +1,27 @@
import { createHash } from "crypto";
import type { Setting } from "@/db/schema/01_setting";
import type { User } from "@/db/schema/02_user";
export function resolveAvatarUrl(
user: Pick<User, "email" | "image">,
settings: Pick<Setting, "avatarMode" | "dicebearStyle"> | null | undefined,
): string | undefined {
const mode = settings?.avatarMode ?? "internal";
if (mode === "gravatar") {
const hash = createHash("md5")
.update((user.email ?? "").trim().toLowerCase())
.digest("hex");
return `https://www.gravatar.com/avatar/${hash}?s=200&d=mp`;
}
if (mode === "dicebear") {
const style = settings?.dicebearStyle ?? "thumbs";
const hash = createHash("md5")
.update((user.email ?? "").trim().toLowerCase())
.digest("hex");
return `https://api.dicebear.com/10.x/${style}/svg?seed=${hash}`;
}
return user.image ?? undefined;
}
+42 -46
View File
@@ -1,14 +1,12 @@
import {promises as fs} from 'fs'; import { promises as fs } from "fs";
import path from 'path'; import path from "path";
import {generateKeyPair} from 'crypto'; import { generateKeyPair } from "crypto";
import {promisify} from 'util'; import { promisify } from "util";
import {randomBytes} from 'crypto'; import { randomBytes } from "crypto";
import {env} from "@/env.mjs"; import { env } from "@/env.mjs";
import {logger} from "@/lib/logger"; import { logger } from "@/lib/logger";
const log = logger.child({module: "rsa-keys"});
const log = logger.child({ module: "rsa-keys" });
const generateKeyPairAsync = promisify(generateKeyPair); const generateKeyPairAsync = promisify(generateKeyPair);
@@ -19,33 +17,33 @@ const generateKeyPairAsync = promisify(generateKeyPair);
* @param {string} [dir] path to directory * @param {string} [dir] path to directory
* @returns {Promise<{privateKeyPath:string, publicKeyPath:string}>} * @returns {Promise<{privateKeyPath:string, publicKeyPath:string}>}
*/ */
export async function generateRSAKeys(dir = path.join(env.PRIVATE_PATH!, '/keys')) { export async function generateRSAKeys(
await fs.mkdir(dir, {recursive: true}); dir = path.join(env.PRIVATE_PATH!, "/keys"),
) {
await fs.mkdir(dir, { recursive: true });
const privateKeyPath = path.join(dir, 'server_private.pem'); const privateKeyPath = path.join(dir, "server_private.pem");
const publicKeyPath = path.join(dir, 'server_public.pem'); const publicKeyPath = path.join(dir, "server_public.pem");
try { try {
await fs.access(privateKeyPath); await fs.access(privateKeyPath);
await fs.access(publicKeyPath); await fs.access(publicKeyPath);
log.info('RSA keys already exist. Skipping generation.'); log.info("RSA keys already exist. Skipping generation.");
return {privateKeyPath, publicKeyPath}; return { privateKeyPath, publicKeyPath };
} catch { } catch {}
}
const {publicKey, privateKey} = await generateKeyPairAsync('rsa', { const { publicKey, privateKey } = await generateKeyPairAsync("rsa", {
modulusLength: 2048, modulusLength: 2048,
publicKeyEncoding: {type: 'pkcs1', format: 'pem'}, publicKeyEncoding: { type: "pkcs1", format: "pem" },
privateKeyEncoding: {type: 'pkcs1', format: 'pem'}, privateKeyEncoding: { type: "pkcs1", format: "pem" },
}); });
await fs.writeFile(privateKeyPath, privateKey, {mode: 0o600}); await fs.writeFile(privateKeyPath, privateKey, { mode: 0o600 });
await fs.writeFile(publicKeyPath, publicKey, {mode: 0o644}); await fs.writeFile(publicKeyPath, publicKey, { mode: 0o644 });
return {privateKeyPath, publicKeyPath}; return { privateKeyPath, publicKeyPath };
} }
/** /**
* Generate a 256-bit AES master key for AES-256-GCM. * Generate a 256-bit AES master key for AES-256-GCM.
* - Skips generation if the file already exists. * - Skips generation if the file already exists.
@@ -53,23 +51,21 @@ export async function generateRSAKeys(dir = path.join(env.PRIVATE_PATH!, '/keys'
* @param {string} [filePath] Path to store the key * @param {string} [filePath] Path to store the key
* @returns {Promise<Buffer>} The master key * @returns {Promise<Buffer>} The master key
*/ */
export async function getOrCreateMasterKey(filePath = path.join(env.PRIVATE_PATH!, '/keys', 'master_key.bin')) { export async function getOrCreateMasterKey(
filePath = path.join(env.PRIVATE_PATH!, "/keys", "master_key.bin"),
) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.mkdir(path.dirname(filePath), {recursive: true}); try {
const existing = await fs.readFile(filePath);
try {
const existing = await fs.readFile(filePath);
log.info('Master key already exists. Skipping generation.');
return existing;
} catch {
// File does not exist, generate
}
const key = randomBytes(32); // 256-bit key
await fs.writeFile(filePath, key, {mode: 0o600});
log.info("Master key already exists. Skipping generation."); log.info("Master key already exists. Skipping generation.");
return existing;
} catch {}
return key; const key = randomBytes(32);
await fs.writeFile(filePath, key, { mode: 0o600 });
log.info("Master key already exists. Skipping generation.");
return key;
} }
+13 -13
View File
@@ -1,14 +1,14 @@
export const slugify = (text: string) => { export const slugify = (text: string) => {
return text return text
.toString() .toString()
.normalize('NFKD') // Normalize accents .normalize("NFKD")
.replace(/[\u0300-\u036f]/g, '') // Remove diacritics .replace(/[\u0300-\u036f]/g, "")
.toLowerCase() .toLowerCase()
.trim() .trim()
.replace(/\_/g, '-') // _ → dash .replace(/\_/g, "-")
.replace(/\s+/g, '-') // spaces → dash .replace(/\s+/g, "-")
.replace(/[^\w\-]+/g, '') // Remove non-word chars .replace(/[^\w\-]+/g, "")
.replace(/\-\-+/g, '-') // multiple dashes → one .replace(/\-\-+/g, "-")
.replace(/^-+/, '') // Remove leading dash .replace(/^-+/, "")
.replace(/-+$/, ''); // Remove trailing dash .replace(/-+$/, "");
}; };
+41 -43
View File
@@ -1,69 +1,67 @@
export function truncateWords(text: string, wordLimit: number = 10): string { export function truncateWords(text: string, wordLimit: number = 10): string {
if (!text) return ""; if (!text) return "";
const words = text.trim().split(/\s+/); const words = text.trim().split(/\s+/);
if (words.length <= wordLimit) return text; if (words.length <= wordLimit) return text;
return words.slice(0, wordLimit).join(" ") + "…"; return words.slice(0, wordLimit).join(" ") + "…";
} }
export function capitalizeFirstLetter(text: string): string { export function capitalizeFirstLetter(text: string): string {
return text ? text.charAt(0).toUpperCase() + text.slice(1) : ""; return text ? text.charAt(0).toUpperCase() + text.slice(1) : "";
} }
export function isUUID(str: string) { export function isUUID(str: string) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(str); return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
str,
);
} }
export function isImportedFilename(name: string): boolean { export function isImportedFilename(name: string): boolean {
return name.startsWith("imported_"); return name.startsWith("imported_");
} }
export function formatBytes(bytes: number | null, decimals = 2): string { export function formatBytes(bytes: number | null, decimals = 2): string {
if (!bytes) return "N/A"; if (!bytes) return "N/A";
if (bytes === 0) return "0 Bytes"; if (bytes === 0) return "0 Bytes";
const k = 1024; const k = 1024;
const dm = decimals < 0 ? 0 : decimals; const dm = decimals < 0 ? 0 : decimals;
const sizes = ["Bytes", "KB", "MB", "GB", "TB"]; const sizes = ["Bytes", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k)); const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
} }
export function formatDuration(ms: number): string { export function formatDuration(ms: number): string {
if (ms == null || Number.isNaN(ms)) return "0 ms"; if (ms == null || Number.isNaN(ms)) return "0 ms";
const totalMs = Math.max(0, Math.floor(ms)); const totalMs = Math.max(0, Math.floor(ms));
if (totalMs < 1000) { if (totalMs < 1000) {
return `${totalMs} ms`; return `${totalMs} ms`;
} }
const totalSeconds = Math.floor(totalMs / 1000); const totalSeconds = Math.floor(totalMs / 1000);
const seconds = totalSeconds % 60; const seconds = totalSeconds % 60;
if (totalSeconds < 60) { if (totalSeconds < 60) {
return `${totalSeconds} s`; return `${totalSeconds} s`;
} }
const totalMinutes = Math.floor(totalSeconds / 60); const totalMinutes = Math.floor(totalSeconds / 60);
const minutes = totalMinutes % 60; const minutes = totalMinutes % 60;
if (totalMinutes < 60) { if (totalMinutes < 60) {
return seconds > 0 return seconds > 0
? `${totalMinutes} min ${seconds} s` ? `${totalMinutes} min ${seconds} s`
: `${totalMinutes} min`; : `${totalMinutes} min`;
} }
const totalHours = Math.floor(totalMinutes / 60); const totalHours = Math.floor(totalMinutes / 60);
const hours = totalHours % 24; const hours = totalHours % 24;
if (totalHours < 24) { if (totalHours < 24) {
return minutes > 0 return minutes > 0 ? `${totalHours} h ${minutes} min` : `${totalHours} h`;
? `${totalHours} h ${minutes} min` }
: `${totalHours} h`;
}
const days = Math.floor(totalHours / 24); const days = Math.floor(totalHours / 24);
return hours > 0 return hours > 0 ? `${days} d ${hours} h` : `${days} d`;
? `${days} d ${hours} h` }
: `${days} d`;
}
-5
View File
@@ -1,5 +0,0 @@
const uuidv4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function isUuidv4(value: string): value is string {
return uuidv4Regex.test(value);
}