This commit is contained in:
Théo LAGACHE
2026-06-22 16:56:17 +02:00
parent bd157e7d49
commit fd41f7ea50
95 changed files with 9511 additions and 3458 deletions
+3
View File
@@ -81,3 +81,6 @@ TRUSTED_DOMAINS="http://localhost:8887, http://localhost:3055, http://localhost:
# Default to false
#TUSD_BEHIND_PROXY=true
#
#SKIP_ONBOARDING=false
+3 -2
View File
@@ -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>
);
@@ -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 havent added any databases to this project yet.</p>
<p className="text-sm mt-2">
You havent added any databases to this project yet.
</p>
</div>
)}
</PageContent>
+3 -2
View File
@@ -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");
}
+3 -2
View File
@@ -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");
}
+1
View File
@@ -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 },
);
}
});
},
);
+52 -38
View File
@@ -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 },
);
}
}
+132 -79
View File
@@ -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;
}
+51 -43
View File
@@ -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 },
);
}
}
+8 -2
View File
@@ -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
View File
@@ -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
+14
View File
@@ -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
}
]
}
+5 -1
View File
@@ -1,4 +1,6 @@
import {boolean, pgTable, uuid, varchar} from "drizzle-orm/pg-core";
import {boolean, pgEnum, pgTable, uuid, varchar} from "drizzle-orm/pg-core";
export const avatarModeEnum = pgEnum('avatar_mode', ['internal', 'gravatar', 'dicebear']);
import {createSelectSchema} from "drizzle-zod";
import {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
});
+7 -9
View File
@@ -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 -1
View File
@@ -1,4 +1,4 @@
"use server"
"use server";
import { eq } from "drizzle-orm";
import * as drizzleDb from "@/db";
import { db } from "@/db";
+96 -12
View File
@@ -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;
}
+59 -44
View File
@@ -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() {
}
}
}
+40 -8
View File
@@ -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[];
}
+23 -13
View File
@@ -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,
}));
+2
View File
@@ -1,3 +1,5 @@
"use server";
import { db } from "@/db";
import { eq } from "drizzle-orm";
import { member } from "@/db/schema/04_member";
+20 -14
View File
@@ -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:", {
+7
View File
@@ -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;
}
+39 -7
View File
@@ -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[];
}
+8 -4
View File
@@ -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;
}
+4
View File
@@ -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,
+2 -1
View File
@@ -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)
+107 -142
View File
@@ -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>
);
+119 -23
View File
@@ -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>
)
}
);
};
+54 -66
View File
@@ -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 -2
View File
@@ -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>
+6 -1
View File
@@ -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>
+5 -2
View File
@@ -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 &quot;Connect a
notifier&quot; step first.
Go back and configure notifiers in the &quot;Connect a notifier&quot; step first.
</p>
</div>
<Button type="button" variant="outline" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" />
Back
</Button>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Notification Policies</Label>
<Button
type="button"
size="sm"
variant="outline"
disabled={policies.length >= notifiers.length}
onClick={addPolicy}
>
<Plus className="size-4 mr-1" />
Add Policy
</Button>
</div>
{policies.length === 0 ? (
<div className="flex flex-col items-center justify-center p-6 border border-dashed rounded-xl bg-muted/20 text-center gap-1">
<p className="text-sm text-muted-foreground">
Click &quot;Add Policy&quot; to start receiving notifications.
</p>
</div>
) : (
<div className="flex flex-col gap-3">
{policies.map((policy, index) => {
const available = notifiers.filter(
(n) =>
n.id === policy.channelId || !selectedChannelIds.includes(n.id),
);
const selected = notifiers.find((n) => n.id === policy.channelId);
return (
<Card
key={policy.channelId || index}
className="p-4 flex flex-col gap-3"
>
<div className="flex items-end gap-2">
<div className="flex-1 flex flex-col gap-1.5">
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
Channel
</Label>
<Select
value={policy.channelId}
onValueChange={(v) =>
updatePolicy(index, { channelId: v })
}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select channel">
{selected && (
<div className="flex items-center gap-2">
{getChannelIcon(selected.provider)}
<span className="truncate font-medium text-sm">
{selected.name}
</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{available.map((n) => (
<SelectItem key={n.id} value={n.id}>
<div className="flex items-center gap-2">
{getChannelIcon(n.provider)}
<span>{n.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5 shrink-0">
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
Status
</Label>
<div className="flex items-center h-9 px-3 rounded-md border border-input bg-background gap-2">
<Label className="text-xs cursor-pointer">
{policy.enabled ? "Active" : "Off"}
</Label>
<Switch
checked={policy.enabled}
onCheckedChange={(v) =>
updatePolicy(index, { enabled: v })
}
className="scale-75 origin-right"
/>
</div>
</div>
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9 text-muted-foreground hover:text-destructive hover:border-destructive/50 shrink-0"
onClick={() => removePolicy(index)}
>
<Trash2 className="size-4" />
</Button>
</div>
<div className="flex flex-col gap-1.5">
<Label className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Trigger Events
</Label>
<MultiSelect
options={EVENT_KIND_OPTIONS}
onValueChange={(v) =>
updatePolicy(index, { eventKinds: v as EventKind[] })
}
defaultValue={policy.eventKinds}
placeholder="Select events…"
variant="inverted"
animation={0}
className="bg-background/50 w-full"
/>
</div>
</Card>
);
})}
</div>
)}
<div className="flex gap-2 pt-2">
<Button type="button" variant="outline" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" />
Back
</Button>
<Button
type="button"
disabled={
isPending ||
policies.some((p) => !p.channelId || p.eventKinds.length === 0)
}
onClick={() => onSave(policies)}
className="ml-auto"
>
{isPending ? "Saving…" : "Save"}
</Button>
</div>
</div>
);
};
@@ -1,232 +1,31 @@
"use client";
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 &quot;Connect a storage&quot;
step first.
Go back and configure storages in the &quot;Connect a storage&quot; step first.
</p>
</div>
<Button type="button" variant="outline" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" />
Back
</Button>
</div>
);
}
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<Label className="text-sm font-medium">Storage Policies</Label>
<Button
type="button"
size="sm"
variant="outline"
disabled={policies.length >= storages.length}
onClick={addPolicy}
>
<Plus className="size-4 mr-1" />
Add Policy
</Button>
</div>
{policies.length === 0 ? (
<div className="flex flex-col items-center justify-center p-6 border border-dashed rounded-xl bg-muted/20 text-center gap-1">
<p className="text-sm text-muted-foreground">
Click &quot;Add Policy&quot; to assign a storage to this database.
</p>
</div>
) : (
<div className="flex flex-col gap-3">
{policies.map((policy, index) => {
const available = storages.filter(
(s) =>
s.id === policy.channelId || !selectedChannelIds.includes(s.id),
);
const selected = storages.find((s) => s.id === policy.channelId);
return (
<Card
key={policy.channelId || index}
className="p-4 flex items-end gap-2"
>
<div className="flex-1 flex flex-col gap-1.5">
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
Storage Channel
</Label>
<Select
value={policy.channelId}
onValueChange={(v) => updatePolicy(index, { channelId: v })}
>
<SelectTrigger className="h-9">
<SelectValue placeholder="Select storage">
{selected && (
<div className="flex items-center gap-2">
{getChannelIcon(selected.provider)}
<span className="truncate font-medium text-sm">
{selected.name}
</span>
</div>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{available.map((s) => (
<SelectItem key={s.id} value={s.id}>
<div className="flex items-center gap-2">
{getChannelIcon(s.provider)}
<span>{s.name}</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-1.5 shrink-0">
<Label className="text-[10px] font-bold text-muted-foreground uppercase tracking-widest">
Status
</Label>
<div className="flex items-center h-9 px-3 rounded-md border border-input bg-background gap-2">
<Label className="text-xs cursor-pointer">
{policy.enabled ? "Active" : "Off"}
</Label>
<Switch
checked={policy.enabled}
onCheckedChange={(v) =>
updatePolicy(index, { enabled: v })
}
className="scale-75 origin-right"
/>
</div>
</div>
<Button
type="button"
variant="outline"
size="icon"
className="h-9 w-9 text-muted-foreground hover:text-destructive hover:border-destructive/50 shrink-0"
onClick={() => removePolicy(index)}
>
<Trash2 className="size-4" />
</Button>
</Card>
);
})}
</div>
)}
<div className="flex gap-2 pt-2">
<Button type="button" variant="outline" onClick={onBack}>
<ArrowLeft className="size-4 mr-1" />
Back
</Button>
<Button
type="button"
disabled={isPending || policies.some((p) => !p.channelId)}
onClick={() => onSave(policies)}
className="ml-auto"
>
{isPending ? "Saving…" : "Save"}
</Button>
</div>
</div>
);
};
@@ -40,6 +40,7 @@ export const useAddStorage = () => {
label,
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),
});
+11 -4
View File
@@ -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}
+36 -9
View File
@@ -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 };
+2 -2
View File
@@ -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&apos;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()}
/>
);
+110 -32
View File
@@ -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() },
)
}
+55 -20
View File
@@ -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>
);
})}
+5
View File
@@ -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 -1
View File
@@ -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>
);
};
+6 -1
View File
@@ -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>
+7 -218
View File
@@ -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>
);
}
+1
View File
@@ -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>
);
+60
View File
@@ -0,0 +1,60 @@
"use client";
import { useState } from "react";
import { useMutation } from "@tanstack/react-query";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import { Setting } from "@/db/schema/01_setting";
import { updateAvatarModeAction } from "@/features/settings/avatar.action";
import { AvatarModeSelector } from "@/features/settings/avatar-mode-selector";
import { DicebearStylePicker } from "@/features/settings/dicebear-style-picker";
import type { AvatarMode } from "@/features/onboarding/types";
type Props = { settings: Setting };
export const SettingsAvatarSection = ({ settings }: Props) => {
const router = useRouter();
const [avatarMode, setAvatarMode] = useState<AvatarMode>(settings.avatarMode ?? "internal");
const [dicebearStyle, setDicebearStyle] = useState<string>(settings.dicebearStyle ?? "thumbs");
const mutation = useMutation({
mutationFn: async ({ mode, style }: { mode: AvatarMode; style: string }) => {
const result = await updateAvatarModeAction({ name: "system", avatarMode: mode, dicebearStyle: style });
if (result?.data?.success === false || result?.serverError) {
throw new Error(result?.serverError ?? "Failed to update");
}
},
onSuccess: () => {
toast.success("Avatar settings saved");
router.refresh();
},
onError: (e: Error) => toast.error(e.message),
});
const handleModeChange = (mode: AvatarMode) => {
setAvatarMode(mode);
mutation.mutate({ mode, style: dicebearStyle });
};
const handleStyleChange = (style: string) => {
setDicebearStyle(style);
mutation.mutate({ mode: avatarMode, style });
};
return (
<div className="flex flex-col gap-6 max-w-2xl">
<div>
<h2 className="text-lg font-semibold">Avatar</h2>
<p className="text-sm text-muted-foreground mt-1">
Choose how user avatars are generated across the platform.
</p>
</div>
<AvatarModeSelector value={avatarMode} onChange={handleModeChange} disabled={mutation.isPending} />
{avatarMode === "dicebear" && (
<DicebearStylePicker value={dicebearStyle} onChange={handleStyleChange} disabled={mutation.isPending} />
)}
</div>
);
};
+42
View File
@@ -0,0 +1,42 @@
"use server";
import { z } from "zod";
import { eq } from "drizzle-orm";
import { db } from "@/db";
import * as drizzleDb from "@/db";
import { userAction } from "@/lib/safe-actions/actions";
import { withUpdatedAt } from "@/db/utils";
import { ServerActionResult } from "@/types/action-type";
import { Setting } from "@/db/schema/01_setting";
const AVATAR_MODES = ['internal', 'gravatar', 'dicebear'] as const;
export const updateAvatarModeAction = userAction
.schema(z.object({
name: z.string(),
avatarMode: z.enum(AVATAR_MODES),
dicebearStyle: z.string().optional(),
}))
.action(async ({ parsedInput }): Promise<ServerActionResult<Setting>> => {
const { name, avatarMode, dicebearStyle } = parsedInput;
try {
const [updated] = await db
.update(drizzleDb.schemas.setting)
.set(withUpdatedAt({
avatarMode,
...(dicebearStyle ? { dicebearStyle } : {}),
}))
.where(eq(drizzleDb.schemas.setting.name, name))
.returning();
return { success: true, value: updated, actionSuccess: { message: "Avatar mode updated." } };
} catch (_error) {
return {
success: false,
actionError: {
message: "Failed to update avatar mode.",
status: 500,
cause: _error instanceof Error ? _error.message : "Unknown error",
},
};
}
});
@@ -0,0 +1,83 @@
"use client";
import { AlertCircle, Loader2 } from "lucide-react";
import { cn } from "@/lib/utils";
import { useDiceBearStyles } from "@/features/settings/use-dicebear-styles";
const DEMO_SEED = "portabase";
type Props = {
value: string;
onChange: (style: string) => void;
disabled?: boolean;
};
export const DicebearStylePicker = ({ value, onChange, disabled }: Props) => {
const dicebearState = useDiceBearStyles();
return (
<div className="flex flex-col gap-2 mt-1 p-3 rounded-xl border border-border bg-muted/20">
<div className="flex items-center justify-between">
<div className="flex items-center gap-1.5">
{dicebearState.status === "error" && (
<span className="flex items-center gap-1 text-[10px] text-amber-500">
<AlertCircle className="size-3" /> offline
</span>
)}
</div>
</div>
{dicebearState.status === "loading" && (
<div className="flex items-center justify-center h-32 text-muted-foreground">
<Loader2 className="size-5 animate-spin" />
</div>
)}
{dicebearState.status === "error" && (
<div className="flex flex-col items-center justify-center h-32 gap-2 text-muted-foreground">
<AlertCircle className="size-5 text-amber-500" />
<span className="text-xs">
Unable to load styles check your connection
</span>
</div>
)}
{dicebearState.status === "success" && (
<div className="max-h-52 overflow-y-auto scrollbar-hide">
<div className="grid grid-cols-5 gap-2">
{dicebearState.styles.map((style) => {
const isActive = value === style;
return (
<button
key={style}
type="button"
disabled={disabled}
onClick={() => onChange(style)}
title={style}
className={cn(
"flex flex-col items-center gap-1 rounded-lg border-2 p-1.5 transition-all hover:bg-accent/50 disabled:opacity-50",
isActive
? "border-primary bg-primary/5"
: "border-transparent hover:border-muted",
)}
>
<img
src={`https://api.dicebear.com/10.x/${style}/svg?seed=${DEMO_SEED}`}
alt={style}
width={36}
height={36}
loading="lazy"
className="size-9 rounded bg-muted"
/>
<span className="text-[10px] text-muted-foreground truncate w-full text-center leading-tight">
{style}
</span>
</button>
);
})}
</div>
</div>
)}
</div>
);
};
+8 -1
View File
@@ -54,7 +54,14 @@ export const EmailForm = (props: EmailFormProps) => {
return;
}
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();
},
});
+10 -1
View File
@@ -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;
}
+3 -2
View File
@@ -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} />;
};
+3 -2
View File
@@ -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
View File
@@ -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());
-115
View File
@@ -1,115 +0,0 @@
export const organizations = [
{
slug: "default",
name: "Default Organization",
createdAt: "2024-01-10T10:00:00.000Z",
projects: [],
},
{
slug: "tech-corp",
name: "Tech Corp",
createdAt: "2024-01-10T10:00:00.000Z",
projects: [],
},
{
slug: "design-studio",
name: "Design Studio",
createdAt: "2023-09-15T15:45:00.000Z",
projects: [],
},
];
export const projects = [
{
slug: "backend-system",
name: "Backend System",
createdAt: "2024-02-20T11:30:00.000Z",
organizationId: "org-1a2b3c4d",
databases: [],
},
{
slug: "creative-suite",
name: "Creative Suite",
createdAt: "2023-10-10T08:20:00.000Z",
organizationId: "org-2e3f4g5h",
databases: [],
},
];
export const databases = [
{
name: "Main Production DB",
dbms: "postgresql",
generatedId: "prod-db-1",
description: "Primary database for production environment",
backupPolicy: "daily",
createdAt: "2024-11-01T12:00:00.000Z",
agentId: "agent-1234",
lastContact: "2024-11-28T08:30:00.000Z",
projectId: "proj-789",
backups: [],
restorations: [],
},
{
name: "Staging DB",
dbms: "mysql",
generatedId: "staging-db-2",
description: "Database for testing and staging environment",
backupPolicy: "weekly",
createdAt: "2024-10-15T09:45:00.000Z",
agentId: "agent-5678",
lastContact: "2024-11-25T10:15:00.000Z",
projectId: null,
backups: [],
restorations: [],
},
{
name: "Development DB",
dbms: "mongodb",
generatedId: "dev-db-3",
description: null,
backupPolicy: null,
createdAt: "2024-09-20T16:20:00.000Z",
agentId: "agent-9012",
lastContact: null,
projectId: "proj-456",
backups: [],
restorations: [],
},
];
export const backups = [
{ createdAt: "2023-11-27T11:26:54.870914Z", status: "pending" },
{ createdAt: "2023-12-03T11:26:54.870927Z", status: "failed" },
{ createdAt: "2023-12-12T11:26:54.870937Z", status: "success" },
{ createdAt: "2023-11-23T11:26:54.870946Z", status: "processing" },
{ createdAt: "2023-12-09T11:26:54.870955Z", status: "pending" },
{ createdAt: "2023-11-29T11:26:54.870964Z", status: "failed" },
{ createdAt: "2023-12-07T11:26:54.870973Z", status: "success" },
{ createdAt: "2023-11-18T11:26:54.870982Z", status: "processing" },
{ createdAt: "2023-12-01T11:26:54.870991Z", status: "pending" },
{ createdAt: "2023-11-25T11:26:54.871000Z", status: "failed" },
{ createdAt: "2023-11-27T11:26:54.870914Z", status: "pending" },
{ createdAt: "2023-12-03T11:26:54.870927Z", status: "failed" },
{ createdAt: "2023-12-12T11:26:54.870937Z", status: "success" },
{ createdAt: "2023-11-23T11:26:54.870946Z", status: "processing" },
{ createdAt: "2023-12-09T11:26:54.870955Z", status: "pending" },
{ createdAt: "2023-11-29T11:26:54.870964Z", status: "failed" },
{ createdAt: "2023-12-07T11:26:54.870973Z", status: "success" },
{ createdAt: "2023-11-18T11:26:54.870982Z", status: "processing" },
{ createdAt: "2023-12-01T11:26:54.870991Z", status: "pending" },
{ createdAt: "2023-11-25T11:26:54.871000Z", status: "failed" },
];
export const restorations = [
{ backupId: "backup-1", createdAt: "2023-11-27T11:26:54.870914Z", status: "pending" },
{ backupId: "backup-2", createdAt: "2023-12-03T11:26:54.870927Z", status: "failed" },
{ backupId: "backup-3", createdAt: "2023-12-12T11:26:54.870937Z", status: "success" },
{ backupId: "backup-4", createdAt: "2023-11-23T11:26:54.870946Z", status: "processing" },
{ backupId: "backup-5", createdAt: "2023-12-09T11:26:54.870955Z", status: "pending" },
{ backupId: "backup-6", createdAt: "2023-11-29T11:26:54.870964Z", status: "failed" },
{ backupId: "backup-7", createdAt: "2023-12-07T11:26:54.870973Z", status: "success" },
{ backupId: "backup-8", createdAt: "2023-11-18T11:26:54.870982Z", status: "processing" },
{ backupId: "backup-9", createdAt: "2023-12-01T11:26:54.870991Z", status: "pending" },
{ backupId: "backup-10", createdAt: "2023-11-25T11:26:54.871000Z", status: "failed" },
];
+4 -4
View File
@@ -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(" ");
}
-148
View File
@@ -1,148 +0,0 @@
export default function detectOSWithUA(userAgent: string) {
const osList = [
{
name: "Windows",
keywords: ["Win", "NT", "Windows"],
icon: {
name: "microsoft-windows-icon",
size: {
width: 24,
height: 0,
},
},
showText: true,
},
{
name: "Ubuntu",
keywords: ["Ubuntu"],
icon: {
name: "ubuntu",
size: {
width: 24,
height: 0,
},
},
showText: true,
},
{
name: "iOS",
keywords: ["iOS"],
icon: {
name: "ios",
size: {
width: 24,
height: 0,
},
},
showText: false,
},
{
name: "iPadOS",
keywords: ["iPadOS", "iPad"],
icon: {
name: "ios",
size: {
width: 24,
height: 0,
},
},
showText: true,
},
{
name: "MacOS",
keywords: ["MacOS", "Macintosh", "Mac OS", "Mac OS X"],
icon: {
name: "macos",
size: {
width: 0,
height: 16,
},
},
showText: false,
},
{
name: "Android",
keywords: ["Android"],
icon: {
name: "android-icon",
size: {
width: 24,
height: 0,
},
},
showText: true,
},
{
name: "Linux",
keywords: ["X11", "Linux"],
icon: {
name: "linux-tux",
size: {
width: 24,
height: 0,
},
},
showText: true,
},
{
name: "Playstation 4",
keywords: ["PlayStation 4"],
showText: true,
},
{
name: "Playstation 5",
keywords: ["PlayStation 5"],
showText: true,
},
{
name: "Xbox Series X",
keywords: ["Xbox Series X"],
showText: true,
},
{
name: "Xbox One S",
keywords: ["XBOX_ONE_ED"],
showText: true,
},
{
name: "Xbox One",
keywords: ["Xbox One"],
showText: true,
},
{
name: "Nintendo Switch",
keywords: ["Nintendo Switch"],
showText: true,
},
{
name: "AppleTV",
keywords: ["AppleTV"],
icon: {
name: "apple",
size: {
width: 24,
height: 0,
},
},
showText: true,
},
];
for (let os of osList) {
if (os.keywords.some((keyword) => userAgent.includes(keyword))) {
return os;
}
}
return {
name: "Unknown OS",
icon: {
name: "unknown",
size: {
width: 24,
height: 0,
},
},
showText: true,
};
}
+27
View File
@@ -0,0 +1,27 @@
import { createHash } from "crypto";
import type { Setting } from "@/db/schema/01_setting";
import type { User } from "@/db/schema/02_user";
export function resolveAvatarUrl(
user: Pick<User, "email" | "image">,
settings: Pick<Setting, "avatarMode" | "dicebearStyle"> | null | undefined,
): string | undefined {
const mode = settings?.avatarMode ?? "internal";
if (mode === "gravatar") {
const hash = createHash("md5")
.update((user.email ?? "").trim().toLowerCase())
.digest("hex");
return `https://www.gravatar.com/avatar/${hash}?s=200&d=mp`;
}
if (mode === "dicebear") {
const style = settings?.dicebearStyle ?? "thumbs";
const hash = createHash("md5")
.update((user.email ?? "").trim().toLowerCase())
.digest("hex");
return `https://api.dicebear.com/10.x/${style}/svg?seed=${hash}`;
}
return user.image ?? undefined;
}
+21 -25
View File
@@ -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;
}
+8 -8
View File
@@ -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
View File
@@ -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`;
}
-5
View File
@@ -1,5 +0,0 @@
const uuidv4Regex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export function isUuidv4(value: string): value is string {
return uuidv4Regex.test(value);
}