mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
fix
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
+118
-103
@@ -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 haven’t 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 haven’t added any databases to this project yet.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</PageContent>
|
||||||
|
</Page>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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[];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 "Connect a notifier" 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 "Connect a
|
|
||||||
notifier" step first.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button type="button" variant="outline" onClick={onBack}>
|
|
||||||
<ArrowLeft className="size-4 mr-1" />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label className="text-sm font-medium">Notification Policies</Label>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
disabled={policies.length >= notifiers.length}
|
|
||||||
onClick={addPolicy}
|
|
||||||
>
|
|
||||||
<Plus className="size-4 mr-1" />
|
|
||||||
Add Policy
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{policies.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center p-6 border border-dashed rounded-xl bg-muted/20 text-center gap-1">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Click "Add Policy" to start receiving notifications.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{policies.map((policy, index) => {
|
|
||||||
const available = notifiers.filter(
|
|
||||||
(n) =>
|
|
||||||
n.id === policy.channelId || !selectedChannelIds.includes(n.id),
|
|
||||||
);
|
|
||||||
const selected = notifiers.find((n) => n.id === policy.channelId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={policy.channelId || index}
|
|
||||||
className="p-4 flex flex-col gap-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-end gap-2">
|
|
||||||
<div className="flex-1 flex flex-col gap-1.5">
|
|
||||||
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
|
|
||||||
Channel
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={policy.channelId}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
updatePolicy(index, { channelId: v })
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-9">
|
|
||||||
<SelectValue placeholder="Select channel">
|
|
||||||
{selected && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{getChannelIcon(selected.provider)}
|
|
||||||
<span className="truncate font-medium text-sm">
|
|
||||||
{selected.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{available.map((n) => (
|
|
||||||
<SelectItem key={n.id} value={n.id}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{getChannelIcon(n.provider)}
|
|
||||||
<span>{n.name}</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5 shrink-0">
|
|
||||||
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
|
|
||||||
Status
|
|
||||||
</Label>
|
|
||||||
<div className="flex items-center h-9 px-3 rounded-md border border-input bg-background gap-2">
|
|
||||||
<Label className="text-xs cursor-pointer">
|
|
||||||
{policy.enabled ? "Active" : "Off"}
|
|
||||||
</Label>
|
|
||||||
<Switch
|
|
||||||
checked={policy.enabled}
|
|
||||||
onCheckedChange={(v) =>
|
|
||||||
updatePolicy(index, { enabled: v })
|
|
||||||
}
|
|
||||||
className="scale-75 origin-right"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-9 w-9 text-muted-foreground hover:text-destructive hover:border-destructive/50 shrink-0"
|
|
||||||
onClick={() => removePolicy(index)}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
|
||||||
Trigger Events
|
|
||||||
</Label>
|
|
||||||
<MultiSelect
|
|
||||||
options={EVENT_KIND_OPTIONS}
|
|
||||||
onValueChange={(v) =>
|
|
||||||
updatePolicy(index, { eventKinds: v as EventKind[] })
|
|
||||||
}
|
|
||||||
defaultValue={policy.eventKinds}
|
|
||||||
placeholder="Select events…"
|
|
||||||
variant="inverted"
|
|
||||||
animation={0}
|
|
||||||
className="bg-background/50 w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-2">
|
|
||||||
<Button type="button" variant="outline" onClick={onBack}>
|
|
||||||
<ArrowLeft className="size-4 mr-1" />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
disabled={
|
|
||||||
isPending ||
|
|
||||||
policies.some((p) => !p.channelId || p.eventKinds.length === 0)
|
|
||||||
}
|
|
||||||
onClick={() => onSave(policies)}
|
|
||||||
className="ml-auto"
|
|
||||||
>
|
|
||||||
{isPending ? "Saving…" : "Save"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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 "Connect a storage" 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 "Connect a storage"
|
|
||||||
step first.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button type="button" variant="outline" onClick={onBack}>
|
|
||||||
<ArrowLeft className="size-4 mr-1" />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label className="text-sm font-medium">Storage Policies</Label>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
disabled={policies.length >= storages.length}
|
|
||||||
onClick={addPolicy}
|
|
||||||
>
|
|
||||||
<Plus className="size-4 mr-1" />
|
|
||||||
Add Policy
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{policies.length === 0 ? (
|
|
||||||
<div className="flex flex-col items-center justify-center p-6 border border-dashed rounded-xl bg-muted/20 text-center gap-1">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
Click "Add Policy" to assign a storage to this database.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-3">
|
|
||||||
{policies.map((policy, index) => {
|
|
||||||
const available = storages.filter(
|
|
||||||
(s) =>
|
|
||||||
s.id === policy.channelId || !selectedChannelIds.includes(s.id),
|
|
||||||
);
|
|
||||||
const selected = storages.find((s) => s.id === policy.channelId);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={policy.channelId || index}
|
|
||||||
className="p-4 flex items-end gap-2"
|
|
||||||
>
|
|
||||||
<div className="flex-1 flex flex-col gap-1.5">
|
|
||||||
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
|
|
||||||
Storage Channel
|
|
||||||
</Label>
|
|
||||||
<Select
|
|
||||||
value={policy.channelId}
|
|
||||||
onValueChange={(v) => updatePolicy(index, { channelId: v })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="h-9">
|
|
||||||
<SelectValue placeholder="Select storage">
|
|
||||||
{selected && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{getChannelIcon(selected.provider)}
|
|
||||||
<span className="truncate font-medium text-sm">
|
|
||||||
{selected.name}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{available.map((s) => (
|
|
||||||
<SelectItem key={s.id} value={s.id}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{getChannelIcon(s.provider)}
|
|
||||||
<span>{s.name}</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-col gap-1.5 shrink-0">
|
|
||||||
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
|
|
||||||
Status
|
|
||||||
</Label>
|
|
||||||
<div className="flex items-center h-9 px-3 rounded-md border border-input bg-background gap-2">
|
|
||||||
<Label className="text-xs cursor-pointer">
|
|
||||||
{policy.enabled ? "Active" : "Off"}
|
|
||||||
</Label>
|
|
||||||
<Switch
|
|
||||||
checked={policy.enabled}
|
|
||||||
onCheckedChange={(v) =>
|
|
||||||
updatePolicy(index, { enabled: v })
|
|
||||||
}
|
|
||||||
className="scale-75 origin-right"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="icon"
|
|
||||||
className="h-9 w-9 text-muted-foreground hover:text-destructive hover:border-destructive/50 shrink-0"
|
|
||||||
onClick={() => removePolicy(index)}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="flex gap-2 pt-2">
|
|
||||||
<Button type="button" variant="outline" onClick={onBack}>
|
|
||||||
<ArrowLeft className="size-4 mr-1" />
|
|
||||||
Back
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
disabled={isPending || policies.some((p) => !p.channelId)}
|
|
||||||
onClick={() => onSave(policies)}
|
|
||||||
className="ml-auto"
|
|
||||||
>
|
|
||||||
{isPending ? "Saving…" : "Save"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
@@ -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've run the command →
|
I'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()}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -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()),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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} />;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
@@ -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()
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -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" },
|
|
||||||
];
|
|
||||||
@@ -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(" ");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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`;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user