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
|
||||
#TUSD_BEHIND_PROXY=true
|
||||
|
||||
#
|
||||
#SKIP_ONBOARDING=false
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React from "react";
|
||||
import { redirect } from "next/navigation";
|
||||
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 { env } from "@/env.mjs";
|
||||
import { Heart } from "lucide-react";
|
||||
|
||||
export default async function Layout({
|
||||
@@ -10,7 +11,7 @@ export default async function Layout({
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
if (!(await isOnboardingDone())) {
|
||||
if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) {
|
||||
redirect("/welcome");
|
||||
}
|
||||
|
||||
|
||||
@@ -5,17 +5,24 @@ import {desc, isNull} from "drizzle-orm";
|
||||
import {AdminUserList} from "@/features/users/admin-user-list";
|
||||
import {AdminUserAddModal} from "@/features/users/admin-user-add-modal";
|
||||
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<{}>) {
|
||||
|
||||
const users = await db.query.user.findMany({
|
||||
const [settings, users] = await Promise.all([
|
||||
getSettings(),
|
||||
db.query.user.findMany({
|
||||
where: (fields) => isNull(fields.deletedAt),
|
||||
with: {
|
||||
accounts: true
|
||||
},
|
||||
with: { accounts: true },
|
||||
orderBy: (fields) => desc(fields.createdAt),
|
||||
}),
|
||||
]);
|
||||
|
||||
const avatarUrls = Object.fromEntries(
|
||||
users.map((u) => [u.id, resolveAvatarUrl(u, settings)])
|
||||
);
|
||||
|
||||
});
|
||||
const organizations = await db.query.organization.findMany({
|
||||
with: {
|
||||
members: true,
|
||||
@@ -38,7 +45,7 @@ export default async function RoutePage(props: PageParams<{}>) {
|
||||
</div>
|
||||
</PageHeader>
|
||||
<PageContent className="flex flex-col gap-5">
|
||||
<AdminUserList users={users} isPasswordAuthEnabled={isPasswordAuthEnabled}/>
|
||||
<AdminUserList users={users} isPasswordAuthEnabled={isPasswordAuthEnabled} avatarUrls={avatarUrls}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
|
||||
+42
-27
@@ -11,14 +11,16 @@ import {DatabaseContent} from "@/features/database/database-content";
|
||||
import { getHealthLast12hLogs } from "@/db/services/healthcheck";
|
||||
import { LogsModalProvider } from "@/features/logs/logs-modal-context";
|
||||
|
||||
export default async function RoutePage(props: PageParams<{
|
||||
export default async function RoutePage(
|
||||
props: PageParams<{
|
||||
projectId: string;
|
||||
databaseId: string
|
||||
}>) {
|
||||
databaseId: string;
|
||||
}>,
|
||||
) {
|
||||
const { projectId, databaseId } = await props.params;
|
||||
|
||||
const organization = await getOrganization({});
|
||||
const activeMember = await getActiveMember()
|
||||
const activeMember = await getActiveMember();
|
||||
|
||||
if (!organization || !activeMember) {
|
||||
notFound();
|
||||
@@ -26,17 +28,21 @@ export default async function RoutePage(props: PageParams<{
|
||||
|
||||
const databasesProject = await getOrganizationProjectDatabases({
|
||||
organizationSlug: organization.slug,
|
||||
projectId: projectId
|
||||
})
|
||||
projectId: projectId,
|
||||
});
|
||||
|
||||
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) {
|
||||
@@ -49,10 +55,10 @@ export default async function RoutePage(props: PageParams<{
|
||||
restorations: true,
|
||||
storages: {
|
||||
with: {
|
||||
storageChannel: true
|
||||
}
|
||||
storageChannel: true,
|
||||
},
|
||||
logs: true
|
||||
},
|
||||
logs: true,
|
||||
},
|
||||
orderBy: (b, { desc }) => [desc(b.createdAt)],
|
||||
});
|
||||
@@ -60,41 +66,50 @@ export default async function RoutePage(props: PageParams<{
|
||||
const restorations = await db.query.restoration.findMany({
|
||||
where: eq(drizzleDb.schemas.restoration.databaseId, dbItem.id),
|
||||
with: {
|
||||
logs: true
|
||||
logs: true,
|
||||
},
|
||||
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 totalBackups = await db.select({count: drizzleDb.schemas.backup.id})
|
||||
const totalBackups = await db
|
||||
.select({ count: drizzleDb.schemas.backup.id })
|
||||
.from(drizzleDb.schemas.backup)
|
||||
.where(eq(drizzleDb.schemas.backup.databaseId, dbItem.id))
|
||||
.then(rows => rows.length);
|
||||
.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
|
||||
.select({ count: drizzleDb.schemas.backup.id })
|
||||
.from(drizzleDb.schemas.backup)
|
||||
.where(and(
|
||||
.where(
|
||||
and(
|
||||
eq(drizzleDb.schemas.backup.databaseId, dbItem.id),
|
||||
eq(drizzleDb.schemas.backup.status, "success")
|
||||
))
|
||||
.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);
|
||||
const [settings] = await db
|
||||
.select()
|
||||
.from(drizzleDb.schemas.setting)
|
||||
.where(eq(drizzleDb.schemas.setting.name, "system"))
|
||||
.limit(1);
|
||||
if (!settings) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const databaseHealthLogs = dbItem ? await getHealthLast12hLogs({id: dbItem.id}) : []
|
||||
const databaseHealthLogs = dbItem
|
||||
? await getHealthLast12hLogs({ id: dbItem.id })
|
||||
: [];
|
||||
|
||||
const successRate =
|
||||
totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
||||
|
||||
const successRate = totalBackups > 0 ? (successfulBackups / totalBackups) * 100 : null;
|
||||
|
||||
const isMember = activeMember?.role === "member";
|
||||
//const isMember = activeMember?.role === "member";
|
||||
|
||||
return (
|
||||
<Page>
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { PageParams } from "@/types/next";
|
||||
import { Page, PageContent, PageTitle } from "@/features/layout/page";
|
||||
import {
|
||||
ButtonDeleteProject
|
||||
} from "@/features/projects/project-delete-button";
|
||||
import { ButtonDeleteProject } from "@/features/projects/project-delete-button";
|
||||
import { CardsWithPagination } from "@/components/common/cards-with-pagination";
|
||||
import { ProjectDatabaseCard } from "@/features/projects/project-database-card";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
@@ -10,26 +8,24 @@ import {db} from "@/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { getActiveMember, getOrganization } from "@/lib/auth/auth";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {capitalizeFirstLetter} from "@/utils/text";
|
||||
import { capitalizeFirstLetter, isUUID } from "@/utils/text";
|
||||
import { ProjectDialog } from "@/features/projects/project-dialog";
|
||||
import { ProjectWith } from "@/db/schema/06_project";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
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<{
|
||||
projectId: string
|
||||
}>) {
|
||||
const {
|
||||
projectId
|
||||
} = await props.params;
|
||||
|
||||
if (!isUuidv4(projectId)) {
|
||||
notFound()
|
||||
if (!isUUID(projectId)) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
const organization = await getOrganization({});
|
||||
const activeMember = await getActiveMember()
|
||||
const activeMember = await getActiveMember();
|
||||
|
||||
if (!organization) {
|
||||
notFound();
|
||||
@@ -41,11 +37,12 @@ export default async function RoutePage(props: PageParams<{
|
||||
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))),
|
||||
where: (proj, { and, eq, not }) =>
|
||||
and(
|
||||
eq(proj.id, projectId),
|
||||
eq(proj.organizationId, org.id),
|
||||
not(eq(proj.isArchived, true)),
|
||||
),
|
||||
with: {
|
||||
databases: true,
|
||||
},
|
||||
@@ -55,7 +52,10 @@ export default async function RoutePage(props: PageParams<{
|
||||
redirect("/dashboard/projects");
|
||||
}
|
||||
|
||||
const availableDatabases = await getOrganizationAvailableDatabases(organization.id, proj.id)
|
||||
const availableDatabases = await getOrganizationAvailableDatabases(
|
||||
organization.id,
|
||||
proj.id,
|
||||
);
|
||||
const isMember = activeMember?.role === "member";
|
||||
|
||||
return (
|
||||
@@ -76,7 +76,10 @@ export default async function RoutePage(props: PageParams<{
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<ButtonDeleteProject projectId={projectId} text={"Delete Project"}/>
|
||||
<ButtonDeleteProject
|
||||
projectId={projectId}
|
||||
text={"Delete Project"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -85,8 +88,10 @@ export default async function RoutePage(props: PageParams<{
|
||||
<PageContent className="flex flex-col w-full h-full">
|
||||
{proj.databases.length > 0 ? (
|
||||
<CardsWithPagination
|
||||
data={[...proj.databases].sort((a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
data={[...proj.databases].sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() -
|
||||
new Date(a.createdAt).getTime(),
|
||||
)}
|
||||
organizationSlug={organization.slug}
|
||||
// @ts-ignore
|
||||
@@ -99,7 +104,9 @@ export default async function RoutePage(props: PageParams<{
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground py-20">
|
||||
<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>
|
||||
<p className="text-sm mt-2">
|
||||
You haven’t added any databases to this project yet.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</PageContent>
|
||||
|
||||
@@ -5,12 +5,13 @@ import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
|
||||
import { AppSidebar } from "@/features/layout/app-sidebar";
|
||||
import { Header } from "@/features/layout/header";
|
||||
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 { UpdateNotification } from "@/features/updates/update-notification";
|
||||
|
||||
export default async function Layout({ children }: { children: ReactNode }) {
|
||||
if (!(await isOnboardingDone())) {
|
||||
if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) {
|
||||
redirect("/welcome");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { getCurrentOrganizationSlug } from "@/features/organizations/organization-cookie";
|
||||
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() {
|
||||
if (!(await isOnboardingDone())) {
|
||||
if (env.SKIP_ONBOARDING !== "true" && !(await isOnboardingDone())) {
|
||||
redirect("/welcome");
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ export default async function WelcomePage() {
|
||||
|
||||
return (
|
||||
<OnboardingClient
|
||||
key={result.stepId}
|
||||
initialStepId={result.stepId}
|
||||
initialFlowData={result.flowData}
|
||||
/>
|
||||
|
||||
@@ -3,21 +3,28 @@ import {and, eq} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import { db } from "@/db";
|
||||
import { getDatabaseOrThrow, withAgentCheck } from "../../helpers";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import { eventEmitter } from "@/lib/event";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { isUUID } from "@/utils/text";
|
||||
|
||||
const log = logger.child({ module: "api/agent/backup/upload/init" });
|
||||
|
||||
export type Body = {
|
||||
generatedId: string
|
||||
storageChannelId: string
|
||||
backupId: string
|
||||
}
|
||||
export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
params: Promise<{ agentId: string }>,
|
||||
agent: any
|
||||
}) => {
|
||||
generatedId: string;
|
||||
storageChannelId: string;
|
||||
backupId: string;
|
||||
};
|
||||
export const POST = withAgentCheck(
|
||||
async (
|
||||
request: Request,
|
||||
{
|
||||
params,
|
||||
agent,
|
||||
}: {
|
||||
params: Promise<{ agentId: string }>;
|
||||
agent: any;
|
||||
},
|
||||
) => {
|
||||
try {
|
||||
const body: Body = await request.json();
|
||||
|
||||
@@ -27,10 +34,10 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
const storageChannelId = body.storageChannelId;
|
||||
const backupId = body.backupId;
|
||||
|
||||
if (!generatedId || !isUuidv4(generatedId)) {
|
||||
if (!generatedId || !isUUID(generatedId)) {
|
||||
return NextResponse.json(
|
||||
{ error: "generatedId is not a valid UUID" },
|
||||
{status: 400}
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,7 +53,7 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
if (!backup) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unable to find the corresponding backup" },
|
||||
{status: 404}
|
||||
{ status: 404 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,21 +66,21 @@ export const POST = withAgentCheck(async (request: Request, {params, agent}: {
|
||||
})
|
||||
.returning();
|
||||
|
||||
eventEmitter.emit('modification', {update: true});
|
||||
eventEmitter.emit("modification", { update: true });
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: "Backup storage successfully created",
|
||||
backupStorage: backupStorage
|
||||
backupStorage: backupStorage,
|
||||
},
|
||||
{status: 200}
|
||||
{ status: 200 },
|
||||
);
|
||||
} catch (error) {
|
||||
log.error({ error: error }, "Error in POST for INIT backup");
|
||||
return NextResponse.json(
|
||||
{ error: "Internal server error" },
|
||||
{status: 500}
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import * as drizzleDb from "@/db";
|
||||
import { db as dbClient, db } from "@/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
@@ -7,67 +6,81 @@ import {sendNotificationsBackupRestore} from "@/features/notifications/notificat
|
||||
import { logger } from "@/lib/logger";
|
||||
import { withUpdatedAt } from "@/db/utils";
|
||||
import { JobLogEntry } from "@/features/logs/types";
|
||||
import { isUUID } from "@/utils/text";
|
||||
|
||||
const log = logger.child({ module: "api/agent/restore" });
|
||||
|
||||
export type BodyResultRestore = {
|
||||
generatedId: string
|
||||
status: string
|
||||
logs: JobLogEntry[]
|
||||
durationMs: number
|
||||
}
|
||||
type RestorationStatus = 'waiting' | 'ongoing' | 'failed' | 'success';
|
||||
|
||||
generatedId: string;
|
||||
status: string;
|
||||
logs: JobLogEntry[];
|
||||
durationMs: number;
|
||||
};
|
||||
type RestorationStatus = "waiting" | "ongoing" | "failed" | "success";
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{params}: { params: Promise<{ agentId: string }> }
|
||||
{ params }: { params: Promise<{ agentId: string }> },
|
||||
) {
|
||||
|
||||
try {
|
||||
|
||||
const agentId = (await params).agentId
|
||||
const agentId = (await params).agentId;
|
||||
const body: BodyResultRestore = await request.json();
|
||||
|
||||
|
||||
if (!isUuidv4(body.generatedId)) {
|
||||
if (!isUUID(body.generatedId)) {
|
||||
return NextResponse.json(
|
||||
{ error: "generatedId 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)),
|
||||
})
|
||||
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})
|
||||
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
|
||||
}
|
||||
})
|
||||
alertPolicies: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!database) {
|
||||
return NextResponse.json({error: "Database associated with generatedId provided not found"}, {status: 404})
|
||||
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),)
|
||||
})
|
||||
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})
|
||||
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();
|
||||
|
||||
.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,
|
||||
@@ -87,24 +100,25 @@ export async function POST(
|
||||
}));
|
||||
|
||||
if (logsToInsert.length > 0) {
|
||||
await dbClient
|
||||
.insert(drizzleDb.schemas.jobLog)
|
||||
.values(logsToInsert);
|
||||
await dbClient.insert(drizzleDb.schemas.jobLog).values(logsToInsert);
|
||||
}
|
||||
|
||||
await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore");
|
||||
await sendNotificationsBackupRestore(
|
||||
database,
|
||||
body.status == "failed" ? "error_restore" : "success_restore",
|
||||
);
|
||||
|
||||
const response = {
|
||||
status: true,
|
||||
message: "Restoration successfully updated"
|
||||
}
|
||||
message: "Restoration successfully updated",
|
||||
};
|
||||
|
||||
return Response.json(response, {status: 200})
|
||||
return Response.json(response, { status: 200 });
|
||||
} catch (error) {
|
||||
log.error({error: error}, "Error in POST handler")
|
||||
log.error({ error: error }, "Error in POST handler");
|
||||
return NextResponse.json(
|
||||
{error: 'Internal server error'},
|
||||
{status: 500}
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { Body } from "./route";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import { Agent } from "@/db/schema/08_agent";
|
||||
import { DatabaseWith } from "@/db/schema/07_database";
|
||||
import * as drizzleDb from "@/db";
|
||||
@@ -12,13 +11,26 @@ import type {StorageInput} from "@/features/storages/storages.types";
|
||||
import { dispatchStorage } from "@/features/storages/storages.dispatch";
|
||||
import { Setting } from "@/db/schema/01_setting";
|
||||
import { logger } from "@/lib/logger";
|
||||
import { isUUID } from "@/utils/text";
|
||||
|
||||
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(
|
||||
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 = (
|
||||
database: DatabaseWith,
|
||||
backupAction: boolean,
|
||||
restoreAction: boolean,
|
||||
UrlBackup: string | null,
|
||||
storages: PingDatabaseStorageChannels[],
|
||||
urlMeta: string | null,
|
||||
) => ({
|
||||
generatedId: database.agentDatabaseId,
|
||||
dbms: database.dbms,
|
||||
storages: storages,
|
||||
@@ -31,35 +43,37 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
restore: {
|
||||
action: restoreAction,
|
||||
file: UrlBackup,
|
||||
metaFile: urlMeta
|
||||
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
|
||||
}
|
||||
project: true,
|
||||
},
|
||||
});
|
||||
|
||||
let backupAction: boolean = false
|
||||
let restoreAction: boolean = false
|
||||
let backupAction: boolean = false;
|
||||
let restoreAction: boolean = false;
|
||||
let urlBackup: string | null = null;
|
||||
let urlMeta: string | null = null
|
||||
let urlMeta: string | null = null;
|
||||
|
||||
if (!existingDatabase) {
|
||||
if (!isUuidv4(db.generatedId)) {
|
||||
if (!isUUID(db.generatedId)) {
|
||||
return NextResponse.json(
|
||||
{ error: "generatedId is not a valid uuid" },
|
||||
{status: 500}
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
|
||||
if (!dbmsEnumSchema.safeParse(db.dbms).success) {
|
||||
log.error({name: "handleDatabases"},`Database type not available: ${db.dbms}`);
|
||||
log.error(
|
||||
{ name: "handleDatabases" },
|
||||
`Database type not available: ${db.dbms}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -71,68 +85,76 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
dbms: db.dbms as EDbmsSchema,
|
||||
agentDatabaseId: db.generatedId,
|
||||
lastContact: db.pingStatus ? lastContact : null,
|
||||
healthErrorCount: null
|
||||
healthErrorCount: null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
if (databaseCreated) {
|
||||
|
||||
|
||||
await dbClient
|
||||
.insert(drizzleDb.schemas.healthcheckLog)
|
||||
.values({
|
||||
await dbClient.insert(drizzleDb.schemas.healthcheckLog).values({
|
||||
kind: "database",
|
||||
status: db.pingStatus ? "success" : "failed",
|
||||
objectId: databaseCreated.id,
|
||||
date: lastContact
|
||||
})
|
||||
date: lastContact,
|
||||
});
|
||||
|
||||
const storages = await getDatabaseStorageChannels(databaseCreated.id)
|
||||
const storages = await getDatabaseStorageChannels(databaseCreated.id);
|
||||
|
||||
databasesResponse.push(formatDatabase(databaseCreated, backupAction, restoreAction, urlBackup, storages, null));
|
||||
databasesResponse.push(
|
||||
formatDatabase(
|
||||
databaseCreated,
|
||||
backupAction,
|
||||
restoreAction,
|
||||
urlBackup,
|
||||
storages,
|
||||
null,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
|
||||
const [databaseUpdated] = await dbClient
|
||||
.update(drizzleDb.schemas.database)
|
||||
.set(withUpdatedAt({
|
||||
.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,
|
||||
}))
|
||||
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({
|
||||
await dbClient.insert(drizzleDb.schemas.healthcheckLog).values({
|
||||
kind: "database",
|
||||
status: db.pingStatus ? "success" : "failed",
|
||||
objectId: databaseUpdated.id,
|
||||
date: lastContact
|
||||
})
|
||||
|
||||
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"])
|
||||
)
|
||||
})
|
||||
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")),
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.restoration.databaseId, databaseUpdated.id),
|
||||
eq(drizzleDb.schemas.restoration.status, "waiting"),
|
||||
),
|
||||
with: {
|
||||
backupStorage: true
|
||||
}
|
||||
})
|
||||
backupStorage: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (activeBackup && activeBackup.status == "waiting") {
|
||||
backupAction = true
|
||||
backupAction = true;
|
||||
|
||||
await dbClient
|
||||
.update(drizzleDb.schemas.backup)
|
||||
@@ -141,10 +163,14 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
}
|
||||
|
||||
if (restoration) {
|
||||
restoreAction = true
|
||||
restoreAction = true;
|
||||
|
||||
if (!restoration.backupStorage || restoration.backupStorage.status != "success" || !restoration.backupStorage.path) {
|
||||
restoreAction = false
|
||||
if (
|
||||
!restoration.backupStorage ||
|
||||
restoration.backupStorage.status != "success" ||
|
||||
!restoration.backupStorage.path
|
||||
) {
|
||||
restoreAction = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -156,8 +182,8 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
},
|
||||
metadata: {
|
||||
storageId: restoration.backupStorage.storageChannelId,
|
||||
fileKind: "backups"
|
||||
}
|
||||
fileKind: "backups",
|
||||
},
|
||||
};
|
||||
|
||||
const inputMeta: StorageInput = {
|
||||
@@ -168,18 +194,25 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
},
|
||||
metadata: {
|
||||
storageId: restoration.backupStorage.storageChannelId,
|
||||
fileKind: "backups"
|
||||
}
|
||||
fileKind: "backups",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
try {
|
||||
const result = await dispatchStorage(input, undefined, restoration.backupStorage.storageChannelId);
|
||||
const resultMeta = await dispatchStorage(inputMeta, undefined, restoration.backupStorage.storageChannelId);
|
||||
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
|
||||
urlMeta = resultMeta.url ?? null;
|
||||
} else {
|
||||
await dbClient
|
||||
.update(drizzleDb.schemas.restoration)
|
||||
@@ -187,11 +220,17 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
.where(eq(drizzleDb.schemas.restoration.id, restoration.id));
|
||||
|
||||
const errorMessage = "Failed to get backup URL";
|
||||
log.error({error: errorMessage, name: "handleDatabases"}, "Restoration failed");
|
||||
log.error(
|
||||
{ error: errorMessage, name: "handleDatabases" },
|
||||
"Restoration failed",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
} catch (err) {
|
||||
log.error({error: err, name: "handleDatabases"}, "Restoration crashed unexpectedly");
|
||||
log.error(
|
||||
{ error: err, name: "handleDatabases" },
|
||||
"Restoration crashed unexpectedly",
|
||||
);
|
||||
await dbClient
|
||||
.update(drizzleDb.schemas.restoration)
|
||||
.set(withUpdatedAt({ status: "failed" }))
|
||||
@@ -204,34 +243,43 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
.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));
|
||||
const storages = await getDatabaseStorageChannels(databaseUpdated.id);
|
||||
databasesResponse.push(
|
||||
formatDatabase(
|
||||
databaseUpdated,
|
||||
backupAction,
|
||||
restoreAction,
|
||||
urlBackup,
|
||||
storages,
|
||||
urlMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return databasesResponse;
|
||||
}
|
||||
|
||||
|
||||
type PingDatabaseStorageChannels = {
|
||||
id: string;
|
||||
config: any
|
||||
provider: string
|
||||
}
|
||||
|
||||
async function getDatabaseStorageChannels(databaseId: string): Promise<PingDatabaseStorageChannels[]> {
|
||||
config: any;
|
||||
provider: string;
|
||||
};
|
||||
|
||||
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
|
||||
}
|
||||
storagePolicies: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!database) {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
|
||||
const settings = await db.query.setting.findFirst({
|
||||
@@ -239,21 +287,26 @@ async function getDatabaseStorageChannels(databaseId: string): Promise<PingDatab
|
||||
with: { storageChannel: true },
|
||||
});
|
||||
|
||||
const defaultStorageChannel: PingDatabaseStorageChannels[] = settings?.storageChannel
|
||||
? [{
|
||||
const defaultStorageChannel: PingDatabaseStorageChannels[] =
|
||||
settings?.storageChannel
|
||||
? [
|
||||
{
|
||||
id: settings.storageChannel.id,
|
||||
provider: settings.storageChannel.provider,
|
||||
config: settings.storageChannel.config,
|
||||
}]
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
|
||||
const enabledDatabaseStorageChannels = await Promise.all(
|
||||
(database.storagePolicies ?? [])
|
||||
.filter(p => p.enabled)
|
||||
.map(async policy => {
|
||||
.filter((p) => p.enabled)
|
||||
.map(async (policy) => {
|
||||
const storageChannel = await db.query.storageChannel.findFirst({
|
||||
where: eq(drizzleDb.schemas.storageChannel.id, policy.storageChannelId),
|
||||
where: eq(
|
||||
drizzleDb.schemas.storageChannel.id,
|
||||
policy.storageChannelId,
|
||||
),
|
||||
});
|
||||
|
||||
if (!storageChannel) return null;
|
||||
@@ -263,13 +316,13 @@ async function getDatabaseStorageChannels(databaseId: string): Promise<PingDatab
|
||||
config: storageChannel.config,
|
||||
provider: storageChannel.provider,
|
||||
} as PingDatabaseStorageChannels;
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
const filteredChannels: PingDatabaseStorageChannels[] = enabledDatabaseStorageChannels.filter(
|
||||
(c): c is PingDatabaseStorageChannels => c !== null
|
||||
const filteredChannels: PingDatabaseStorageChannels[] =
|
||||
enabledDatabaseStorageChannels.filter(
|
||||
(c): c is PingDatabaseStorageChannels => c !== null,
|
||||
);
|
||||
|
||||
return filteredChannels.length > 0 ? filteredChannels : defaultStorageChannel;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,96 +4,104 @@ import * as drizzleDb from "@/db";
|
||||
import { db } from "@/db";
|
||||
import { EDbmsSchema } from "@/db/schema/types";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import {isUuidv4} from "@/utils/verify-uuid";
|
||||
import { withUpdatedAt } from "@/db/utils";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
import { isUUID } from "@/utils/text";
|
||||
|
||||
const log = logger.child({ module: "api/agent/status/route" });
|
||||
|
||||
|
||||
export type databaseAgent = {
|
||||
name: string,
|
||||
dbms: EDbmsSchema,
|
||||
generatedId: string
|
||||
pingStatus: boolean
|
||||
}
|
||||
name: string;
|
||||
dbms: EDbmsSchema;
|
||||
generatedId: string;
|
||||
pingStatus: boolean;
|
||||
};
|
||||
|
||||
export type Body = {
|
||||
version: string,
|
||||
databases: databaseAgent[]
|
||||
}
|
||||
|
||||
version: string;
|
||||
databases: databaseAgent[];
|
||||
};
|
||||
|
||||
export async function POST(
|
||||
request: Request,
|
||||
{params}: { params: Promise<{ agentId: string }> }
|
||||
{ params }: { params: Promise<{ agentId: string }> },
|
||||
) {
|
||||
try {
|
||||
const agentId = (await params).agentId
|
||||
log.debug(`Agent ID: ${agentId}`)
|
||||
const agentId = (await params).agentId;
|
||||
log.debug(`Agent ID: ${agentId}`);
|
||||
const body: Body = await request.json();
|
||||
const lastContact = new Date();
|
||||
let message: string
|
||||
let message: string;
|
||||
|
||||
if (!isUuidv4(agentId)) {
|
||||
message = "agentId is not a valid uuid"
|
||||
log.error({error: message}, "An error occurred")
|
||||
if (!isUUID(agentId)) {
|
||||
message = "agentId is not a valid uuid";
|
||||
log.error({ error: message }, "An error occurred");
|
||||
return NextResponse.json(
|
||||
{ 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)),
|
||||
})
|
||||
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})
|
||||
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);
|
||||
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})
|
||||
return NextResponse.json({ error: "An error occured" }, { status: 404 });
|
||||
}
|
||||
|
||||
const databasesResponse = await handleDatabases(body, agent, lastContact, settings)
|
||||
const databasesResponse = await handleDatabases(
|
||||
body,
|
||||
agent,
|
||||
lastContact,
|
||||
settings,
|
||||
);
|
||||
|
||||
await db
|
||||
.update(drizzleDb.schemas.agent)
|
||||
.set(withUpdatedAt({
|
||||
.set(
|
||||
withUpdatedAt({
|
||||
version: body.version,
|
||||
lastContact: lastContact,
|
||||
healthErrorCount: null
|
||||
}))
|
||||
healthErrorCount: null,
|
||||
}),
|
||||
)
|
||||
.where(eq(drizzleDb.schemas.agent.id, agentId));
|
||||
|
||||
await db
|
||||
.insert(drizzleDb.schemas.healthcheckLog)
|
||||
.values({
|
||||
await db.insert(drizzleDb.schemas.healthcheckLog).values({
|
||||
kind: "agent",
|
||||
status: "success",
|
||||
objectId: agentId,
|
||||
date: lastContact
|
||||
})
|
||||
date: lastContact,
|
||||
});
|
||||
|
||||
const response = {
|
||||
agent: {
|
||||
id: agentId,
|
||||
lastContact: lastContact
|
||||
lastContact: lastContact,
|
||||
},
|
||||
databases: databasesResponse
|
||||
}
|
||||
databases: databasesResponse,
|
||||
};
|
||||
|
||||
return Response.json(response)
|
||||
return Response.json(response);
|
||||
} catch (error) {
|
||||
log.error({error: error}, "Error in POST handler")
|
||||
log.error({ error: error }, "Error in POST handler");
|
||||
return NextResponse.json(
|
||||
{error: 'Internal server error'},
|
||||
{status: 500}
|
||||
{ error: "Internal server error" },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 = [
|
||||
"#4f46e5",
|
||||
@@ -15,6 +16,11 @@ const AVATAR_COLORS = [
|
||||
];
|
||||
|
||||
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 initials = (searchParams.get("initials") ?? "?")
|
||||
.slice(0, 2)
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@ export const PORTABASE_DEFAULT_SETTINGS = {
|
||||
"https://api.iconify.design",
|
||||
"https://code.iconify.design",
|
||||
"https://api.github.com",
|
||||
|
||||
"https://api.dicebear.com",
|
||||
],
|
||||
OBJECT_SRC: ["'none'"],
|
||||
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,
|
||||
"tag": "0065_overjoyed_mantis",
|
||||
"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 {z} from "zod";
|
||||
import {timestamps} from "@/db/schema/00_common";
|
||||
@@ -21,6 +23,8 @@ export const setting = pgTable("settings", {
|
||||
.references(() => storageChannel.id, {onDelete: "set null"}),
|
||||
encryption: boolean("encryption").default(false),
|
||||
onboarding: boolean("onboarding").default(false).notNull(),
|
||||
avatarMode: avatarModeEnum('avatar_mode').default('internal').notNull(),
|
||||
dicebearStyle: varchar('dicebear_style', { length: 64 }).default('thumbs').notNull(),
|
||||
...timestamps
|
||||
});
|
||||
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use server";
|
||||
|
||||
import { and, desc, eq, sql } from "drizzle-orm";
|
||||
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) {
|
||||
|
||||
return await db
|
||||
return (await db
|
||||
.select({
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
@@ -27,17 +28,14 @@ export async function getOrganizationAgents(organizationId: string) {
|
||||
`,
|
||||
})
|
||||
.from(organizationAgent)
|
||||
.innerJoin(
|
||||
agent,
|
||||
eq(organizationAgent.agentId, agent.id)
|
||||
)
|
||||
.innerJoin(agent, eq(organizationAgent.agentId, agent.id))
|
||||
.leftJoin(database, eq(database.agentId, agent.id))
|
||||
.groupBy(agent.id)
|
||||
.orderBy(desc(agent.createdAt))
|
||||
.where(
|
||||
and(
|
||||
eq(organizationAgent.organizationId, organizationId),
|
||||
eq(agent.isArchived, false)
|
||||
)
|
||||
) as unknown as Agent[];
|
||||
eq(agent.isArchived, false),
|
||||
),
|
||||
)) as unknown as Agent[];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use server"
|
||||
"use server";
|
||||
import { eq } from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import { db } from "@/db";
|
||||
|
||||
+96
-12
@@ -1,15 +1,21 @@
|
||||
"use server"
|
||||
"use server";
|
||||
import { inArray } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
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(
|
||||
organizationId: string,
|
||||
projectId?: string
|
||||
projectId?: string,
|
||||
) {
|
||||
|
||||
const availableDatabases = (
|
||||
await db.query.database.findMany({
|
||||
const availableDatabases = (await db.query.database.findMany({
|
||||
where: (db, { eq, or, isNull }) =>
|
||||
projectId
|
||||
? or(isNull(db.projectId), eq(db.projectId, projectId))
|
||||
@@ -17,23 +23,101 @@ export async function getOrganizationAvailableDatabases(
|
||||
with: {
|
||||
agent: {
|
||||
with: {
|
||||
organizations: true
|
||||
}
|
||||
organizations: true,
|
||||
},
|
||||
},
|
||||
project: true,
|
||||
backups: true,
|
||||
restorations: true,
|
||||
},
|
||||
orderBy: (db, { desc }) => [desc(db.createdAt)],
|
||||
})
|
||||
) as DatabaseWith[];
|
||||
})) as DatabaseWith[];
|
||||
|
||||
return availableDatabases.filter(db => {
|
||||
return availableDatabases.filter((db) => {
|
||||
const agent = db.agent as AgentWith;
|
||||
if (agent?.isArchived) return false;
|
||||
return (
|
||||
agent?.organizationId === organizationId ||
|
||||
agent?.organizations?.some(org => org.organizationId === organizationId)
|
||||
agent?.organizations?.some((org) => org.organizationId === organizationId)
|
||||
);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
export async function getDatabasesSettings(
|
||||
databaseIds: string[],
|
||||
): Promise<Record<string, OnboardingDbSettings>> {
|
||||
if (databaseIds.length === 0) return {};
|
||||
|
||||
const [retentionPolicies, dbs, alertPolicies, storagePolicies] =
|
||||
await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(retentionPolicy)
|
||||
.where(inArray(retentionPolicy.databaseId, databaseIds)),
|
||||
db
|
||||
.select({ id: database.id, backupPolicy: database.backupPolicy })
|
||||
.from(database)
|
||||
.where(inArray(database.id, databaseIds)),
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use server";
|
||||
|
||||
import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import { and, eq, gte, isNotNull, lt } from "drizzle-orm";
|
||||
@@ -8,8 +10,8 @@ import {logger} from "@/lib/logger";
|
||||
const log = logger.child({ module: "tasks/healthcheck" });
|
||||
|
||||
export async function getHealthLast12hLogs({ id }: { id: string }) {
|
||||
const now = new Date()
|
||||
const since = new Date(now.getTime() - 12 * 60 * 60 * 1000)
|
||||
const now = new Date();
|
||||
const since = new Date(now.getTime() - 12 * 60 * 60 * 1000);
|
||||
|
||||
return db
|
||||
.select()
|
||||
@@ -17,31 +19,30 @@ export async function getHealthLast12hLogs({id}: { id: string }) {
|
||||
.where(
|
||||
and(
|
||||
eq(drizzleDb.schemas.healthcheckLog.objectId, id),
|
||||
gte(drizzleDb.schemas.healthcheckLog.date, since)
|
||||
)
|
||||
)
|
||||
gte(drizzleDb.schemas.healthcheckLog.date, since),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteHealthLogsOlderThan12h() {
|
||||
const now = new Date()
|
||||
const threshold = new Date(now.getTime() - 12 * 60 * 60 * 1000)
|
||||
const now = new Date();
|
||||
const threshold = new Date(now.getTime() - 12 * 60 * 60 * 1000);
|
||||
|
||||
const logsToDelete = await db
|
||||
.select()
|
||||
.from(drizzleDb.schemas.healthcheckLog)
|
||||
.where(
|
||||
lt(drizzleDb.schemas.healthcheckLog.date, threshold)
|
||||
)
|
||||
.where(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
|
||||
.delete(drizzleDb.schemas.healthcheckLog)
|
||||
.where(
|
||||
lt(drizzleDb.schemas.healthcheckLog.date, threshold)
|
||||
)
|
||||
.where(lt(drizzleDb.schemas.healthcheckLog.date, threshold));
|
||||
|
||||
return logsToDelete.length
|
||||
return logsToDelete.length;
|
||||
}
|
||||
|
||||
export async function checkAgentsHealthError() {
|
||||
@@ -58,8 +59,11 @@ export async function checkAgentsHealthError() {
|
||||
}
|
||||
|
||||
if (!settings.defaultNotificationChannelId) {
|
||||
log.error({name: "checkAgentsHealthError"},`No default notification channel id found.`)
|
||||
return
|
||||
log.error(
|
||||
{ name: "checkAgentsHealthError" },
|
||||
`No default notification channel id found.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
@@ -72,9 +76,9 @@ export async function checkAgentsHealthError() {
|
||||
|
||||
if (diffMinutes > 10) {
|
||||
if ((agent.healthErrorCount ?? 0) < 3) {
|
||||
|
||||
const newHealthErrorCount = (agent.healthErrorCount ?? 0) + 1
|
||||
await db.update(drizzleDb.schemas.agent)
|
||||
const newHealthErrorCount = (agent.healthErrorCount ?? 0) + 1;
|
||||
await db
|
||||
.update(drizzleDb.schemas.agent)
|
||||
.set({
|
||||
healthErrorCount: newHealthErrorCount,
|
||||
})
|
||||
@@ -91,31 +95,30 @@ export async function checkAgentsHealthError() {
|
||||
error: "Agent is down",
|
||||
},
|
||||
};
|
||||
log.info({name: "checkAgentsHealthError", payload: payload},`Agent Healthcheck Notification`)
|
||||
log.info(
|
||||
{ name: "checkAgentsHealthError", payload: payload },
|
||||
`Agent Healthcheck Notification`,
|
||||
);
|
||||
|
||||
await dispatchNotification(
|
||||
payload,
|
||||
undefined,
|
||||
settings.defaultNotificationChannelId,
|
||||
undefined
|
||||
undefined,
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
export async function checkDatabasesHealthError() {
|
||||
|
||||
const databases = await db.query.database.findMany({
|
||||
where: isNotNull(drizzleDb.schemas.database.lastContact),
|
||||
with: {
|
||||
agent: true,
|
||||
alertPolicies: true
|
||||
}
|
||||
})
|
||||
alertPolicies: true,
|
||||
},
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
|
||||
@@ -127,9 +130,9 @@ export async function checkDatabasesHealthError() {
|
||||
|
||||
if (diffMinutes > 10) {
|
||||
if ((database.healthErrorCount ?? 0) < 3) {
|
||||
|
||||
const newHealthErrorCount = (database.healthErrorCount ?? 0) + 1
|
||||
await db.update(drizzleDb.schemas.database)
|
||||
const newHealthErrorCount = (database.healthErrorCount ?? 0) + 1;
|
||||
await db
|
||||
.update(drizzleDb.schemas.database)
|
||||
.set({
|
||||
healthErrorCount: newHealthErrorCount,
|
||||
})
|
||||
@@ -141,24 +144,30 @@ export async function checkDatabasesHealthError() {
|
||||
});
|
||||
|
||||
const defaultPolicy = settings?.notificationChannel
|
||||
? [{
|
||||
? [
|
||||
{
|
||||
id: null,
|
||||
notificationChannelId: settings.notificationChannel.id,
|
||||
enabled: settings.notificationChannel.enabled,
|
||||
eventKinds: ["error_health_database"]
|
||||
}]
|
||||
eventKinds: ["error_health_database"],
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const policiesToUse = (database.alertPolicies && database.alertPolicies.length > 0)
|
||||
? database.alertPolicies.filter(policy => policy.enabled && policy.eventKinds.includes("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
|
||||
continue;
|
||||
}
|
||||
|
||||
const promises = policiesToUse.map(alertPolicy => {
|
||||
|
||||
const promises = policiesToUse.map((alertPolicy) => {
|
||||
const payload: EventPayload = {
|
||||
title: "Database down",
|
||||
message: `Database ${database.name} is down, (notification number: ${newHealthErrorCount}/3)`,
|
||||
@@ -171,9 +180,17 @@ export async function checkDatabasesHealthError() {
|
||||
},
|
||||
};
|
||||
|
||||
log.info({name: "checkDatabasesHealthError", payload: payload},`Database Healthcheck Notification`)
|
||||
log.info(
|
||||
{ name: "checkDatabasesHealthError", payload: payload },
|
||||
`Database Healthcheck Notification`,
|
||||
);
|
||||
|
||||
return dispatchNotification(payload, alertPolicy.id == null ? undefined : alertPolicy.id, alertPolicy.id ? undefined : alertPolicy.notificationChannelId, undefined);
|
||||
return dispatchNotification(
|
||||
payload,
|
||||
alertPolicy.id == null ? undefined : alertPolicy.id,
|
||||
alertPolicy.id ? undefined : alertPolicy.notificationChannelId,
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
await Promise.all(promises);
|
||||
@@ -181,5 +198,3 @@ export async function checkDatabasesHealthError() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
import {desc, eq} from "drizzle-orm";
|
||||
"use server";
|
||||
|
||||
import { desc, eq, isNull } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
NotificationChannel,
|
||||
notificationChannel,
|
||||
organizationNotificationChannel
|
||||
organizationNotificationChannel,
|
||||
} from "@/db/schema/09_notification-channel";
|
||||
import {storageChannel} from "@/db/schema/12_storage-channel";
|
||||
|
||||
export async function getOrganizationChannels(organizationId: string) {
|
||||
return await db
|
||||
export async function getOrganizationChannels(
|
||||
organizationId: string,
|
||||
): Promise<NotificationChannel[]> {
|
||||
const [orgChannels, systemChannels] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: notificationChannel.id,
|
||||
name: notificationChannel.name,
|
||||
@@ -18,13 +22,41 @@ export async function getOrganizationChannels(organizationId: string) {
|
||||
updatedAt: notificationChannel.updatedAt,
|
||||
createdAt: notificationChannel.createdAt,
|
||||
deletedAt: notificationChannel.deletedAt,
|
||||
organizationId: notificationChannel.organizationId
|
||||
organizationId: notificationChannel.organizationId,
|
||||
})
|
||||
.from(organizationNotificationChannel)
|
||||
.innerJoin(
|
||||
notificationChannel,
|
||||
eq(organizationNotificationChannel.notificationChannelId, notificationChannel.id)
|
||||
eq(
|
||||
organizationNotificationChannel.notificationChannelId,
|
||||
notificationChannel.id,
|
||||
),
|
||||
)
|
||||
.orderBy(desc(notificationChannel.createdAt))
|
||||
.where(eq(organizationNotificationChannel.organizationId, organizationId)) as unknown as NotificationChannel[];
|
||||
.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,5 +1,10 @@
|
||||
import {and, desc, eq, gte, lte} from 'drizzle-orm';
|
||||
import {NotificationLevel, notificationLog} from "@/db/schema/11_notification-log";
|
||||
"use server";
|
||||
|
||||
import { and, desc, eq, gte, lte } from "drizzle-orm";
|
||||
import {
|
||||
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";
|
||||
@@ -15,7 +20,7 @@ export type NotificationLogWithRelations = {
|
||||
content: {
|
||||
title: string;
|
||||
message: string;
|
||||
},
|
||||
};
|
||||
channel: {
|
||||
name: string;
|
||||
provider: string;
|
||||
@@ -25,8 +30,7 @@ export type NotificationLogWithRelations = {
|
||||
} | null;
|
||||
};
|
||||
|
||||
export async function getNotificationHistory(
|
||||
filters?: {
|
||||
export async function getNotificationHistory(filters?: {
|
||||
channelId?: string;
|
||||
policyId?: string;
|
||||
organizationId?: string;
|
||||
@@ -35,14 +39,17 @@ export async function getNotificationHistory(
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
limit?: number;
|
||||
}
|
||||
): Promise<NotificationLogWithRelations[]> {
|
||||
}): Promise<NotificationLogWithRelations[]> {
|
||||
const where = [];
|
||||
if (filters?.channelId) where.push(eq(notificationLog.channelId, filters.channelId));
|
||||
if (filters?.policyId) where.push(eq(notificationLog.policyId, filters.policyId));
|
||||
if (filters?.organizationId) where.push(eq(notificationLog.organizationId, filters.organizationId));
|
||||
if (filters?.channelId)
|
||||
where.push(eq(notificationLog.channelId, filters.channelId));
|
||||
if (filters?.policyId)
|
||||
where.push(eq(notificationLog.policyId, filters.policyId));
|
||||
if (filters?.organizationId)
|
||||
where.push(eq(notificationLog.organizationId, filters.organizationId));
|
||||
if (filters?.level) where.push(eq(notificationLog.level, filters.level));
|
||||
if (typeof filters?.success === 'boolean') where.push(eq(notificationLog.success, filters.success));
|
||||
if (typeof filters?.success === "boolean")
|
||||
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));
|
||||
|
||||
@@ -68,13 +75,16 @@ export async function getNotificationHistory(
|
||||
},
|
||||
})
|
||||
.from(notificationLog)
|
||||
.leftJoin(notificationChannel, eq(notificationLog.channelId, notificationChannel.id))
|
||||
.leftJoin(
|
||||
notificationChannel,
|
||||
eq(notificationLog.channelId, notificationChannel.id),
|
||||
)
|
||||
// .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,
|
||||
payload: row.payload as Json,
|
||||
}));
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use server";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { member } from "@/db/schema/04_member";
|
||||
|
||||
+20
-14
@@ -1,24 +1,28 @@
|
||||
"use server";
|
||||
|
||||
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) {
|
||||
return db.query.project.findFirst({
|
||||
where: eq(project.organizationId, organizationId),
|
||||
with: {
|
||||
databases: true
|
||||
}
|
||||
databases: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const getOrganizationProjectDatabases = async ({organizationSlug, projectId}: {
|
||||
organizationSlug: string, projectId: string
|
||||
export const getOrganizationProjectDatabases = async ({
|
||||
organizationSlug,
|
||||
projectId,
|
||||
}: {
|
||||
organizationSlug: string;
|
||||
projectId: string;
|
||||
}) => {
|
||||
try {
|
||||
|
||||
const organization = await getOrganization({});
|
||||
|
||||
if (!organization) {
|
||||
@@ -30,10 +34,13 @@ export const getOrganizationProjectDatabases = async ({organizationSlug, project
|
||||
};
|
||||
}
|
||||
const databasesProject = await db.query.project.findFirst({
|
||||
where: and(eq(drizzleDb.schemas.project.organizationId, organization.id), eq(drizzleDb.schemas.project.id, projectId)),
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.project.organizationId, organization.id),
|
||||
eq(drizzleDb.schemas.project.id, projectId),
|
||||
),
|
||||
with: {
|
||||
databases: true
|
||||
}
|
||||
databases: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!databasesProject) {
|
||||
@@ -47,12 +54,11 @@ export const getOrganizationProjectDatabases = async ({organizationSlug, project
|
||||
|
||||
return {
|
||||
data: databasesProject.databases,
|
||||
ids: databasesProject.databases.map((project) => project.id)
|
||||
}
|
||||
|
||||
|
||||
ids: databasesProject.databases.map((project) => project.id),
|
||||
};
|
||||
} catch (e: any) {
|
||||
const errorMessage = e?.response?.data?.message || e?.message || "Unknown auth error";
|
||||
const errorMessage =
|
||||
e?.response?.data?.message || e?.message || "Unknown auth error";
|
||||
const status = e?.response?.status || 500;
|
||||
|
||||
console.error("API GettingOrganizationProjectDatabases error:", {
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
"use server";
|
||||
|
||||
import { db } from "@/db";
|
||||
|
||||
export async function getSettings() {
|
||||
return db.query.setting.findFirst();
|
||||
}
|
||||
|
||||
export async function isOnboardingDone(): Promise<boolean> {
|
||||
const settings = await db.query.setting.findFirst();
|
||||
return settings?.onboarding ?? false;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import {desc, eq} from "drizzle-orm";
|
||||
import {db} from "@/db";
|
||||
import {organizationStorageChannel, StorageChannel, storageChannel} from "@/db/schema/12_storage-channel";
|
||||
"use server";
|
||||
|
||||
export async function getOrganizationStorageChannels(organizationId: string) {
|
||||
return await db
|
||||
import { desc, eq, isNull } from "drizzle-orm";
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
organizationStorageChannel,
|
||||
StorageChannel,
|
||||
storageChannel,
|
||||
} from "@/db/schema/12_storage-channel";
|
||||
|
||||
export async function getOrganizationStorageChannels(
|
||||
organizationId: string,
|
||||
): Promise<StorageChannel[]> {
|
||||
const [orgChannels, systemChannels] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: storageChannel.id,
|
||||
name: storageChannel.name,
|
||||
@@ -18,8 +27,31 @@ export async function getOrganizationStorageChannels(organizationId: string) {
|
||||
.from(organizationStorageChannel)
|
||||
.innerJoin(
|
||||
storageChannel,
|
||||
eq(organizationStorageChannel.storageChannelId, storageChannel.id)
|
||||
eq(organizationStorageChannel.storageChannelId, storageChannel.id),
|
||||
)
|
||||
.orderBy(desc(storageChannel.createdAt))
|
||||
.where(eq(organizationStorageChannel.organizationId, organizationId)) as unknown as StorageChannel[];
|
||||
.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[];
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use server";
|
||||
|
||||
import { SignUpUser } from "@/types/auth";
|
||||
import { hashPassword } from "better-auth/crypto";
|
||||
import { db } from "@/db";
|
||||
@@ -5,7 +7,6 @@ import * as drizzleDb from "@/db";
|
||||
import { User, UserThemeEnum } from "@/db/schema/02_user";
|
||||
import { assertValidPassword } from "@/utils/password";
|
||||
|
||||
|
||||
export async function hasUsers(): Promise<boolean> {
|
||||
const result = await db.select().from(drizzleDb.schemas.user).limit(1);
|
||||
return result.length > 0;
|
||||
@@ -17,7 +18,9 @@ export async function createUserDb(data: SignUpUser): Promise<User> {
|
||||
const now = new Date();
|
||||
const userId = crypto.randomUUID();
|
||||
|
||||
const [newUser] = await db.insert(drizzleDb.schemas.user).values({
|
||||
const [newUser] = await db
|
||||
.insert(drizzleDb.schemas.user)
|
||||
.values({
|
||||
...data,
|
||||
id: userId,
|
||||
name: data.name,
|
||||
@@ -27,7 +30,8 @@ export async function createUserDb(data: SignUpUser): Promise<User> {
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
theme: data.theme as UserThemeEnum,
|
||||
}).returning();
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (data.password) {
|
||||
const hashedPassword = await hashPassword(data.password);
|
||||
@@ -41,5 +45,5 @@ export async function createUserDb(data: SignUpUser): Promise<User> {
|
||||
});
|
||||
}
|
||||
|
||||
return newUser
|
||||
return newUser;
|
||||
}
|
||||
|
||||
@@ -88,6 +88,8 @@ export const env = createEnv({
|
||||
|
||||
ALLOWED_GROUP: z.string().optional(),
|
||||
|
||||
SKIP_ONBOARDING: z.string().optional().default("false"),
|
||||
|
||||
AUTH_EMAIL_PASSWORD_ENABLED: z.string().optional().default("true"),
|
||||
AUTH_SIGNUP_ENABLED: z.string().optional().default("true"),
|
||||
AUTH_PASSKEY_ENABLED: z.string().optional().default("false"),
|
||||
@@ -174,6 +176,8 @@ export const env = createEnv({
|
||||
|
||||
ALLOWED_GROUP: process.env.ALLOWED_GROUP,
|
||||
|
||||
SKIP_ONBOARDING: process.env.SKIP_ONBOARDING,
|
||||
|
||||
AUTH_EMAIL_PASSWORD_ENABLED: process.env.AUTH_EMAIL_PASSWORD_ENABLED,
|
||||
AUTH_SIGNUP_ENABLED: process.env.AUTH_SIGNUP_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 isLocalSystem = data.provider == "local";
|
||||
const isSystemChannel = data.organizationId === null;
|
||||
|
||||
return (
|
||||
<div className="block transition-all duration-200 rounded-xl">
|
||||
@@ -69,7 +70,7 @@ export const ChannelCard = (props: ChannelCardProps) => {
|
||||
channel={data}
|
||||
kind={kind}
|
||||
/>
|
||||
{!isLocalSystem && (
|
||||
{!isLocalSystem && !isSystemChannel && (
|
||||
<DeleteChannelButton
|
||||
kind={kind}
|
||||
organizationId={organization?.id}
|
||||
|
||||
@@ -73,6 +73,24 @@ export const removeNotificationChannelAction = userAction.schema(
|
||||
const {organizationId, notificationChannelId} = parsedInput;
|
||||
|
||||
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) {
|
||||
await db
|
||||
.delete(drizzleDb.schemas.organizationNotificationChannel)
|
||||
|
||||
@@ -74,6 +74,24 @@ export const removeStorageChannelAction = userAction.schema(
|
||||
const {organizationId, id} = parsedInput;
|
||||
|
||||
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) {
|
||||
await db
|
||||
.delete(drizzleDb.schemas.organizationStorageChannel)
|
||||
|
||||
@@ -1,149 +1,73 @@
|
||||
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { InfoIcon, Plus, Trash2 } from "lucide-react";
|
||||
import { useFieldArray } from "react-hook-form";
|
||||
import {DatabaseWith} from "@/db/schema/07_database";
|
||||
import {NotificationChannel} from "@/db/schema/09_notification-channel";
|
||||
import { toast } from "sonner";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ButtonWithLoading } from "@/components/common/button-with-loading";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { MultiSelect } from "@/components/common/multi-select";
|
||||
import {useMutation, useQueryClient} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import Link from "next/link";
|
||||
import { useIsMobile } from "@/hooks/use-mobile";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {
|
||||
ChannelKind,
|
||||
getChannelIcon,
|
||||
getChannelTextBasedOnKind
|
||||
} from "@/features/channel/channels-helpers";
|
||||
import {StorageChannel} from "@/db/schema/12_storage-channel";
|
||||
import { ChannelKind, getChannelIcon, getChannelTextBasedOnKind } from "@/features/channel/channels-helpers";
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm } from "@/components/ui/form";
|
||||
import {
|
||||
EVENT_KIND_BACKUP_ONLY_OPTIONS,
|
||||
EVENT_KIND_OPTIONS,
|
||||
PoliciesSchema,
|
||||
PoliciesType,
|
||||
PolicyType
|
||||
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";
|
||||
|
||||
export type ChannelEntry = { id: string; name: string; provider: string };
|
||||
|
||||
type ChannelPoliciesFormProps = {
|
||||
onSuccess?: () => void;
|
||||
channels: NotificationChannel[] | StorageChannel[];
|
||||
database: DatabaseWith;
|
||||
kind: ChannelKind
|
||||
channels: ChannelEntry[];
|
||||
defaultPolicies: PolicyType[];
|
||||
kind: ChannelKind;
|
||||
isBackupOnly?: boolean;
|
||||
isPending?: boolean;
|
||||
onSave: (policies: PolicyType[]) => Promise<void>;
|
||||
onCancel?: () => void;
|
||||
noChannelsMessage?: ReactNode;
|
||||
};
|
||||
|
||||
|
||||
export const ChannelPoliciesForm = ({
|
||||
database,
|
||||
channels,
|
||||
onSuccess,
|
||||
kind
|
||||
defaultPolicies,
|
||||
kind,
|
||||
isBackupOnly = false,
|
||||
isPending = false,
|
||||
onSave,
|
||||
onCancel,
|
||||
noChannelsMessage,
|
||||
}: ChannelPoliciesFormProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
const isMobile = useIsMobile();
|
||||
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({
|
||||
schema: PoliciesSchema,
|
||||
defaultValues: { policies: defaultPolicies },
|
||||
context: { kind }
|
||||
context: { kind },
|
||||
});
|
||||
|
||||
const { fields, append, remove } = useFieldArray({ control: form.control, name: "policies" });
|
||||
|
||||
const addPolicy = () => append({ channelId: "", eventKinds: [], enabled: true });
|
||||
const removePolicyHandler = (index: number) => remove(index);
|
||||
const onCancel = () => { form.reset(); onSuccess?.(); };
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async ({policies}: PoliciesType) => {
|
||||
const payload = policies.map(p => kind === "notification" ? p : { ...p, eventKinds: undefined });
|
||||
|
||||
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"); },
|
||||
});
|
||||
const handleCancel = () => {
|
||||
form.reset();
|
||||
onCancel?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Form form={form} className="flex flex-col gap-6" onSubmit={
|
||||
async (values) => {
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
className="flex flex-col gap-6"
|
||||
onSubmit={async (values) => {
|
||||
if (kind === "notification") {
|
||||
for (const policy of values.policies) {
|
||||
if (!policy.eventKinds || policy.eventKinds.length === 0) {
|
||||
@@ -152,10 +76,9 @@ export const ChannelPoliciesForm = ({
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await mutation.mutateAsync(values)
|
||||
}
|
||||
}>
|
||||
await onSave(values.policies);
|
||||
}}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@@ -175,7 +98,8 @@ export const ChannelPoliciesForm = ({
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={addPolicy}>
|
||||
onClick={addPolicy}
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-1.5" /> Add Policy
|
||||
</Button>
|
||||
</div>
|
||||
@@ -185,11 +109,11 @@ export const ChannelPoliciesForm = ({
|
||||
<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" />
|
||||
<p className="font-medium text-sm text-foreground">No channels</p>
|
||||
{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.
|
||||
No {channelText.toLowerCase()} channels configured.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : 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">
|
||||
@@ -198,13 +122,18 @@ export const ChannelPoliciesForm = ({
|
||||
</div>
|
||||
<p className="font-medium text-sm text-foreground">No policies</p>
|
||||
<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>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4">
|
||||
{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-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">
|
||||
@@ -215,28 +144,36 @@ export const ChannelPoliciesForm = ({
|
||||
control={form.control}
|
||||
name={`policies.${index}.channelId`}
|
||||
render={({ field }) => {
|
||||
const selectedIds = form.watch("policies").map((a: PolicyType) => a.channelId).filter(Boolean);
|
||||
const availableChannels = channels.filter(
|
||||
(channel) => channel.id.toString() === field.value?.toString() || !selectedIds.includes(channel.id.toString())
|
||||
const selectedIds = form
|
||||
.watch("policies")
|
||||
.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 (
|
||||
<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>
|
||||
<SelectTrigger className="h-9 w-full bg-background border-input min-w-0">
|
||||
<SelectValue placeholder="Select channel">
|
||||
{selectedChannel && (
|
||||
{selected && (
|
||||
<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">
|
||||
{getChannelIcon(selectedChannel.provider)}
|
||||
{getChannelIcon(selected.provider)}
|
||||
</div>
|
||||
<span className="truncate font-medium text-sm min-w-0">
|
||||
{selectedChannel.name}
|
||||
{selected.name}
|
||||
</span>
|
||||
<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>
|
||||
</div>
|
||||
)}
|
||||
@@ -244,12 +181,16 @@ export const ChannelPoliciesForm = ({
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{availableChannels.map(channel => (
|
||||
<SelectItem key={channel.id.toString()} value={channel.id.toString()}>
|
||||
{available.map((c) => (
|
||||
<SelectItem key={c.id.toString()} value={c.id.toString()}>
|
||||
<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>
|
||||
<span className="font-medium truncate min-w-0">{channel.name}</span>
|
||||
<span className="text-xs text-muted-foreground ml-2 capitalize shrink-0">({channel.provider})</span>
|
||||
<div className="text-muted-foreground scale-90 shrink-0">
|
||||
{getChannelIcon(c.provider)}
|
||||
</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>
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -263,7 +204,9 @@ export const ChannelPoliciesForm = ({
|
||||
</div>
|
||||
|
||||
<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
|
||||
control={form.control}
|
||||
name={`policies.${index}.enabled`}
|
||||
@@ -272,11 +215,19 @@ export const ChannelPoliciesForm = ({
|
||||
<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">
|
||||
{!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"}
|
||||
</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>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
@@ -285,9 +236,13 @@ export const ChannelPoliciesForm = ({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1.5 shrink-0 mt-auto">
|
||||
<Button type="button" variant="outline" size="icon"
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
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={() => removePolicyHandler(index)}>
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -299,14 +254,20 @@ export const ChannelPoliciesForm = ({
|
||||
name={`policies.${index}.eventKinds`}
|
||||
render={({ field }) => (
|
||||
<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>
|
||||
<div className="max-w-full overflow-hidden">
|
||||
<MultiSelect
|
||||
options={isBackupOnly ? EVENT_KIND_BACKUP_ONLY_OPTIONS : EVENT_KIND_OPTIONS}
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value ?? []}
|
||||
placeholder={isMobile ? "Select events..." : "Select events to trigger notifications..."}
|
||||
placeholder={
|
||||
isMobile
|
||||
? "Select events..."
|
||||
: "Select events to trigger notifications..."
|
||||
}
|
||||
variant="inverted"
|
||||
animation={0}
|
||||
className="bg-background/50 w-full min-w-0 flex-wrap"
|
||||
@@ -327,8 +288,12 @@ export const ChannelPoliciesForm = ({
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-end pt-2 border-t mt-2">
|
||||
<ButtonWithLoading variant="outline" type="button" onClick={onCancel}>Cancel</ButtonWithLoading>
|
||||
<ButtonWithLoading isPending={mutation.isPending}>Save Changes</ButtonWithLoading>
|
||||
{onCancel && (
|
||||
<ButtonWithLoading variant="outline" type="button" onClick={handleCancel}>
|
||||
Cancel
|
||||
</ButtonWithLoading>
|
||||
)}
|
||||
<ButtonWithLoading isPending={isPending}>Save Changes</ButtonWithLoading>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
|
||||
@@ -1,22 +1,36 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import { ReactNode, useState } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {DatabaseWith} from "@/db/schema/07_database";
|
||||
import {NotificationChannel} from "@/db/schema/09_notification-channel";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import Link from "next/link";
|
||||
import { DatabaseWith } from "@/db/schema/07_database";
|
||||
import { NotificationChannel } from "@/db/schema/09_notification-channel";
|
||||
import { StorageChannel } from "@/db/schema/12_storage-channel";
|
||||
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 = {
|
||||
database: DatabaseWith;
|
||||
@@ -24,24 +38,93 @@ type ChannelPoliciesModalProps = {
|
||||
organizationId: string;
|
||||
kind: ChannelKind;
|
||||
icon: ReactNode;
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
export const ChannelPoliciesModal = ({ icon, kind, database, channels, organizationId }: ChannelPoliciesModalProps) => {
|
||||
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
|
||||
.filter((channel) => channel.enabled)
|
||||
const defaultPolicies: PolicyType[] =
|
||||
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
|
||||
.map(channel => channel.id);
|
||||
const activeAlertPolicies = database.alertPolicies?.filter((policy) => channelsIds.includes(policy.notificationChannelId));
|
||||
const activeStoragePolicies = database.storagePolicies?.filter((policy) => channelsIds.includes(policy.storageChannelId));
|
||||
const activePolicies = kind === "notification"
|
||||
? database.alertPolicies?.filter((p) => channelIds.includes(p.notificationChannelId))
|
||||
: database.storagePolicies?.filter((p) => channelIds.includes(p.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 (
|
||||
<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">
|
||||
{icon}
|
||||
{activePolicies && activePolicies.length > 0 && (
|
||||
<Badge
|
||||
className="absolute -top-1.5 -right-1.5 h-4 w-4 rounded-full p-0 text-[10px] flex items-center justify-center"
|
||||
>
|
||||
<Badge 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}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -65,13 +146,28 @@ export const ChannelPoliciesModal = ({icon, kind, database, channels, organizati
|
||||
</DialogDescription>
|
||||
<Separator className="mt-3 mb-3" />
|
||||
<ChannelPoliciesForm
|
||||
channels={channels}
|
||||
database={database}
|
||||
onSuccess={() => setOpen(false)}
|
||||
channels={channelsFiltered.map((c) => ({ id: c.id, name: c.name, provider: c.provider }))}
|
||||
defaultPolicies={defaultPolicies}
|
||||
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>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"use client"
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
@@ -7,14 +8,12 @@ import {
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
useZodForm
|
||||
useZodForm,
|
||||
} from "@/components/ui/form";
|
||||
import {RetentionSettings, RetentionSettingsSchema} from "@/features/database/retention-policy.schema";
|
||||
import {useMutation, useQueryClient} from "@tanstack/react-query";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {updateOrCreateBackupRetentionPolicyAction} from "@/features/database/retention-policy.action";
|
||||
import {DatabaseWith, RetentionPolicy} from "@/db/schema/07_database";
|
||||
import {toast} from "sonner";
|
||||
import {
|
||||
RetentionSettings,
|
||||
RetentionSettingsSchema,
|
||||
} from "@/features/database/retention-policy.schema";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -23,44 +22,24 @@ import {Button} from "@/components/ui/button";
|
||||
import { Calendar, Save } from "lucide-react";
|
||||
|
||||
export type BackupRetentionSettingsFormProps = {
|
||||
defaultValues?: RetentionPolicy;
|
||||
database: DatabaseWith;
|
||||
};
|
||||
|
||||
export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRetentionSettingsFormProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
const router = useRouter();
|
||||
|
||||
const defaultValuesFormatted: RetentionSettings = {
|
||||
type: defaultValues?.type,
|
||||
count: defaultValues?.count ?? 7,
|
||||
days: defaultValues?.days ?? 30,
|
||||
gfs: {
|
||||
daily: defaultValues?.gfsDaily ?? 7,
|
||||
weekly: defaultValues?.gfsWeekly ?? 4,
|
||||
monthly: defaultValues?.gfsMonthly ?? 12,
|
||||
yearly: defaultValues?.gfsYearly ?? 3,
|
||||
},
|
||||
defaultValues?: RetentionSettings;
|
||||
currentType?: string;
|
||||
isPending?: boolean;
|
||||
onSave: (values: RetentionSettings) => Promise<void>;
|
||||
};
|
||||
|
||||
export const BackupRetentionSettingsForm = ({
|
||||
defaultValues,
|
||||
currentType,
|
||||
isPending = false,
|
||||
onSave,
|
||||
}: BackupRetentionSettingsFormProps) => {
|
||||
const form = useZodForm({
|
||||
schema: RetentionSettingsSchema,
|
||||
defaultValues: defaultValuesFormatted,
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (payload: RetentionSettings) =>
|
||||
await 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.");
|
||||
defaultValues: defaultValues ?? {
|
||||
count: 7,
|
||||
days: 30,
|
||||
gfs: { daily: 7, weekly: 4, monthly: 12, yearly: 3 },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -73,7 +52,11 @@ export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRet
|
||||
(values.gfs.yearly ?? 0)
|
||||
);
|
||||
}
|
||||
return values.type === "count" ? values.count ?? 0 : values.type === "days" ? values.days ?? 0 : 0;
|
||||
return values.type === "count"
|
||||
? (values.count ?? 0)
|
||||
: values.type === "days"
|
||||
? (values.days ?? 0)
|
||||
: 0;
|
||||
};
|
||||
|
||||
const getStorageEstimate = (totalFiles: number) => {
|
||||
@@ -89,7 +72,7 @@ export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRet
|
||||
form={form}
|
||||
className="flex flex-col gap-6 mt-0"
|
||||
onSubmit={async (values) => {
|
||||
await mutation.mutateAsync(values);
|
||||
await onSave(values);
|
||||
}}
|
||||
>
|
||||
<FormField
|
||||
@@ -141,10 +124,11 @@ export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRet
|
||||
</Badge>
|
||||
)}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">{opt.desc}</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{opt.desc}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{database.retentionPolicy?.type === opt.id && (
|
||||
{currentType === opt.id && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Actual
|
||||
</Badge>
|
||||
@@ -158,10 +142,7 @@ export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRet
|
||||
)}
|
||||
/>
|
||||
|
||||
{form.watch("type") && (
|
||||
<Separator/>
|
||||
)}
|
||||
|
||||
{form.watch("type") && <Separator />}
|
||||
|
||||
{form.watch("type") === "count" && (
|
||||
<FormField
|
||||
@@ -181,7 +162,8 @@ export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRet
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Older backups beyond this count will be automatically deleted.
|
||||
Older backups beyond this count will be automatically
|
||||
deleted.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -207,7 +189,8 @@ export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRet
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Backups older than {field.value} days will be automatically deleted.
|
||||
Backups older than {field.value} days will be automatically
|
||||
deleted.
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
@@ -217,28 +200,31 @@ export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRet
|
||||
|
||||
{form.watch("type") === "gfs" && (
|
||||
<div className="space-y-4">
|
||||
{["daily", "weekly", "monthly", "yearly"].map((key) => (
|
||||
{[
|
||||
{ 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}` as const}
|
||||
name={`gfs.${key}`}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{key.charAt(0).toUpperCase() + key.slice(1)} backups
|
||||
</FormLabel>
|
||||
<FormLabel>{label} backups</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={key === "yearly" ? 50 : key === "monthly" ? 120 : key === "weekly" ? 52 : 31}
|
||||
max={max}
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(e.target.valueAsNumber)}
|
||||
onChange={(e) =>
|
||||
field.onChange(e.target.valueAsNumber)
|
||||
}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
Keep N {key} backups
|
||||
</FormDescription>
|
||||
<FormDescription>Keep N {key} backups</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
@@ -276,7 +262,9 @@ export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRet
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Estimated files per database:</span>
|
||||
<span className="text-muted-foreground">
|
||||
Estimated files per database:
|
||||
</span>
|
||||
<p className="font-medium">
|
||||
{calculateTotalFiles(form.getValues())} backup files
|
||||
</p>
|
||||
@@ -293,9 +281,9 @@ export const BackupRetentionSettingsForm = ({defaultValues, database}: BackupRet
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button type="submit" disabled={mutation.isPending} className="w-full">
|
||||
<Button type="submit" disabled={isPending} className="w-full">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{mutation.isPending ? "Saving Policy..." : "Save Retention Policy"}
|
||||
{isPending ? "Saving Policy..." : "Save Retention Policy"}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,16 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Database, Ruler } from "lucide-react";
|
||||
import {DatabaseWith as DbSchema, RetentionPolicy} from "@/db/schema/07_database";
|
||||
import {
|
||||
BackupRetentionSettingsForm
|
||||
} from "@/features/database/retention-policy-form";
|
||||
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 = {
|
||||
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.");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
@@ -18,9 +54,7 @@ export const RetentionPolicySheet = ({database}: RetentionPolicySheetProps) => {
|
||||
<Ruler />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent
|
||||
className="flex gap-4 p-4 w-full md:w-[800px] max-w-[800px] max-h-screen overflow-y-scroll"
|
||||
>
|
||||
<SheetContent className="flex gap-4 p-4 w-full md:w-[800px] max-w-[800px] max-h-screen overflow-y-scroll">
|
||||
<SheetHeader>
|
||||
<SheetTitle className="flex items-center gap-2 text-balance">
|
||||
<Database className="h-5 w-5" />
|
||||
@@ -28,22 +62,24 @@ export const RetentionPolicySheet = ({database}: RetentionPolicySheetProps) => {
|
||||
</SheetTitle>
|
||||
<SheetDescription className="text-pretty">
|
||||
Configure how long to keep your .dump backup files. Choose from simple count-based, time-based,
|
||||
or
|
||||
enterprise GFS rotation strategies.
|
||||
or enterprise GFS rotation strategies.
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
{database.backupPolicy !== null ?
|
||||
<BackupRetentionSettingsForm database={database}
|
||||
defaultValues={database.retentionPolicy as RetentionPolicy}/>
|
||||
:
|
||||
<div
|
||||
className="flex flex-col items-center justify-center text-center py-12 gap-4 border rounded-lg">
|
||||
{database.backupPolicy !== null ? (
|
||||
<BackupRetentionSettingsForm
|
||||
defaultValues={toRetentionSettings(database.retentionPolicy as RetentionPolicy)}
|
||||
currentType={database.retentionPolicy?.type}
|
||||
isPending={mutation.isPending}
|
||||
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">
|
||||
No backup policy configured yet. Please configure one!
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,16 +3,20 @@ import { getAccounts, getSession, getSessions } from "@/lib/auth/auth";
|
||||
import { LoggedInButtonClient } from "./logged-in-button";
|
||||
import { SUPPORTED_PROVIDERS } from "@/lib/auth/config";
|
||||
import { env } from "@/env.mjs";
|
||||
import { getSettings } from "@/db/services/setting";
|
||||
import { resolveAvatarUrl } from "@/utils/resolve-avatar-url";
|
||||
|
||||
export const LoggedInButton = async () => {
|
||||
const user = await currentUser();
|
||||
const sessions = await getSessions();
|
||||
const currentSession = await getSession();
|
||||
const accounts = await getAccounts();
|
||||
const [user, sessions, currentSession, accounts, settings] = await Promise.all([
|
||||
currentUser(),
|
||||
getSessions(),
|
||||
getSession(),
|
||||
getAccounts(),
|
||||
getSettings(),
|
||||
]);
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
|
||||
return (
|
||||
<LoggedInButtonClient
|
||||
user={user}
|
||||
@@ -22,6 +26,8 @@ export const LoggedInButton = async () => {
|
||||
accounts={accounts}
|
||||
providers={SUPPORTED_PROVIDERS.filter((p) => p.isActive)}
|
||||
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 { AuthProviderConfig } from "@/lib/auth/config";
|
||||
import {User} from "@/db/schema/02_user";
|
||||
import type { AvatarMode } from "@/features/onboarding/types";
|
||||
|
||||
type LoggedInButtonClientProps = {
|
||||
user: User;
|
||||
@@ -15,9 +16,11 @@ type LoggedInButtonClientProps = {
|
||||
accounts: Account[];
|
||||
providers: AuthProviderConfig[];
|
||||
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 (
|
||||
<LoggedInDropdown
|
||||
user={user}
|
||||
@@ -29,12 +32,14 @@ export const LoggedInButtonClient = ({ user, sessions, currentSession, accounts,
|
||||
accounts={accounts}
|
||||
providers={providers}
|
||||
apiEnabled={apiEnabled}
|
||||
avatarMode={avatarMode}
|
||||
avatarUrl={avatarUrl}
|
||||
>
|
||||
<SidebarMenuButton type="button" className="h-auto justify-between py-2" data-testid="profile-dropdown">
|
||||
<div className="flex items-center gap-2">
|
||||
<Avatar className="size-6">
|
||||
<AvatarFallback>{(user.name?.[0] ?? user.email?.[0] ?? "?").toUpperCase()}</AvatarFallback>
|
||||
{user.image && <AvatarImage src={user.image} />}
|
||||
{avatarUrl && <AvatarImage src={avatarUrl} />}
|
||||
</Avatar>
|
||||
<div className="flex flex-col items-start">
|
||||
<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 { Account, Session, User as UserType } from "@/db/schema/02_user";
|
||||
import { AuthProviderConfig } from "@/lib/auth/config";
|
||||
import type { AvatarMode } from "@/features/onboarding/types";
|
||||
|
||||
export type LoggedInDropdownProps = PropsWithChildren<{
|
||||
user: UserType;
|
||||
@@ -17,9 +18,11 @@ export type LoggedInDropdownProps = PropsWithChildren<{
|
||||
children: ReactNode;
|
||||
providers: AuthProviderConfig[];
|
||||
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 [isModalOpen, setIsModalOpen] = useState(false);
|
||||
@@ -35,6 +38,8 @@ export const LoggedInDropdown = ({ user, sessions, currentSession, accounts, chi
|
||||
onOpenChange={setIsModalOpen}
|
||||
providers={providers}
|
||||
apiEnabled={apiEnabled}
|
||||
avatarMode={avatarMode}
|
||||
avatarUrl={avatarUrl}
|
||||
/>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ProfileSidebar } from "./profile-sidebar";
|
||||
import type { AuthProviderConfig } from "@/lib/auth/config";
|
||||
import { User, Session, Account } from "@/db/schema/02_user";
|
||||
import { ProfileGeneral } from "@/features/profile/profile-general";
|
||||
import type { AvatarMode } from "@/features/onboarding/types";
|
||||
import { ProfileSecurity } from "@/features/profile/profile-security";
|
||||
import { ProfileProviders } from "@/features/profile/profile-providers";
|
||||
import { ProfileAccount } from "@/features/profile/profile-account";
|
||||
@@ -20,9 +21,11 @@ type ProfileModalProps = {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
providers: AuthProviderConfig[];
|
||||
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 (
|
||||
<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">
|
||||
@@ -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">
|
||||
<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 value="security" className="mt-0 h-full p-6 lg:p-10 outline-none focus-visible:ring-0">
|
||||
|
||||
@@ -1,62 +1,13 @@
|
||||
"use server";
|
||||
|
||||
import { z } from "zod";
|
||||
import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { userAction } from "@/lib/safe-actions/actions";
|
||||
|
||||
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(),
|
||||
});
|
||||
import { ApplyDbSettingsSchema } from "@/features/onboarding/schemas/db-settings.schema";
|
||||
|
||||
export const applyOnboardingDbSettingsAction = userAction
|
||||
.schema(
|
||||
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(),
|
||||
}),
|
||||
)
|
||||
.schema(ApplyDbSettingsSchema)
|
||||
.action(async ({ parsedInput }) => {
|
||||
const {
|
||||
databaseId,
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { AdvancedCronSelect } from "@/features/database/cron-advanced-select";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isValidCronPart } from "@/utils/cron";
|
||||
|
||||
export type BackupScheduleValue = {
|
||||
method: "manual" | "automatic";
|
||||
@@ -19,34 +13,38 @@ export type BackupScheduleValue = {
|
||||
};
|
||||
|
||||
const PRESETS = [
|
||||
{ label: "Every hour", cron: "0 * * * *" },
|
||||
{ label: "Every day", cron: "0 0 * * *" },
|
||||
{ label: "Every week", cron: "0 0 * * 0" },
|
||||
{ label: "Custom", cron: "custom" },
|
||||
{ label: "Hourly", sub: "Every hour", cron: "0 * * * *" },
|
||||
{ label: "Every 6h", sub: "4× per day", cron: "0 */6 * * *" },
|
||||
{ label: "Every 12h", sub: "2× per day", cron: "0 */12 * * *"},
|
||||
{ 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;
|
||||
|
||||
type PresetCron = (typeof PRESETS)[number]["cron"];
|
||||
|
||||
function detectPreset(cron: string | undefined): PresetCron {
|
||||
const match = PRESETS.find((p) => p.cron !== "custom" && p.cron === cron);
|
||||
return match ? match.cron : "custom";
|
||||
function isPresetCron(cron: string | undefined): cron is PresetCron {
|
||||
return PRESETS.some((p) => p.cron === cron);
|
||||
}
|
||||
|
||||
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;
|
||||
onChange: (value: BackupScheduleValue) => void;
|
||||
};
|
||||
|
||||
export const BackupScheduleSelector = ({
|
||||
value,
|
||||
onChange,
|
||||
}: BackupScheduleSelectorProps) => {
|
||||
const [customCron, setCustomCron] = useState<string>(
|
||||
value.cron ?? "0 0 * * *",
|
||||
export const BackupScheduleSelector = ({ value, onChange }: Props) => {
|
||||
const isCustom = !!value.cron && !isPresetCron(value.cron);
|
||||
const [customInput, setCustomInput] = useState(
|
||||
isCustom ? (value.cron ?? "") : "",
|
||||
);
|
||||
|
||||
const selectedPreset = detectPreset(value.cron);
|
||||
const isCustom = selectedPreset === "custom";
|
||||
const [customError, setCustomError] = useState<string | null>(null);
|
||||
|
||||
const handleMethodChange = (method: "manual" | "automatic") => {
|
||||
onChange({
|
||||
@@ -55,180 +53,110 @@ export const BackupScheduleSelector = ({
|
||||
});
|
||||
};
|
||||
|
||||
const handlePresetChange = (preset: PresetCron) => {
|
||||
if (preset === "custom") {
|
||||
onChange({ ...value, cron: customCron });
|
||||
const handlePresetClick = (cron: PresetCron) => {
|
||||
setCustomError(null);
|
||||
onChange({ ...value, cron });
|
||||
};
|
||||
|
||||
const handleCustomChange = (raw: string) => {
|
||||
setCustomInput(raw);
|
||||
if (validateCron(raw)) {
|
||||
setCustomError(null);
|
||||
onChange({ ...value, cron: raw.trim() });
|
||||
} else {
|
||||
onChange({ ...value, cron: preset });
|
||||
setCustomError("Invalid cron expression");
|
||||
}
|
||||
};
|
||||
|
||||
const handleCronPartChange = (
|
||||
type: "minute" | "hour" | "day-of-month" | "month" | "day-of-week",
|
||||
part: string,
|
||||
) => {
|
||||
const indexMap: Record<typeof type, number> = {
|
||||
minute: 0,
|
||||
hour: 1,
|
||||
"day-of-month": 2,
|
||||
month: 3,
|
||||
"day-of-week": 4,
|
||||
const handleCustomFocus = () => {
|
||||
if (!customInput && value.cron && isPresetCron(value.cron)) {
|
||||
setCustomInput(value.cron);
|
||||
}
|
||||
};
|
||||
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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<RadioGroup
|
||||
value={value.method}
|
||||
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: "Backups triggered manually only",
|
||||
},
|
||||
{
|
||||
id: "automatic",
|
||||
label: "Automatic",
|
||||
desc: "Scheduled via cron expression",
|
||||
},
|
||||
{ id: "manual", label: "Manual", desc: "Trigger backups manually" },
|
||||
{ id: "automatic", label: "Automatic", desc: "Scheduled via cron" },
|
||||
] as const
|
||||
).map((opt) => (
|
||||
<Label
|
||||
key={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
|
||||
? "border-primary bg-primary/5"
|
||||
: "hover:bg-muted/50"
|
||||
}`}
|
||||
: "hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem value={opt.id} id={opt.id} />
|
||||
<div className="flex-1">
|
||||
<span className="font-medium">{opt.label}</span>
|
||||
<p className="text-sm text-muted-foreground">{opt.desc}</p>
|
||||
<div>
|
||||
<p className="font-medium text-sm">{opt.label}</p>
|
||||
<p className="text-xs text-muted-foreground">{opt.desc}</p>
|
||||
</div>
|
||||
</Label>
|
||||
))}
|
||||
</RadioGroup>
|
||||
|
||||
{value.method === "automatic" && (
|
||||
<>
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label>Frequency</Label>
|
||||
<Select
|
||||
value={selectedPreset}
|
||||
onValueChange={(v) => handlePresetChange(v as PresetCron)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<Label className="text-xs font-medium text-muted-foreground uppercase tracking-widest">
|
||||
Frequency
|
||||
</Label>
|
||||
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{PRESETS.map((p) => (
|
||||
<SelectItem key={p.cron} value={p.cron}>
|
||||
{p.label}
|
||||
</SelectItem>
|
||||
<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",
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{p.label}</span>
|
||||
<span className="text-[11px] text-muted-foreground">{p.sub}</span>
|
||||
</button>
|
||||
))}
|
||||
</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="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>
|
||||
);
|
||||
|
||||
@@ -16,26 +16,34 @@ import type {
|
||||
SectionKind,
|
||||
} from "@/features/onboarding/types";
|
||||
|
||||
const SECTIONS: { kind: SectionKind; label: string; icon: React.ReactNode }[] =
|
||||
[
|
||||
const SECTIONS: {
|
||||
kind: SectionKind;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: React.ReactNode;
|
||||
}[] = [
|
||||
{
|
||||
kind: "retention",
|
||||
label: "Retention Policy",
|
||||
description: "Configure how long your data is retained",
|
||||
icon: <Shield className="size-4 text-muted-foreground" />,
|
||||
},
|
||||
{
|
||||
kind: "scheduling",
|
||||
label: "Scheduling",
|
||||
description: "Set up backup schedules for your database",
|
||||
icon: <Clock className="size-4 text-muted-foreground" />,
|
||||
},
|
||||
{
|
||||
kind: "notifications",
|
||||
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" />,
|
||||
},
|
||||
];
|
||||
@@ -80,8 +88,8 @@ export const DbDetail = ({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{SECTIONS.map(({ kind, label, icon }) => (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{SECTIONS.map(({ kind, label, description, icon }) => (
|
||||
<button
|
||||
key={kind}
|
||||
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">
|
||||
{icon}
|
||||
</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) && (
|
||||
<div className="size-5 rounded-full bg-primary flex items-center justify-center ml-auto shrink-0">
|
||||
<Check
|
||||
|
||||
@@ -27,7 +27,7 @@ export const DbGrid = ({
|
||||
Optional — configure backup policies for each database.
|
||||
</p>
|
||||
</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) => {
|
||||
const db = getDb(dbId);
|
||||
return (
|
||||
|
||||
@@ -32,7 +32,10 @@ type DbSectionProps = {
|
||||
storages: OnboardingChannel[];
|
||||
onBack: () => void;
|
||||
onSaved: () => void;
|
||||
updateDbSettings: (dbId: string, patch: Partial<OnboardingDbSettings>) => Promise<void>;
|
||||
updateDbSettings: (
|
||||
dbId: string,
|
||||
patch: Partial<OnboardingDbSettings>,
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
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">
|
||||
<p className="flex-1 text-sm font-medium">
|
||||
{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>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={onBack}>
|
||||
<ArrowLeft className="size-4 mr-1" />
|
||||
@@ -63,9 +68,12 @@ export const DbSection = ({
|
||||
<RetentionSection
|
||||
initial={settings.retention}
|
||||
isPending={applyMutation.isPending}
|
||||
onBack={onBack}
|
||||
onSave={async (retention) => {
|
||||
await applyMutation.mutateAsync({ databaseId: dbId, section: "retention", retention });
|
||||
await applyMutation.mutateAsync({
|
||||
databaseId: dbId,
|
||||
section: "retention",
|
||||
retention,
|
||||
});
|
||||
await updateDbSettings(dbId, { retention });
|
||||
toast.success("Retention policy saved.");
|
||||
onSaved();
|
||||
@@ -75,11 +83,18 @@ export const DbSection = ({
|
||||
|
||||
{section === "scheduling" && (
|
||||
<SchedulingSection
|
||||
initial={{ backupMethod: settings.backupMethod, backupCron: settings.backupCron }}
|
||||
initial={{
|
||||
backupMethod: settings.backupMethod,
|
||||
backupCron: settings.backupCron,
|
||||
}}
|
||||
isPending={applyMutation.isPending}
|
||||
onBack={onBack}
|
||||
onSave={async (backupMethod, backupCron) => {
|
||||
await applyMutation.mutateAsync({ databaseId: dbId, section: "scheduling", backupMethod, backupCron });
|
||||
await applyMutation.mutateAsync({
|
||||
databaseId: dbId,
|
||||
section: "scheduling",
|
||||
backupMethod,
|
||||
backupCron,
|
||||
});
|
||||
await updateDbSettings(dbId, { backupMethod, backupCron });
|
||||
toast.success("Schedule saved.");
|
||||
onSaved();
|
||||
@@ -92,9 +107,12 @@ export const DbSection = ({
|
||||
initial={settings.notificationPolicies ?? []}
|
||||
notifiers={notifiers}
|
||||
isPending={applyMutation.isPending}
|
||||
onBack={onBack}
|
||||
onSave={async (notificationPolicies) => {
|
||||
await applyMutation.mutateAsync({ databaseId: dbId, section: "notifications", notificationPolicies });
|
||||
await applyMutation.mutateAsync({
|
||||
databaseId: dbId,
|
||||
section: "notifications",
|
||||
notificationPolicies,
|
||||
});
|
||||
await updateDbSettings(dbId, { notificationPolicies });
|
||||
toast.success("Notification policies saved.");
|
||||
onSaved();
|
||||
@@ -107,9 +125,12 @@ export const DbSection = ({
|
||||
initial={settings.storagePolicies ?? []}
|
||||
storages={storages}
|
||||
isPending={applyMutation.isPending}
|
||||
onBack={onBack}
|
||||
onSave={async (storagePolicies) => {
|
||||
await applyMutation.mutateAsync({ databaseId: dbId, section: "storage", storagePolicies });
|
||||
await applyMutation.mutateAsync({
|
||||
databaseId: dbId,
|
||||
section: "storage",
|
||||
storagePolicies,
|
||||
});
|
||||
await updateDbSettings(dbId, { storagePolicies });
|
||||
toast.success("Storage policies saved.");
|
||||
onSaved();
|
||||
|
||||
@@ -1,223 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ArrowLeft, Bell, Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { MultiSelect } from "@/components/common/multi-select";
|
||||
import { EVENT_KIND_OPTIONS } from "@/features/database/channels-policy.schema";
|
||||
import { getChannelIcon } from "@/features/channel/channels-helpers";
|
||||
import type {
|
||||
EventKind,
|
||||
OnboardingChannel,
|
||||
OnboardingNotificationPolicy,
|
||||
} from "@/features/onboarding/types";
|
||||
import { ChannelPoliciesForm } from "@/features/database/channels-policy-form";
|
||||
import type { EventKind, OnboardingChannel, OnboardingNotificationPolicy } from "@/features/onboarding/types";
|
||||
import type { PolicyType } from "@/features/database/channels-policy.schema";
|
||||
|
||||
type NotificationsSectionProps = {
|
||||
initial: OnboardingNotificationPolicy[];
|
||||
notifiers: OnboardingChannel[];
|
||||
onSave: (policies: OnboardingNotificationPolicy[]) => Promise<void>;
|
||||
onBack: () => void;
|
||||
isPending: boolean;
|
||||
};
|
||||
|
||||
export const NotificationsSection = ({
|
||||
initial,
|
||||
notifiers,
|
||||
onSave,
|
||||
onBack,
|
||||
isPending,
|
||||
}: NotificationsSectionProps) => {
|
||||
const [policies, setPolicies] =
|
||||
useState<OnboardingNotificationPolicy[]>(initial);
|
||||
|
||||
const addPolicy = () =>
|
||||
setPolicies((prev) => [
|
||||
...prev,
|
||||
{ channelId: "", eventKinds: [], enabled: true },
|
||||
]);
|
||||
|
||||
const removePolicy = (index: number) =>
|
||||
setPolicies((prev) => prev.filter((_, i) => i !== index));
|
||||
|
||||
const updatePolicy = (
|
||||
index: number,
|
||||
patch: Partial<OnboardingNotificationPolicy>,
|
||||
) =>
|
||||
setPolicies((prev) =>
|
||||
prev.map((p, i) => (i === index ? { ...p, ...patch } : p)),
|
||||
);
|
||||
|
||||
const selectedChannelIds = policies.map((p) => p.channelId).filter(Boolean);
|
||||
|
||||
if (notifiers.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col items-center justify-center p-8 border border-dashed rounded-xl bg-muted/20 text-center gap-2">
|
||||
<Bell className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="font-medium text-sm">No notifiers configured</p>
|
||||
export const NotificationsSection = ({ initial, notifiers, onSave, isPending }: NotificationsSectionProps) => (
|
||||
<ChannelPoliciesForm
|
||||
channels={notifiers}
|
||||
defaultPolicies={initial as PolicyType[]}
|
||||
kind="notification"
|
||||
isPending={isPending}
|
||||
onSave={async (policies: PolicyType[]) =>
|
||||
onSave(
|
||||
policies.map((p) => ({
|
||||
channelId: p.channelId,
|
||||
eventKinds: (p.eventKinds ?? []) as EventKind[],
|
||||
enabled: p.enabled,
|
||||
})),
|
||||
)
|
||||
}
|
||||
noChannelsMessage={
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Go back and configure notifiers in the "Connect a
|
||||
notifier" step first.
|
||||
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";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { BackupRetentionSettingsForm } from "@/features/database/retention-policy-form";
|
||||
import { DEFAULT_RETENTION } from "@/features/onboarding/constants/db-settings";
|
||||
import type { OnboardingDbSettings } from "@/features/onboarding/types";
|
||||
|
||||
type RetentionSectionProps = {
|
||||
initial: OnboardingDbSettings["retention"];
|
||||
onSave: (
|
||||
value: NonNullable<OnboardingDbSettings["retention"]>,
|
||||
) => Promise<void>;
|
||||
onBack: () => void;
|
||||
onSave: (value: NonNullable<OnboardingDbSettings["retention"]>) => Promise<void>;
|
||||
isPending: boolean;
|
||||
};
|
||||
|
||||
export const RetentionSection = ({
|
||||
initial,
|
||||
onSave,
|
||||
onBack,
|
||||
isPending,
|
||||
}: RetentionSectionProps) => {
|
||||
const [settings, setSettings] = useState<
|
||||
NonNullable<OnboardingDbSettings["retention"]>
|
||||
>(initial ?? DEFAULT_RETENTION);
|
||||
|
||||
const totalFiles = () => {
|
||||
if (settings.type === "gfs") {
|
||||
return (
|
||||
settings.gfs.daily +
|
||||
settings.gfs.weekly +
|
||||
settings.gfs.monthly +
|
||||
settings.gfs.yearly
|
||||
export const RetentionSection = ({ initial, onSave, isPending }: RetentionSectionProps) => (
|
||||
<BackupRetentionSettingsForm
|
||||
defaultValues={initial ?? DEFAULT_RETENTION}
|
||||
isPending={isPending}
|
||||
onSave={async (values) =>
|
||||
onSave({
|
||||
type: values.type,
|
||||
count: values.count ?? DEFAULT_RETENTION.count,
|
||||
days: values.days ?? DEFAULT_RETENTION.days,
|
||||
gfs: {
|
||||
daily: values.gfs?.daily ?? DEFAULT_RETENTION.gfs.daily,
|
||||
weekly: values.gfs?.weekly ?? DEFAULT_RETENTION.gfs.weekly,
|
||||
monthly: values.gfs?.monthly ?? DEFAULT_RETENTION.gfs.monthly,
|
||||
yearly: values.gfs?.yearly ?? DEFAULT_RETENTION.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";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
BackupScheduleSelector,
|
||||
@@ -13,14 +12,12 @@ import type { OnboardingDbSettings } from "@/features/onboarding/types";
|
||||
type SchedulingSectionProps = {
|
||||
initial: Pick<OnboardingDbSettings, "backupMethod" | "backupCron">;
|
||||
onSave: (method: "manual" | "automatic", cron?: string) => Promise<void>;
|
||||
onBack: () => void;
|
||||
isPending: boolean;
|
||||
};
|
||||
|
||||
export const SchedulingSection = ({
|
||||
initial,
|
||||
onSave,
|
||||
onBack,
|
||||
isPending,
|
||||
}: SchedulingSectionProps) => {
|
||||
const [schedule, setSchedule] = useState<BackupScheduleValue>({
|
||||
@@ -32,10 +29,6 @@ export const SchedulingSection = ({
|
||||
<div className="flex flex-col gap-6">
|
||||
<BackupScheduleSelector value={schedule} onChange={setSchedule} />
|
||||
<div className="flex gap-2 pt-2">
|
||||
<Button type="button" variant="outline" onClick={onBack}>
|
||||
<ArrowLeft className="size-4 mr-1" />
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isPending}
|
||||
|
||||
@@ -1,192 +1,34 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { ArrowLeft, HardDrive, Plus, Trash2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { getChannelIcon } from "@/features/channel/channels-helpers";
|
||||
import type {
|
||||
OnboardingChannel,
|
||||
OnboardingStoragePolicy,
|
||||
} from "@/features/onboarding/types";
|
||||
import { ChannelPoliciesForm } from "@/features/database/channels-policy-form";
|
||||
import type { OnboardingChannel, OnboardingStoragePolicy } from "@/features/onboarding/types";
|
||||
import type { PolicyType } from "@/features/database/channels-policy.schema";
|
||||
|
||||
type StorageSectionProps = {
|
||||
initial: OnboardingStoragePolicy[];
|
||||
storages: OnboardingChannel[];
|
||||
onSave: (policies: OnboardingStoragePolicy[]) => Promise<void>;
|
||||
onBack: () => void;
|
||||
isPending: boolean;
|
||||
};
|
||||
|
||||
export const StorageSection = ({
|
||||
initial,
|
||||
storages,
|
||||
onSave,
|
||||
onBack,
|
||||
isPending,
|
||||
}: StorageSectionProps) => {
|
||||
const [policies, setPolicies] = useState<OnboardingStoragePolicy[]>(initial);
|
||||
|
||||
const addPolicy = () =>
|
||||
setPolicies((prev) => [...prev, { channelId: "", enabled: true }]);
|
||||
|
||||
const removePolicy = (index: number) =>
|
||||
setPolicies((prev) => prev.filter((_, i) => i !== index));
|
||||
|
||||
const updatePolicy = (
|
||||
index: number,
|
||||
patch: Partial<OnboardingStoragePolicy>,
|
||||
) =>
|
||||
setPolicies((prev) =>
|
||||
prev.map((p, i) => (i === index ? { ...p, ...patch } : p)),
|
||||
);
|
||||
|
||||
const selectedChannelIds = policies.map((p) => p.channelId).filter(Boolean);
|
||||
|
||||
if (storages.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col items-center justify-center p-8 border border-dashed rounded-xl bg-muted/20 text-center gap-2">
|
||||
<HardDrive className="h-8 w-8 text-muted-foreground/50" />
|
||||
<p className="font-medium text-sm">No storages configured</p>
|
||||
export const StorageSection = ({ initial, storages, onSave, isPending }: StorageSectionProps) => (
|
||||
<ChannelPoliciesForm
|
||||
channels={storages}
|
||||
defaultPolicies={initial}
|
||||
kind="storage"
|
||||
isPending={isPending}
|
||||
onSave={async (policies: PolicyType[]) =>
|
||||
onSave(
|
||||
policies.map((p) => ({
|
||||
channelId: p.channelId,
|
||||
enabled: p.enabled,
|
||||
})),
|
||||
)
|
||||
}
|
||||
noChannelsMessage={
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Go back and configure storages in the "Connect a storage"
|
||||
step first.
|
||||
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,
|
||||
name,
|
||||
config,
|
||||
organizationId: orgId ?? null,
|
||||
};
|
||||
const storages = [
|
||||
...((state?.context.flowData.storages ?? []) as OnboardingChannel[]),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useOnboarding } from "@onboardjs/react";
|
||||
import { toast } from "sonner";
|
||||
import {
|
||||
createOrganizationAction,
|
||||
getMyOrganizationAction,
|
||||
updateOrganizationAction,
|
||||
} from "@/features/organizations/organization.action";
|
||||
import { slugify } from "@/utils/slugify";
|
||||
@@ -16,7 +17,18 @@ export const useCreateOrg = () => {
|
||||
mutationFn: async (name: string) => {
|
||||
const trimmed = name.trim();
|
||||
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) {
|
||||
const result = await updateOrganizationAction({
|
||||
@@ -25,23 +37,34 @@ export const useCreateOrg = () => {
|
||||
});
|
||||
const updateData = result?.data;
|
||||
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({
|
||||
flowData: { ...state?.context.flowData, org: { id: existingOrg.id, name: trimmed } },
|
||||
flowData: {
|
||||
...state?.context.flowData,
|
||||
org: { id: existingOrg.id, name: trimmed },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const result = await createOrganizationAction({ name: trimmed });
|
||||
const createData = result?.data;
|
||||
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;
|
||||
if (!org) throw new Error("Failed to create organisation");
|
||||
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();
|
||||
},
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
|
||||
@@ -9,13 +9,13 @@ import {
|
||||
} from "@/features/projects/projects.action";
|
||||
import type { OnboardingProjectData } from "@/features/onboarding/types";
|
||||
|
||||
type ProjectInput = { name: string; description: string; databaseIds: string[] };
|
||||
type ProjectInput = { name: string; databaseIds: string[] };
|
||||
|
||||
export const useCreateProject = () => {
|
||||
const { state, updateContext } = useOnboarding();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async ({ name, description, databaseIds }: ProjectInput) => {
|
||||
mutationFn: async ({ name, databaseIds }: ProjectInput) => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) throw new Error("Project name is required");
|
||||
const orgId = (state?.context.flowData.org as any)?.id as string | undefined;
|
||||
@@ -35,7 +35,7 @@ export const useCreateProject = () => {
|
||||
await updateContext({
|
||||
flowData: {
|
||||
...state?.context.flowData,
|
||||
project: { id: existingProject.id, name: trimmed, description, databaseIds },
|
||||
project: { id: existingProject.id, name: trimmed, databaseIds },
|
||||
},
|
||||
});
|
||||
} else {
|
||||
@@ -52,7 +52,7 @@ export const useCreateProject = () => {
|
||||
await updateContext({
|
||||
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";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
@@ -12,16 +11,21 @@ export const useRemoveNotifier = () => {
|
||||
|
||||
return useMutation({
|
||||
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({
|
||||
organizationId: orgId,
|
||||
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 = (
|
||||
(state?.context.flowData.notifiers ?? []) as OnboardingChannel[]
|
||||
).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),
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// src/features/onboarding/hooks/use-remove-storage.ts
|
||||
"use client";
|
||||
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
@@ -12,13 +11,21 @@ export const useRemoveStorage = () => {
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async (id: string) => {
|
||||
const orgId = (state?.context.flowData.org as any)?.id as string | undefined;
|
||||
const result = await removeStorageChannelAction({ organizationId: orgId, id });
|
||||
if (result?.data?.success === false) throw new Error("Failed to remove storage");
|
||||
const orgId = (state?.context.flowData.org as any)?.id as
|
||||
| string
|
||||
| undefined;
|
||||
const result = await removeStorageChannelAction({
|
||||
organizationId: orgId,
|
||||
id,
|
||||
});
|
||||
if (result?.data?.success === false)
|
||||
throw new Error("Failed to remove storage");
|
||||
const storages = (
|
||||
(state?.context.flowData.storages ?? []) as OnboardingChannel[]
|
||||
).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),
|
||||
});
|
||||
|
||||
@@ -28,7 +28,7 @@ export const OnboardingShell = () => {
|
||||
|
||||
const isGoingBack = currentIndex < latestIndex;
|
||||
|
||||
const BLOCKED_STEPS = ["login", "account-info", "security"];
|
||||
const BLOCKED_STEPS = ["security"];
|
||||
const prevStepId = STEP_ORDER[currentIndex - 1] ?? "";
|
||||
const canGoBack =
|
||||
!BLOCKED_STEPS.includes(currentStepId) &&
|
||||
@@ -38,7 +38,7 @@ export const OnboardingShell = () => {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground flex flex-col items-center justify-center p-4 gap-4">
|
||||
<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">
|
||||
<OnboardingStepper />
|
||||
<div className="flex-1">{renderStep()}</div>
|
||||
@@ -60,13 +60,20 @@ export const OnboardingShell = () => {
|
||||
prevId = "storage";
|
||||
}
|
||||
} else if (currentStepId === "finish") {
|
||||
const agents = (state.context.flowData.agents as any[]) || [];
|
||||
const agents =
|
||||
(state.context.flowData.agents as any[]) || [];
|
||||
if (agents.length === 0) {
|
||||
prevId = "agent-create";
|
||||
} else {
|
||||
const isAgentConnected = agents.some((a) => a.connected);
|
||||
const databaseIds = (state.context.flowData.project as any)?.databaseIds || [];
|
||||
const databaseIds =
|
||||
(state.context.flowData.project as any)?.databaseIds ||
|
||||
[];
|
||||
if (!isAgentConnected || databaseIds.length === 0) {
|
||||
prevId = "project-create";
|
||||
}
|
||||
}
|
||||
}
|
||||
if (prevId) goToStep(prevId);
|
||||
}}
|
||||
disabled={!canGoBack || state.isLoading}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
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 { getSettings } from "@/db/services/setting";
|
||||
import { hasUsers } from "@/db/services/user";
|
||||
import { getUserOrganization } from "@/db/services/organization";
|
||||
import { getOrganizationProject } from "@/db/services/project";
|
||||
import { getOrganizationAgents } from "@/db/services/agent";
|
||||
import { getDatabasesSettings } from "@/db/services/database";
|
||||
import { getOrganizationChannels } from "@/db/services/notification-channel";
|
||||
import { getOrganizationStorageChannels } from "@/db/services/storage-channel";
|
||||
import type { AgentWith } from "@/db/schema/08_agent";
|
||||
@@ -51,11 +56,23 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
|
||||
return { stepId: "login", flowData: { meta } };
|
||||
}
|
||||
|
||||
const org = await getUserOrganization(user.id);
|
||||
let org = await getUserOrganization(user.id);
|
||||
if (!org) {
|
||||
const defaultOrg = await db.query.organization.findFirst({
|
||||
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 };
|
||||
|
||||
@@ -73,6 +90,7 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
|
||||
label: n.provider,
|
||||
name: n.name,
|
||||
config: (n.config as Record<string, unknown>) ?? {},
|
||||
organizationId: n.organizationId ?? null,
|
||||
}));
|
||||
|
||||
const storages = storageChannels.map((s) => ({
|
||||
@@ -81,11 +99,14 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
|
||||
label: s.provider,
|
||||
name: s.name,
|
||||
config: (s.config as Record<string, unknown>) ?? {},
|
||||
organizationId: s.organizationId ?? null,
|
||||
}));
|
||||
|
||||
const defaults = {
|
||||
notifierId: settings?.defaultNotificationChannelId ?? undefined,
|
||||
storageId: settings?.defaultStorageChannelId ?? undefined,
|
||||
avatarMode: settings?.avatarMode ?? "internal",
|
||||
dicebearStyle: settings?.dicebearStyle ?? "thumbs",
|
||||
};
|
||||
|
||||
const agentData = await Promise.all(
|
||||
@@ -94,7 +115,7 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
|
||||
name: a.name,
|
||||
edgeKey: await generateEdgeKey(getServerUrl(), a.id),
|
||||
connected: !!a.lastContact,
|
||||
}))
|
||||
})),
|
||||
);
|
||||
|
||||
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> = {
|
||||
meta,
|
||||
org: orgData,
|
||||
@@ -115,13 +138,15 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
|
||||
defaults,
|
||||
agents: agentData,
|
||||
databases,
|
||||
dbSettings,
|
||||
...(project
|
||||
? {
|
||||
project: {
|
||||
id: project.id,
|
||||
name: project.name,
|
||||
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;
|
||||
|
||||
// Has project → late stage (project was created after agent-key)
|
||||
if (project) {
|
||||
if (!hasAgents) {
|
||||
// Project without agents: missed earlier steps
|
||||
if (notifiers.length === 0) {
|
||||
meta.resumeStepId = "notifier";
|
||||
return { stepId: "notifier", flowData: fullData };
|
||||
@@ -151,11 +174,16 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
|
||||
return { stepId: "agent-key", flowData: fullData };
|
||||
}
|
||||
|
||||
meta.resumeStepId = "finish";
|
||||
return { stepId: "finish", flowData: fullData };
|
||||
const projectDatabaseIds: string[] =
|
||||
(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) {
|
||||
const agentHasPinged = !!agents[0]?.lastContact;
|
||||
const stepId = agentHasPinged ? "project-create" : "agent-key";
|
||||
@@ -163,7 +191,6 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
|
||||
return { stepId, flowData: fullData };
|
||||
}
|
||||
|
||||
// No agents, no project → check earlier steps in order
|
||||
if (notifiers.length === 0) {
|
||||
meta.resumeStepId = "notifier";
|
||||
return { stepId: "notifier", flowData: fullData };
|
||||
|
||||
@@ -120,14 +120,14 @@ export const onboardingSteps: OnboardingStep[] = [
|
||||
isSkippable: true,
|
||||
skipToStep: (ctx: any) => {
|
||||
const agents = (ctx.flowData?.agents as any[]) || [];
|
||||
if (agents.length === 0) return "agent-create";
|
||||
if (agents.length === 0) return "finish";
|
||||
const isAgentConnected = agents.some((a) => a.connected);
|
||||
const databaseIds = (ctx.flowData?.project?.databaseIds as string[]) || [];
|
||||
return !isAgentConnected || databaseIds.length === 0 ? "finish" : "db-settings";
|
||||
},
|
||||
nextStep: (ctx: any) => {
|
||||
const agents = (ctx.flowData?.agents as any[]) || [];
|
||||
if (agents.length === 0) return "agent-create";
|
||||
if (agents.length === 0) return "finish";
|
||||
const isAgentConnected = agents.some((a) => a.connected);
|
||||
const databaseIds = (ctx.flowData?.project?.databaseIds as string[]) || [];
|
||||
return !isAgentConnected || databaseIds.length === 0 ? "finish" : "db-settings";
|
||||
|
||||
@@ -1,17 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useOnboarding } from "@onboardjs/react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Server } from "lucide-react";
|
||||
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 { cn } from "@/lib/utils";
|
||||
|
||||
export const StepAgentKey = () => {
|
||||
const { next, state } = useOnboarding();
|
||||
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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Connect your agent</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
@@ -19,33 +24,40 @@ export const StepAgentKey = () => {
|
||||
agent to connect.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{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) => (
|
||||
<AgentKeyBlock key={agent.id} agent={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()}>
|
||||
I've run the command →
|
||||
</Button>
|
||||
</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";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useOnboarding } from "@onboardjs/react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useAgentStatus } from "@/features/onboarding/hooks/use-agent-status";
|
||||
import type { OnboardingAgent } from "@/features/onboarding/types";
|
||||
|
||||
export const StepAgentWaiting = () => {
|
||||
const { next, state } = useOnboarding();
|
||||
const { state } = useOnboarding();
|
||||
const router = useRouter();
|
||||
const agents = (state?.context.flowData.agents ?? []) as OnboardingAgent[];
|
||||
const { data, isLoading } = useAgentStatus();
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.connected) {
|
||||
next();
|
||||
router.refresh();
|
||||
}
|
||||
}, [data?.connected, next]);
|
||||
}, [data?.connected, router]);
|
||||
|
||||
if (isLoading || data?.connected) return null;
|
||||
|
||||
|
||||
@@ -52,11 +52,11 @@ export const StepDbSettings = () => {
|
||||
case "retention":
|
||||
return !!s.retention;
|
||||
case "scheduling":
|
||||
return s.backupMethod !== undefined;
|
||||
return s.backupMethod === "automatic";
|
||||
case "notifications":
|
||||
return s.notificationPolicies !== undefined;
|
||||
return (s.notificationPolicies?.length ?? 0) > 0;
|
||||
case "storage":
|
||||
return s.storagePolicies !== undefined;
|
||||
return (s.storagePolicies?.length ?? 0) > 0;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -131,7 +131,7 @@ export const StepDbSettings = () => {
|
||||
getDb={getDb}
|
||||
isDbConfigured={isDbConfigured}
|
||||
onSelectDb={(dbId) => setPhase({ kind: "db", dbId })}
|
||||
onContinue={next}
|
||||
onContinue={() => next()}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { HardDrive } from "lucide-react";
|
||||
import { useOnboarding } from "@onboardjs/react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
@@ -12,12 +11,17 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
import type {
|
||||
AvatarMode,
|
||||
OnboardingChannel,
|
||||
OnboardingDefaultsData,
|
||||
} from "@/features/onboarding/types";
|
||||
import { getChannelIcon } from "@/features/channel/channels-helpers";
|
||||
import { updateNotificationSettingsAction } from "@/features/settings/notification.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 = () => {
|
||||
const { next, updateContext, state } = useOnboarding();
|
||||
@@ -27,12 +31,19 @@ export const StepDefaults = () => {
|
||||
[]) as OnboardingChannel[];
|
||||
const existingDefaults = (state?.context.flowData.defaults ??
|
||||
{}) as OnboardingDefaultsData;
|
||||
|
||||
const [notifierId, setNotifierId] = useState<string | undefined>(
|
||||
existingDefaults.notifierId || undefined,
|
||||
);
|
||||
const [storageId, setStorageId] = useState<string | 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) => {
|
||||
setNotifierId(value);
|
||||
@@ -43,22 +54,12 @@ export const StepDefaults = () => {
|
||||
await updateContext({
|
||||
flowData: {
|
||||
...state?.context.flowData,
|
||||
defaults: { notifierId: value, storageId },
|
||||
defaults: { notifierId: value, storageId, avatarMode },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const selectStorage = async (value: string) => {
|
||||
if (value === "filesystem") {
|
||||
setStorageId(undefined);
|
||||
await updateContext({
|
||||
flowData: {
|
||||
...state?.context.flowData,
|
||||
defaults: { notifierId, storageId: undefined },
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
setStorageId(value);
|
||||
await updateStorageSettingsAction({
|
||||
name: "system",
|
||||
@@ -67,7 +68,37 @@ export const StepDefaults = () => {
|
||||
await updateContext({
|
||||
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({
|
||||
flowData: {
|
||||
...state?.context.flowData,
|
||||
defaults: { notifierId, storageId },
|
||||
defaults: { notifierId, storageId, avatarMode, dicebearStyle },
|
||||
},
|
||||
});
|
||||
await next();
|
||||
};
|
||||
|
||||
const selectedNotifier = notifiers.find((n) => n.id === notifierId);
|
||||
const selectedStorage = storages.find((s) => s.id === storageId);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Set your defaults</h1>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Default notifier</Label>
|
||||
<Select
|
||||
value={
|
||||
notifiers.some((n) => n.id === notifierId) ? notifierId : undefined
|
||||
}
|
||||
value={selectedNotifier ? notifierId : undefined}
|
||||
onValueChange={selectNotifier}
|
||||
disabled={notifiers.length === 0}
|
||||
>
|
||||
@@ -106,43 +139,88 @@ export const StepDefaults = () => {
|
||||
? "No notifier connected"
|
||||
: "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>
|
||||
<SelectContent>
|
||||
{notifiers.map((n) => (
|
||||
<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>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Default storage</Label>
|
||||
<Select
|
||||
value={
|
||||
storages.some((s) => s.id === storageId) ? storageId : "filesystem"
|
||||
}
|
||||
value={selectedStorage ? storageId : undefined}
|
||||
onValueChange={selectStorage}
|
||||
disabled={storages.length === 0}
|
||||
>
|
||||
<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>
|
||||
<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) => (
|
||||
<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>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<AvatarModeSelector value={avatarMode} onChange={selectAvatarMode} />
|
||||
|
||||
{avatarMode === "dicebear" && (
|
||||
<DicebearStylePicker value={dicebearStyle} onChange={selectDicebearStyle} />
|
||||
)}
|
||||
|
||||
<Button type="button" onClick={onContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
|
||||
@@ -1,16 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useOnboarding } from "@onboardjs/react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import confetti from "canvas-confetti";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useMarkOnboardingDone } from "@/features/onboarding/hooks/use-mark-onboarding-done";
|
||||
|
||||
export const StepFinish = () => {
|
||||
const { next } = useOnboarding();
|
||||
const router = useRouter();
|
||||
const fired = useRef(false);
|
||||
const mutation = useMarkOnboardingDone();
|
||||
const [isRedirecting, setIsRedirecting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (fired.current) return;
|
||||
@@ -27,10 +28,11 @@ export const StepFinish = () => {
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={mutation.isPending}
|
||||
disabled={mutation.isPending || isRedirecting}
|
||||
onClick={async () => {
|
||||
await mutation.mutateAsync();
|
||||
await next();
|
||||
setIsRedirecting(true);
|
||||
router.push("/dashboard/home");
|
||||
}}
|
||||
>
|
||||
Go to dashboard
|
||||
|
||||
@@ -5,10 +5,7 @@ import { useTheme } from "next-themes";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import type {
|
||||
OnboardingAccountData,
|
||||
OnboardingMeta,
|
||||
} from "@/features/onboarding/types";
|
||||
import type { OnboardingAccountData } from "@/features/onboarding/types";
|
||||
import { ThemeKey, ThemeSelector } from "@/components/common/theme-selector";
|
||||
|
||||
const AVATAR_COLORS = [
|
||||
|
||||
@@ -5,7 +5,6 @@ import { useOnboarding } from "@onboardjs/react";
|
||||
import { Check, Database, Loader2 } from "lucide-react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useCreateProject } from "@/features/onboarding/hooks/use-create-project";
|
||||
import type {
|
||||
@@ -23,9 +22,6 @@ export const StepProjectCreate = () => {
|
||||
const isUpdateMode = !!existingProject;
|
||||
|
||||
const [name, setName] = useState(existingProject?.name ?? "");
|
||||
const [description, setDescription] = useState(
|
||||
existingProject?.description ?? "",
|
||||
);
|
||||
const [databaseIds, setDatabaseIds] = useState<string[]>(
|
||||
existingProject?.databaseIds ?? [],
|
||||
);
|
||||
@@ -40,7 +36,7 @@ export const StepProjectCreate = () => {
|
||||
: [...databaseIds, id];
|
||||
setDatabaseIds(newDbIds);
|
||||
mutation.mutate(
|
||||
{ name: name || "My project", description, databaseIds: newDbIds },
|
||||
{ name: name || "My project", databaseIds: newDbIds },
|
||||
{ onSettled: () => setLoadingDbId(null) }
|
||||
);
|
||||
};
|
||||
@@ -64,19 +60,10 @@ export const StepProjectCreate = () => {
|
||||
placeholder="My project"
|
||||
/>
|
||||
</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 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<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) => {
|
||||
const isSelected = databaseIds.includes(db.id);
|
||||
const isCurrentLoading = loadingDbId === db.id;
|
||||
@@ -124,7 +111,7 @@ export const StepProjectCreate = () => {
|
||||
type="button"
|
||||
onClick={() =>
|
||||
mutation.mutate(
|
||||
{ name: name || "My project", description, databaseIds },
|
||||
{ name: name || "My project", databaseIds },
|
||||
{ onSuccess: () => next() },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,64 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { useOnboarding } from "@onboardjs/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";
|
||||
|
||||
export const StepSecurity = () => {
|
||||
const { next, updateContext, state } = useOnboarding();
|
||||
const meta = state?.context.flowData.meta as OnboardingMeta | undefined;
|
||||
const passkeyEnabled = meta?.passkeyEnabled ?? false;
|
||||
const alreadySecured = !!state?.context.flowData.security;
|
||||
|
||||
useEffect(() => {
|
||||
if (alreadySecured) next();
|
||||
}, [alreadySecured]);
|
||||
const [phase, setPhase] = useState<"choose" | "two-factor">("choose");
|
||||
|
||||
const choose = async (method: "passkey" | "two-factor") => {
|
||||
await updateContext({ flowData: { ...state?.context.flowData, security: { method } } });
|
||||
const { mutate: addPasskey, isPending: isAddingPasskey } = useMutation({
|
||||
mutationFn: async () => {
|
||||
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 (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Set up two-factor</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Add an extra layer of security to your account.
|
||||
</p>
|
||||
</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">
|
||||
{passkeyEnabled
|
||||
? "Set up a passkey for faster, safer sign-in."
|
||||
: "Set up two-factor authentication to protect your account."}
|
||||
Choose a method to protect your account.
|
||||
</p>
|
||||
</div>
|
||||
{passkeyEnabled ? (
|
||||
|
||||
{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"
|
||||
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">
|
||||
<KeyRound className="size-4 text-muted-foreground" />
|
||||
{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</span>
|
||||
<span className="text-xs text-muted-foreground">Faster, safer sign-in with biometrics</span>
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => choose("two-factor")}
|
||||
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</span>
|
||||
<span className="text-xs text-muted-foreground">Add an extra layer of security</span>
|
||||
<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,6 +161,7 @@ export const StepStorage = () => {
|
||||
<span className="flex-1 truncate">
|
||||
{ch.name} <span className="opacity-60">({ch.label})</span>
|
||||
</span>
|
||||
{ch.organizationId !== null && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeStorage.mutate(ch.id)}
|
||||
@@ -169,6 +170,7 @@ export const StepStorage = () => {
|
||||
>
|
||||
<X className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -40,11 +40,16 @@ export type OnboardingChannel = {
|
||||
label: string;
|
||||
name: string;
|
||||
config: Record<string, unknown>;
|
||||
organizationId?: string | null;
|
||||
};
|
||||
|
||||
export type AvatarMode = 'internal' | 'gravatar' | 'dicebear';
|
||||
|
||||
export type OnboardingDefaultsData = {
|
||||
notifierId?: string;
|
||||
storageId?: string;
|
||||
avatarMode?: AvatarMode;
|
||||
dicebearStyle?: string;
|
||||
};
|
||||
|
||||
export type OnboardingAgent = {
|
||||
|
||||
@@ -10,6 +10,15 @@ import {auth, checkSlugOrganization, createOrganization} from "@/lib/auth/auth";
|
||||
import {slugify} from "@/utils/slugify";
|
||||
import {Organization} from "@/db/schema/03_organization";
|
||||
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>> => {
|
||||
try {
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { z } from "zod";
|
||||
|
||||
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({
|
||||
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()
|
||||
.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'),
|
||||
users: z.array(z.string()),
|
||||
});
|
||||
|
||||
@@ -8,13 +8,18 @@ import {updateImageUserAction} from "@/features/profile/avatar.action";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {User} from "@/db/schema/02_user";
|
||||
import React, {ChangeEvent} from "react";
|
||||
import type {AvatarMode} from "@/features/onboarding/types";
|
||||
|
||||
export type AvatarWithUploadProps = {
|
||||
user: User;
|
||||
avatarMode?: AvatarMode;
|
||||
avatarUrl?: string;
|
||||
};
|
||||
|
||||
export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
|
||||
const user = props.user;
|
||||
const canUpload = !props.avatarMode || props.avatarMode === "internal";
|
||||
const src = props.avatarUrl ?? user.image ?? undefined;
|
||||
const router = useRouter();
|
||||
|
||||
const submitImage = useMutation({
|
||||
@@ -71,10 +76,11 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
|
||||
<div className="relative ">
|
||||
|
||||
<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>
|
||||
</Avatar>
|
||||
|
||||
{canUpload && (
|
||||
<div
|
||||
onClick={() => {
|
||||
const fileInput = document.createElement("input");
|
||||
@@ -88,6 +94,7 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
|
||||
>
|
||||
<UploadIcon className="w-12 h-12 lg:w-16 lg:h-16 text-primary"/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -22,12 +22,15 @@ import {updateProfileSettingsAction} from "./profile.action";
|
||||
import {User} from "@/db/schema/02_user";
|
||||
import {ProfileSchema, ProfileSchemaType} from "./general.schema";
|
||||
import {AvatarWithUpload} from "@/features/profile/avatar-with-upload";
|
||||
import type { AvatarMode } from "@/features/onboarding/types";
|
||||
|
||||
interface ProfileGeneralProps {
|
||||
user: User;
|
||||
avatarMode?: AvatarMode;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
export function ProfileGeneral({user}: ProfileGeneralProps) {
|
||||
export function ProfileGeneral({user, avatarMode, avatarUrl}: ProfileGeneralProps) {
|
||||
const router = useRouter();
|
||||
|
||||
const profileForm = useZodForm({
|
||||
@@ -63,6 +66,8 @@ export function ProfileGeneral({user}: ProfileGeneralProps) {
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<AvatarWithUpload
|
||||
user={user}
|
||||
avatarMode={avatarMode}
|
||||
avatarUrl={avatarUrl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,36 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import React, {useState} from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {Form, FormControl, FormField, FormItem, FormLabel, FormMessage, useZodForm} from "@/components/ui/form";
|
||||
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>;
|
||||
import { TwoFactorSetupContent } from "./two-factor-setup-content";
|
||||
|
||||
type Setup2FAModalProps = {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -39,84 +19,15 @@ type Setup2FAModalProps = {
|
||||
};
|
||||
|
||||
export function Setup2FAProfileProviderModal({ onOpenChange, open, disabled }: Setup2FAModalProps) {
|
||||
|
||||
const router = useRouter();
|
||||
const [step, setStep] = useState<"PASSWORD" | "QR" | "BACKUP">("PASSWORD");
|
||||
|
||||
const [totpURI, setTotpURI] = useState<string>("");
|
||||
const [secret, setSecret] = useState<string>("");
|
||||
const [backupCodes, setBackupCodes] = useState<string[]>([]);
|
||||
|
||||
const form = 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.");
|
||||
form.reset();
|
||||
},
|
||||
});
|
||||
|
||||
const handleCopySecret = () => {
|
||||
navigator.clipboard.writeText(secret);
|
||||
toast.success("Secret copied to clipboard");
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
const handleSuccess = () => {
|
||||
router.refresh();
|
||||
onOpenChange(false);
|
||||
setStep("PASSWORD");
|
||||
form.reset();
|
||||
passwordForm.reset();
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => (!v ? handleClose() : onOpenChange(v))}>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogTrigger asChild disabled={disabled}>
|
||||
<Button variant="outline" size="sm">
|
||||
<ShieldCheck className="w-4 h-4 mr-2" />
|
||||
@@ -126,130 +37,8 @@ export function Setup2FAProfileProviderModal({onOpenChange, open, disabled}: Set
|
||||
<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>
|
||||
)}
|
||||
<TwoFactorSetupContent onSuccess={handleSuccess} />
|
||||
</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"
|
||||
variant="inverted"
|
||||
animation={2}
|
||||
modalPopover={true}
|
||||
/>
|
||||
</FormControl>
|
||||
<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;
|
||||
}
|
||||
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();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,11 +7,12 @@ import {Setting} from "@/db/schema/01_setting";
|
||||
import {SettingsEmailSection} from "@/features/settings/email-section";
|
||||
import {SettingsStorageSection} from "@/features/settings/storage-section";
|
||||
import {StorageChannelWith} from "@/db/schema/12_storage-channel";
|
||||
import {AlarmClock, MailboxIcon, Save} from "lucide-react";
|
||||
import {AlarmClock, MailboxIcon, Save, UserCircle} from "lucide-react";
|
||||
import {
|
||||
SettingsNotificationSection
|
||||
} from "@/features/settings/notification-section";
|
||||
import {NotificationChannelWith} from "@/db/schema/09_notification-channel";
|
||||
import {SettingsAvatarSection} from "@/features/settings/avatar-section";
|
||||
|
||||
export type SettingsTabsProps = {
|
||||
settings: Setting
|
||||
@@ -63,6 +64,14 @@ export const SettingsTabs = ({settings, storageChannels, notificationChannels}:
|
||||
<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 = {
|
||||
users: User[];
|
||||
isPasswordAuthEnabled: boolean;
|
||||
avatarUrls?: Record<string, string | undefined>;
|
||||
};
|
||||
|
||||
export const AdminUserList = ({ users, isPasswordAuthEnabled }: AdminUserListProps) => {
|
||||
return <DataTable columns={usersListColumns({ isPasswordAuthEnabled })} data={users} enablePagination={true} enableSelect={false} />;
|
||||
export const AdminUserList = ({ users, isPasswordAuthEnabled, avatarUrls }: AdminUserListProps) => {
|
||||
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 = {
|
||||
isPasswordAuthEnabled: boolean;
|
||||
avatarUrls?: Record<string, string | undefined>;
|
||||
}
|
||||
|
||||
export function usersListColumns({ isPasswordAuthEnabled }: UsersListColumnsProps): ColumnDef<User>[] {
|
||||
export function usersListColumns({ isPasswordAuthEnabled, avatarUrls }: UsersListColumnsProps): ColumnDef<User>[] {
|
||||
|
||||
return [
|
||||
{
|
||||
@@ -27,7 +28,7 @@ export function usersListColumns({ isPasswordAuthEnabled }: UsersListColumnsProp
|
||||
<TooltipTrigger>
|
||||
<div className="flex flex-row items-center gap-x-2">
|
||||
<Avatar>
|
||||
<AvatarImage src={row.original.image ?? ""} alt={row.original.name}/>
|
||||
<AvatarImage src={avatarUrls?.[row.original.id] ?? row.original.image ?? ""} alt={row.original.name}/>
|
||||
<AvatarFallback>
|
||||
{row.original.name
|
||||
.split(" ")
|
||||
|
||||
+12
-11
@@ -1,24 +1,25 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const zString = () =>
|
||||
z.string();
|
||||
export const zString = () => 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 = () =>
|
||||
z.preprocess(
|
||||
(arg) => {
|
||||
z.preprocess((arg) => {
|
||||
if (typeof arg === "string" || arg instanceof Date) {
|
||||
const date = new Date(arg);
|
||||
return isNaN(date.getTime()) ? undefined : date;
|
||||
}
|
||||
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 {
|
||||
const localPart = email.split("@")[0];
|
||||
const nameParts = localPart
|
||||
.replace(/[_\.\-]/g, " ") // Replace underscores, dots, and hyphens with spaces
|
||||
.split(" ") // Split into parts
|
||||
.filter(Boolean); // Remove empty strings
|
||||
.replace(/[_\.\-]/g, " ")
|
||||
.split(" ")
|
||||
.filter(Boolean);
|
||||
|
||||
return nameParts
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1)) // Capitalize each part
|
||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
||||
.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;
|
||||
}
|
||||
+21
-25
@@ -1,15 +1,13 @@
|
||||
import {promises as fs} from 'fs';
|
||||
import path from 'path';
|
||||
import {generateKeyPair} from 'crypto';
|
||||
import {promisify} from 'util';
|
||||
import {randomBytes} from 'crypto';
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { generateKeyPair } from "crypto";
|
||||
import { promisify } from "util";
|
||||
import { randomBytes } from "crypto";
|
||||
import { env } from "@/env.mjs";
|
||||
import { logger } from "@/lib/logger";
|
||||
|
||||
const log = logger.child({ module: "rsa-keys" });
|
||||
|
||||
|
||||
|
||||
const generateKeyPairAsync = promisify(generateKeyPair);
|
||||
|
||||
/**
|
||||
@@ -19,24 +17,25 @@ const generateKeyPairAsync = promisify(generateKeyPair);
|
||||
* @param {string} [dir] path to directory
|
||||
* @returns {Promise<{privateKeyPath:string, publicKeyPath:string}>}
|
||||
*/
|
||||
export async function generateRSAKeys(dir = path.join(env.PRIVATE_PATH!, '/keys')) {
|
||||
export async function generateRSAKeys(
|
||||
dir = path.join(env.PRIVATE_PATH!, "/keys"),
|
||||
) {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
|
||||
const privateKeyPath = path.join(dir, 'server_private.pem');
|
||||
const publicKeyPath = path.join(dir, 'server_public.pem');
|
||||
const privateKeyPath = path.join(dir, "server_private.pem");
|
||||
const publicKeyPath = path.join(dir, "server_public.pem");
|
||||
|
||||
try {
|
||||
await fs.access(privateKeyPath);
|
||||
await fs.access(publicKeyPath);
|
||||
log.info('RSA keys already exist. Skipping generation.');
|
||||
log.info("RSA keys already exist. Skipping generation.");
|
||||
return { privateKeyPath, publicKeyPath };
|
||||
} catch {
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const {publicKey, privateKey} = await generateKeyPairAsync('rsa', {
|
||||
const { publicKey, privateKey } = await generateKeyPairAsync("rsa", {
|
||||
modulusLength: 2048,
|
||||
publicKeyEncoding: {type: 'pkcs1', format: 'pem'},
|
||||
privateKeyEncoding: {type: 'pkcs1', format: 'pem'},
|
||||
publicKeyEncoding: { type: "pkcs1", format: "pem" },
|
||||
privateKeyEncoding: { type: "pkcs1", format: "pem" },
|
||||
});
|
||||
|
||||
await fs.writeFile(privateKeyPath, privateKey, { mode: 0o600 });
|
||||
@@ -45,7 +44,6 @@ export async function generateRSAKeys(dir = path.join(env.PRIVATE_PATH!, '/keys'
|
||||
return { privateKeyPath, publicKeyPath };
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Generate a 256-bit AES master key for AES-256-GCM.
|
||||
* - 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
|
||||
* @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 });
|
||||
|
||||
try {
|
||||
const existing = await fs.readFile(filePath);
|
||||
log.info('Master key already exists. Skipping generation.');
|
||||
log.info("Master key already exists. Skipping generation.");
|
||||
return existing;
|
||||
} catch {
|
||||
// File does not exist, generate
|
||||
}
|
||||
} catch {}
|
||||
|
||||
const key = randomBytes(32); // 256-bit key
|
||||
const key = randomBytes(32);
|
||||
|
||||
await fs.writeFile(filePath, key, { mode: 0o600 });
|
||||
log.info("Master key already exists. Skipping generation.");
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
export const slugify = (text: string) => {
|
||||
return text
|
||||
.toString()
|
||||
.normalize('NFKD') // Normalize accents
|
||||
.replace(/[\u0300-\u036f]/g, '') // Remove diacritics
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/\_/g, '-') // _ → dash
|
||||
.replace(/\s+/g, '-') // spaces → dash
|
||||
.replace(/[^\w\-]+/g, '') // Remove non-word chars
|
||||
.replace(/\-\-+/g, '-') // multiple dashes → one
|
||||
.replace(/^-+/, '') // Remove leading dash
|
||||
.replace(/-+$/, ''); // Remove trailing dash
|
||||
.replace(/\_/g, "-")
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/[^\w\-]+/g, "")
|
||||
.replace(/\-\-+/g, "-")
|
||||
.replace(/^-+/, "")
|
||||
.replace(/-+$/, "");
|
||||
};
|
||||
+5
-7
@@ -10,7 +10,9 @@ export function capitalizeFirstLetter(text: string): 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 {
|
||||
@@ -56,14 +58,10 @@ export function formatDuration(ms: number): string {
|
||||
const hours = totalHours % 24;
|
||||
|
||||
if (totalHours < 24) {
|
||||
return minutes > 0
|
||||
? `${totalHours} h ${minutes} min`
|
||||
: `${totalHours} h`;
|
||||
return minutes > 0 ? `${totalHours} h ${minutes} min` : `${totalHours} h`;
|
||||
}
|
||||
|
||||
const days = Math.floor(totalHours / 24);
|
||||
|
||||
return hours > 0
|
||||
? `${days} d ${hours} h`
|
||||
: `${days} d`;
|
||||
return hours > 0 ? `${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