Compare commits

..
9 Commits
Author SHA1 Message Date
github-actions[bot] e29846ae25 chore: release 1.12.0 2026-04-10 16:38:28 +00:00
e38519aec2 feat: org agent management (#259)
* feat: org agent management

* feat: org agent management

* fix: adding migrations for legacy and refactoring.

* fix: refactoring

* fix: delete agent

* fix: refactoring

---------

Co-authored-by: charles-gauthereau <charles.gauthereau@soluce-technologies.com>
2026-04-10 18:37:35 +02:00
github-actions[bot] a620d7a9f7 chore: release 1.11.5 2026-04-06 19:24:12 +00:00
Charles GTEandGitHub 88ebd79681 fix: username capitalization in profile modal (#257) 2026-04-06 21:23:36 +02:00
github-actions[bot] e08efa12fa chore: release 1.11.4 2026-04-06 19:09:07 +00:00
Charles GTEandGitHub 7a685c0518 fix: home project counter (#256)
* fix: home project counter

* fix: home project counter
2026-04-06 21:08:29 +02:00
github-actions[bot] 4deaaa2c8e chore: release 1.11.3 2026-04-06 18:34:59 +00:00
Charles GTEandGitHub adaf0f89fb fix: pipeline ci (#255) 2026-04-06 20:34:24 +02:00
47de5373b1 fix: add page size selector to CardsWithPagination (#253)
* fix: add page size selector to CardsWithPagination

Add an optional pageSizeOptions prop to CardsWithPagination that renders
a "Cards per page" dropdown selector, consistent with the existing
TablePaginationSize pattern used in DataTable.

- New PaginationSize component (standalone, no TanStack Table dependency)
- CardsWithPagination: manage pageSize as state, reset to page 1 on change
- Project databases page: increase default from 6 to 20, add selector [10, 20, 50]
- Projects list page: increase default from 9 to 12, add selector [12, 24, 48]
- Fully backward compatible: without pageSizeOptions, behavior is unchanged

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address CodeRabbit review feedback

- Validate pageSize in handlePageSizeChange (reject 0/negative/NaN)
- Guard empty pageSizeOptions array from rendering empty selector
- Restore className on PaginationNavigation for backward compatibility
- Add aria-label to SelectTrigger for accessibility on small screens

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(pagination-size): fallback to pageSizeOptions[0] when pageSize not in options

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(pagination-size): fix pageSize is not defined (placeholder ref)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 20:16:15 +02:00
63 changed files with 13351 additions and 2110 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
name: Auto Release & Publish name: Auto Release & Publish
on: on:
pull_request: pull_request_target:
types: [ closed ] types: [ closed ]
branches: branches:
- main - main
+1 -1
View File
@@ -31,5 +31,5 @@ keywords:
- web-ui - web-ui
- agent - agent
license: Apache-2.0 license: Apache-2.0
version: 1.11.2 version: 1.12.0
date-released: '2026-03-02' date-released: '2026-03-02'
+1 -1
View File
@@ -16,7 +16,7 @@ seed-pocket:
@docker compose -f docker-compose.func.yml stop pocket-id >/dev/null 2>&1 || true @docker compose -f docker-compose.func.yml stop pocket-id >/dev/null 2>&1 || true
@docker compose -f docker-compose.func.yml rm -f -s pocket-id >/dev/null 2>&1 || true @docker compose -f docker-compose.func.yml rm -f -s pocket-id >/dev/null 2>&1 || true
@docker volume rm portabase-dev-func_pocket-id-data >/dev/null 2>&1 || true @docker volume rm portabase-dev-func_pocket-id-data >/dev/null 2>&1 || true
@docker compose -f docker-compose.func.yml run --rm -v ./seeds/pocket-id/portabase.zip:/tmp/portabase.zip pocket-id ./pocket-id import --yes --path /tmp/portabase.zip >/dev/null @docker compose -f docker-compose.func.yml run --rm -v $$(pwd)/seeds/pocket-id/portabase.zip:/tmp/portabase.zip pocket-id ./pocket-id import --yes --path /tmp/portabase.zip >/dev/null
@docker compose -f docker-compose.func.yml up -d pocket-id @docker compose -f docker-compose.func.yml up -d pocket-id
@sleep 2 @sleep 2
@docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id one-time-access-token admin @docker compose -f docker-compose.func.yml exec pocket-id ./pocket-id one-time-access-token admin
@@ -7,7 +7,7 @@ import {
} from "@/features/layout/page"; } from "@/features/layout/page";
import { db } from "@/db"; import { db } from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import { eq } from "drizzle-orm"; import {eq, isNull} from "drizzle-orm";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { ButtonDeleteAgent } from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent"; import { ButtonDeleteAgent } from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent";
import { capitalizeFirstLetter } from "@/utils/text"; import { capitalizeFirstLetter } from "@/utils/text";
@@ -15,7 +15,7 @@ import { generateEdgeKey } from "@/utils/edge_key";
import { getServerUrl } from "@/utils/get-server-url"; import { getServerUrl } from "@/utils/get-server-url";
import { AgentContentPage } from "@/components/wrappers/dashboard/agent/agent-content"; import { AgentContentPage } from "@/components/wrappers/dashboard/agent/agent-content";
import { AgentDialog } from "@/features/agents/components/agent.dialog"; import { AgentDialog } from "@/features/agents/components/agent.dialog";
import { AgentType } from "@/features/agents/agents.schema";
export default async function RoutePage( export default async function RoutePage(
props: PageParams<{ agentId: string }>, props: PageParams<{ agentId: string }>,
@@ -26,13 +26,30 @@ export default async function RoutePage(
where: eq(drizzleDb.schemas.agent.id, agentId), where: eq(drizzleDb.schemas.agent.id, agentId),
with: { with: {
databases: true, databases: true,
organizations: true,
}, },
}); });
const organizations = await db.query.organization.findMany({
where: (fields) => isNull(fields.deletedAt),
with: {
members: true,
},
});
if (!agent) { if (!agent) {
notFound(); notFound();
} }
const isOwnerByAnOrganization = agent.organizationId
if (isOwnerByAnOrganization){
notFound();
}
const organizationIds = agent.organizations.map(org => org.organizationId)
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id); const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
return ( return (
@@ -45,12 +62,14 @@ export default async function RoutePage(
<div className="flex items-center gap-2 md:justify-between w-full "> <div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<AgentDialog <AgentDialog
agent={agent as AgentType & { id: string }} agent={agent}
typeTrigger={"edit"} typeTrigger={"edit"}
adminView={true}
organizations={organizations}
/> />
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ButtonDeleteAgent agentId={agentId} text={"Delete Agent"} /> <ButtonDeleteAgent organizationIds={organizationIds} agentId={agentId} text={"Delete Agent"} />
</div> </div>
</div> </div>
</PageTitle> </PageTitle>
@@ -5,7 +5,7 @@ import {Page, PageActions, PageContent, PageHeader, PageTitle} from "@/features/
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {db} from "@/db"; import {db} from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {desc, eq, not} from "drizzle-orm"; import {and, desc, eq, isNull, not} from "drizzle-orm";
import {Metadata} from "next"; import {Metadata} from "next";
import {AgentDialog} from "@/features/agents/components/agent.dialog"; import {AgentDialog} from "@/features/agents/components/agent.dialog";
@@ -16,7 +16,7 @@ export const metadata: Metadata = {
export default async function RoutePage(props: PageParams<{}>) { export default async function RoutePage(props: PageParams<{}>) {
const agents = await db.query.agent.findMany({ const agents = await db.query.agent.findMany({
where: not(eq(drizzleDb.schemas.agent.isArchived, true)), where: and(not(eq(drizzleDb.schemas.agent.isArchived, true)),isNull(drizzleDb.schemas.agent.organizationId)),
with: { with: {
databases: true databases: true
}, },
@@ -6,16 +6,15 @@ import {
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination"; import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {ProjectDatabaseCard} from "@/components/wrappers/dashboard/projects/project-card/project-database-card"; import {ProjectDatabaseCard} from "@/components/wrappers/dashboard/projects/project-card/project-database-card";
import {notFound, redirect} from "next/navigation"; import {notFound, redirect} from "next/navigation";
import {db} from "@/db"; import {db} from "@/db";
import {eq} from "drizzle-orm"; import {eq} from "drizzle-orm";
import {getActiveMember, getOrganization} from "@/lib/auth/auth"; import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {capitalizeFirstLetter} from "@/utils/text"; import {capitalizeFirstLetter} from "@/utils/text";
import {ProjectDialog} from "@/features/projects/components/project.dialog"; import {ProjectDialog} from "@/features/projects/components/project.dialog";
import {DatabaseWith} from "@/db/schema/07_database";
import {ProjectWith} from "@/db/schema/06_project"; import {ProjectWith} from "@/db/schema/06_project";
import {isUuidv4} from "@/utils/verify-uuid"; import {isUuidv4} from "@/utils/verify-uuid";
import {getOrganizationAvailableDatabases} from "@/db/services/database";
export default async function RoutePage(props: PageParams<{ export default async function RoutePage(props: PageParams<{
@@ -56,19 +55,7 @@ export default async function RoutePage(props: PageParams<{
redirect("/dashboard/projects"); redirect("/dashboard/projects");
} }
const availableDatabases = ( const availableDatabases = await getOrganizationAvailableDatabases(organization.id, proj.id)
await db.query.database.findMany({
where: (db, {or, eq, isNull}) => or(isNull(db.projectId), eq(db.projectId, proj.id)),
with: {
agent: true,
project: true,
backups: true,
restorations: true,
},
orderBy: (db, {desc}) => [desc(db.createdAt)],
})
) as DatabaseWith[];
const isMember = activeMember?.role === "member"; const isMember = activeMember?.role === "member";
return ( return (
@@ -102,8 +89,9 @@ export default async function RoutePage(props: PageParams<{
organizationSlug={organization.slug} organizationSlug={organization.slug}
// @ts-ignore // @ts-ignore
cardItem={ProjectDatabaseCard} cardItem={ProjectDatabaseCard}
cardsPerPage={6} cardsPerPage={20}
numberOfColumns={3} numberOfColumns={3}
pageSizeOptions={[10, 20, 50]}
extendedProps={proj} extendedProps={proj}
/> />
) : ( ) : (
@@ -9,6 +9,7 @@ import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-pl
import {Metadata} from "next"; import {Metadata} from "next";
import {ProjectDialog} from "@/features/projects/components/project.dialog"; import {ProjectDialog} from "@/features/projects/components/project.dialog";
import {DatabaseWith} from "@/db/schema/07_database"; import {DatabaseWith} from "@/db/schema/07_database";
import {getOrganizationAvailableDatabases} from "@/db/services/database";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Projects", title: "Projects",
@@ -35,18 +36,7 @@ export default async function RoutePage(props: PageParams<{}>) {
}); });
const isMember = activeMember?.role === "member"; const isMember = activeMember?.role === "member";
const availableDatabases = ( const availableDatabases = await getOrganizationAvailableDatabases(organization.id)
await db.query.database.findMany({
where: (db, {isNull}) => isNull(db.projectId),
with: {
agent: true,
project: true,
backups: true,
restorations: true,
},
orderBy: (db, {desc}) => [desc(db.createdAt)],
})
).filter((db) => db.project == null) as DatabaseWith[];
return ( return (
@@ -66,11 +56,12 @@ export default async function RoutePage(props: PageParams<{}>) {
organizationSlug={organization.slug} organizationSlug={organization.slug}
data={projects} data={projects}
cardItem={ProjectCard} cardItem={ProjectCard}
cardsPerPage={9} cardsPerPage={12}
numberOfColumns={3} numberOfColumns={3}
pageSizeOptions={[12, 24, 48]}
/> />
) : isMember ? ( ) : isMember ? (
<EmptyStatePlaceholder text="No project available"/> <EmptyStatePlaceholder state={"empty"} text="No project available"/>
) : ( ) : (
<ProjectDialog databases={availableDatabases} organization={organization} isEmpty={true}/> <ProjectDialog databases={availableDatabases} organization={organization} isEmpty={true}/>
)} )}
@@ -0,0 +1,101 @@
import {PageParams} from "@/types/next";
import {
Page,
PageContent,
PageDescription,
PageTitle,
} from "@/features/layout/page";
import {db} from "@/db";
import * as drizzleDb from "@/db";
import {eq} from "drizzle-orm";
import {notFound} from "next/navigation";
import {ButtonDeleteAgent} from "@/components/wrappers/dashboard/agent/button-delete-agent/button-delete-agent";
import {capitalizeFirstLetter} from "@/utils/text";
import {generateEdgeKey} from "@/utils/edge_key";
import {getServerUrl} from "@/utils/get-server-url";
import {AgentContentPage} from "@/components/wrappers/dashboard/agent/agent-content";
import {AgentDialog} from "@/features/agents/components/agent.dialog";
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import {currentUser} from "@/lib/auth/current-user";
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
export default async function RoutePage(
props: PageParams<{ agentId: string }>,
) {
const {agentId} = await props.params;
const organization = await getOrganization({});
const user = await currentUser();
const activeMember = await getActiveMember();
if (!organization || !activeMember || !user) {
notFound();
}
const {canManageAgents} = computeOrganizationPermissions(activeMember);
if (!canManageAgents){
notFound();
}
const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId),
with: {
databases: true,
organizations: true,
},
});
if (!agent) {
notFound();
}
const hasAccess =
agent.organizationId === organization.id ||
agent.organizations.some(org => org.organizationId === organization.id);
if (!hasAccess) {
notFound();
}
const isOwned = agent.organizationId
const edgeKey = await generateEdgeKey(getServerUrl(), agent.id);
return (
<Page>
<div className="justify-between gap-2 sm:flex">
<PageTitle className="flex flex-col md:flex-row items-center justify-between w-full ">
<div className="min-w-full md:min-w-fit ">
{capitalizeFirstLetter(agent.name)}
</div>
{isOwned && (
<div className="flex items-center gap-2 md:justify-between w-full ">
<div className="flex items-center gap-2">
<AgentDialog
agent={agent}
typeTrigger={"edit"}
/>
</div>
<div className="flex items-center gap-2">
<ButtonDeleteAgent organizationId={organization.id ?? null} agentId={agentId} text={"Delete Agent"}/>
</div>
</div>
)}
</PageTitle>
</div>
{agent.description && (
<PageDescription className="mt-5 sm:mt-0">
{agent.description}
</PageDescription>
)}
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
<AgentContentPage agent={agent} edgeKey={edgeKey}/>
</PageContent>
</Page>
);
}
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default async function RoutePage() {
redirect("/dashboard/settings?tab=agents");
}
@@ -1,25 +1,26 @@
import { PageParams } from "@/types/next"; import {PageParams} from "@/types/next";
import { import {
Page, Page,
PageActions,
PageContent, PageContent,
PageHeader, PageHeader,
PageTitle, PageTitle,
} from "@/features/layout/page"; } from "@/features/layout/page";
import { currentUser } from "@/lib/auth/current-user"; import {currentUser} from "@/lib/auth/current-user";
import { getActiveMember, getOrganization } from "@/lib/auth/auth"; import {getActiveMember, getOrganization} from "@/lib/auth/auth";
import { notFound } from "next/navigation"; import {notFound} from "next/navigation";
import { Metadata } from "next"; import {Metadata} from "next";
import { OrganizationTabs } from "@/components/wrappers/dashboard/organization/tabs/organization-tabs"; import {OrganizationTabs} from "@/components/wrappers/dashboard/organization/tabs/organization-tabs";
import { getOrganizationChannels } from "@/db/services/notification-channel"; import {getOrganizationChannels} from "@/db/services/notification-channel";
import { computeOrganizationPermissions } from "@/lib/acl/organization-acl"; import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
import { getOrganizationStorageChannels } from "@/db/services/storage-channel"; import {getOrganizationStorageChannels} from "@/db/services/storage-channel";
import { DeleteOrganizationButton } from "@/components/wrappers/dashboard/organization/delete-organization-button"; import {DeleteOrganizationButton} from "@/components/wrappers/dashboard/organization/delete-organization-button";
import { EditOrganizationDialog } from "@/features/organization/components/edit-organization.dialog"; import {EditOrganizationDialog} from "@/features/organization/components/edit-organization.dialog";
import { db } from "@/db"; import {db} from "@/db";
import { isNull } from "drizzle-orm"; import {isNull} from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import { eq } from "drizzle-orm"; import {eq} from "drizzle-orm";
import {getOrganizationAgents} from "@/db/services/agent";
import {Tooltip, TooltipContent, TooltipTrigger} from "@/components/ui/tooltip";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Settings", title: "Settings",
@@ -36,6 +37,7 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
const notificationChannels = await getOrganizationChannels(organization.id); const notificationChannels = await getOrganizationChannels(organization.id);
const storageChannels = await getOrganizationStorageChannels(organization.id); const storageChannels = await getOrganizationStorageChannels(organization.id);
const agents = await getOrganizationAgents(organization.id);
const permissions = computeOrganizationPermissions(activeMember); const permissions = computeOrganizationPermissions(activeMember);
const users = await db.query.user.findMany({ const users = await db.query.user.findMany({
@@ -45,6 +47,7 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
const organizationWithMembers = await db.query.organization.findFirst({ const organizationWithMembers = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.id, organization.id), where: eq(drizzleDb.schemas.organization.id, organization.id),
with: { with: {
projects: true,
members: { members: {
with: { with: {
user: true, user: true,
@@ -74,9 +77,24 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{permissions.canManageDangerZone && {permissions.canManageDangerZone &&
organization.slug !== "default" && ( organization.slug !== "default" && (
<Tooltip>
<TooltipTrigger asChild>
<div>
<DeleteOrganizationButton <DeleteOrganizationButton
disabled={organizationWithMembers.projects.length > 0}
organizationSlug={organization.slug} organizationSlug={organization.slug}
/> />
</div>
</TooltipTrigger>
{organizationWithMembers.projects.length > 0 && (
<TooltipContent>
<p>Your organization has some projects associated with it. Please delete them before deleting the organization.</p>
</TooltipContent>
)}
</Tooltip>
)} )}
</div> </div>
</div> </div>
@@ -88,6 +106,7 @@ export default async function RoutePage(props: PageParams<{ slug: string }>) {
organization={organization} organization={organization}
notificationChannels={notificationChannels} notificationChannels={notificationChannels}
storageChannels={storageChannels} storageChannels={storageChannels}
agents={agents}
/> />
</PageContent> </PageContent>
</Page> </Page>
+2 -2
View File
@@ -5,7 +5,7 @@ import {Building2, Database, DatabaseBackup, Folder, RefreshCcw, Server, Workflo
import {currentUser} from "@/lib/auth/current-user"; import {currentUser} from "@/lib/auth/current-user";
import {notFound} from "next/navigation"; import {notFound} from "next/navigation";
import {db} from "@/db"; import {db} from "@/db";
import {asc, inArray} from "drizzle-orm"; import {and, asc, eq, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {listOrganizations} from "@/lib/auth/auth"; import {listOrganizations} from "@/lib/auth/auth";
import {Metadata} from "next"; import {Metadata} from "next";
@@ -26,7 +26,7 @@ export default async function RoutePage(props: PageParams<{}>) {
const agents = await db.query.agent.findMany({}); const agents = await db.query.agent.findMany({});
const projects = await db.query.project.findMany({ const projects = await db.query.project.findMany({
where: inArray(drizzleDb.schemas.project.organizationId, organizationIds), where: and(inArray(drizzleDb.schemas.project.organizationId, organizationIds), eq(drizzleDb.schemas.project.isArchived, false)),
}); });
const projectIds = projects.map(project => project.id); const projectIds = projects.map(project => project.id);
+2 -2
View File
@@ -1,5 +1,5 @@
import {NextResponse} from "next/server"; import {NextResponse} from "next/server";
import {eq} from "drizzle-orm"; import {and, eq} from "drizzle-orm";
import {db} from "@/db"; import {db} from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {logger} from "@/lib/logger"; import {logger} from "@/lib/logger";
@@ -12,7 +12,7 @@ export function withAgentCheck(handler: Function) {
const agentId = (await context.params).agentId; const agentId = (await context.params).agentId;
const agent = await db.query.agent.findFirst({ const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId), where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)),
}); });
if (!agent) { if (!agent) {
@@ -70,10 +70,10 @@ export const PATCH = withAgentCheck(async (request: Request, {params, agent}: {
if (hasSuccessfulStorage && backup.status !== "success") { if (hasSuccessfulStorage && backup.status !== "success") {
await db await db
.update(drizzleDb.schemas.backup) .update(drizzleDb.schemas.backup)
.set({ .set(withUpdatedAt({
status: "success", status: "success",
fileSize: fileSize, fileSize: fileSize,
}) }))
.where(eq(drizzleDb.schemas.backup.id, backup.id)); .where(eq(drizzleDb.schemas.backup.id, backup.id));
} }
} }
+3 -2
View File
@@ -5,6 +5,7 @@ import {db} from "@/db";
import {and, eq} from "drizzle-orm"; import {and, eq} from "drizzle-orm";
import {sendNotificationsBackupRestore} from "@/features/notifications/helpers"; import {sendNotificationsBackupRestore} from "@/features/notifications/helpers";
import {logger} from "@/lib/logger"; import {logger} from "@/lib/logger";
import {withUpdatedAt} from "@/db/utils";
const log = logger.child({module: "api/agent/restore"}); const log = logger.child({module: "api/agent/restore"});
@@ -34,7 +35,7 @@ export async function POST(
} }
const agent = await db.query.agent.findFirst({ const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId) where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)),
}) })
if (!agent) { if (!agent) {
return NextResponse.json({error: "Agent not found"}, {status: 404}) return NextResponse.json({error: "Agent not found"}, {status: 404})
@@ -62,7 +63,7 @@ export async function POST(
await db await db
.update(drizzleDb.schemas.restoration) .update(drizzleDb.schemas.restoration)
.set({status: body.status as RestorationStatus}) .set(withUpdatedAt({status: body.status as RestorationStatus}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id)); .where(eq(drizzleDb.schemas.restoration.id, restoration.id));
await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore"); await sendNotificationsBackupRestore(database, body.status == "failed" ? "error_restore" : "success_restore");
+3 -3
View File
@@ -183,7 +183,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
} else { } else {
await dbClient await dbClient
.update(drizzleDb.schemas.restoration) .update(drizzleDb.schemas.restoration)
.set({status: "failed"}) .set(withUpdatedAt({status: "failed"}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id)); .where(eq(drizzleDb.schemas.restoration.id, restoration.id));
const errorMessage = "Failed to get backup URL"; const errorMessage = "Failed to get backup URL";
@@ -194,14 +194,14 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
log.error({error: err, name: "handleDatabases"}, "Restoration crashed unexpectedly"); log.error({error: err, name: "handleDatabases"}, "Restoration crashed unexpectedly");
await dbClient await dbClient
.update(drizzleDb.schemas.restoration) .update(drizzleDb.schemas.restoration)
.set({status: "failed"}) .set(withUpdatedAt({status: "failed"}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id)); .where(eq(drizzleDb.schemas.restoration.id, restoration.id));
continue; continue;
} }
await dbClient await dbClient
.update(drizzleDb.schemas.restoration) .update(drizzleDb.schemas.restoration)
.set({status: "ongoing"}) .set(withUpdatedAt({status: "ongoing"}))
.where(eq(drizzleDb.schemas.restoration.id, restoration.id)); .where(eq(drizzleDb.schemas.restoration.id, restoration.id));
} }
const storages = await getDatabaseStorageChannels(databaseUpdated.id) const storages = await getDatabaseStorageChannels(databaseUpdated.id)
+2 -2
View File
@@ -3,7 +3,7 @@ import {handleDatabases} from "./helpers";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {db} from "@/db"; import {db} from "@/db";
import {EDbmsSchema} from "@/db/schema/types"; import {EDbmsSchema} from "@/db/schema/types";
import {eq} from "drizzle-orm"; import {and, eq} from "drizzle-orm";
import {isUuidv4} from "@/utils/verify-uuid"; import {isUuidv4} from "@/utils/verify-uuid";
import {withUpdatedAt} from "@/db/utils"; import {withUpdatedAt} from "@/db/utils";
import {logger} from "@/lib/logger"; import {logger} from "@/lib/logger";
@@ -46,7 +46,7 @@ export async function POST(
} }
const agent = await db.query.agent.findFirst({ const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId), where: and(eq(drizzleDb.schemas.agent.id, agentId), eq(drizzleDb.schemas.agent.isArchived, false)),
}) })
if (!agent) { if (!agent) {
+21 -20
View File
@@ -1,6 +1,6 @@
{ {
"name": "portabase", "name": "portabase",
"version": "1.11.2", "version": "1.12.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack -p 8887", "dev": "next dev --turbopack -p 8887",
@@ -15,8 +15,9 @@
"release": "release-it" "release": "release-it"
}, },
"dependencies": { "dependencies": {
"@better-auth/passkey": "^1.5.6", "@better-auth/core": "1.6.2",
"@better-auth/sso": "^1.5.6", "@better-auth/passkey": "^1.6.2",
"@better-auth/sso": "^1.6.2",
"@hookform/resolvers": "^5.2.2", "@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-alert-dialog": "^1.1.15",
@@ -48,22 +49,22 @@
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@react-email/components": "^0.0.41", "@react-email/components": "^0.0.41",
"@t3-oss/env-nextjs": "^0.13.11", "@t3-oss/env-nextjs": "^0.13.11",
"@tanstack/react-query": "^5.95.2", "@tanstack/react-query": "^5.97.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@types/nodemailer": "^6.4.23", "@types/nodemailer": "^6.4.23",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"@zenstackhq/runtime": "2.14.2", "@zenstackhq/runtime": "2.14.2",
"argon2": "^0.43.1", "argon2": "^0.43.1",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"better-auth": "1.5.6", "better-auth": "1.6.2",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dockerode": "^4.0.10", "dockerode": "^4.0.10",
"dotenv": "^16.6.1", "dotenv": "^16.6.1",
"drizzle-orm": "^0.43.1", "drizzle-orm": "0.45.2",
"drizzle-zod": "^0.7.1", "drizzle-zod": "0.8.3",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"googleapis": "^170.1.0", "googleapis": "^170.1.0",
"input-otp": "^1.4.2", "input-otp": "^1.4.2",
@@ -75,18 +76,18 @@
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"node-forge": "^1.4.0", "node-forge": "^1.4.0",
"nodemailer": "^7.0.13", "nodemailer": "8.0.5",
"npm-check-updates": "^18.3.1", "npm-check-updates": "^18.3.1",
"pg": "^8.20.0", "pg": "^8.20.0",
"pino": "^10.3.1", "pino": "^10.3.1",
"pino-pretty": "^13.1.3", "pino-pretty": "^13.1.3",
"prettier": "^3.8.1", "prettier": "^3.8.2",
"react": "^19.2.4", "react": "^19.2.5",
"react-day-picker": "9.7.0", "react-day-picker": "9.7.0",
"react-dom": "^19.2.4", "react-dom": "^19.2.5",
"react-dropzone": "^14.4.1", "react-dropzone": "^14.4.1",
"react-email": "^4.3.2", "react-email": "^4.3.2",
"react-hook-form": "^7.72.0", "react-hook-form": "^7.72.1",
"react-qr-code": "^2.0.18", "react-qr-code": "^2.0.18",
"react-resizable-panels": "^3.0.6", "react-resizable-panels": "^3.0.6",
"react-twc": "^1.5.1", "react-twc": "^1.5.1",
@@ -101,33 +102,33 @@
"uuid": "^11.1.0", "uuid": "^11.1.0",
"vaul": "^1.1.2", "vaul": "^1.1.2",
"ws": "^8.20.0", "ws": "^8.20.0",
"zod": "^3.25.76" "zod": "4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@iconify/react": "^6.0.2", "@iconify/react": "^6.0.2",
"@playwright/test": "1.58.2", "@playwright/test": "1.58.2",
"@react-email/preview-server": "4.3.2", "@react-email/preview-server": "4.3.2",
"@react-email/render": "^2.0.4", "@react-email/render": "^2.0.6",
"@release-it/bumper": "^7.0.5", "@release-it/bumper": "^7.0.5",
"@release-it/conventional-changelog": "^10.0.6", "@release-it/conventional-changelog": "^10.0.6",
"@tailwindcss/postcss": "^4.2.2", "@tailwindcss/postcss": "^4.2.2",
"@types/eslint-plugin-tailwindcss": "^3.17.0", "@types/eslint-plugin-tailwindcss": "^3.17.0",
"@types/node": "^22.19.15", "@types/node": "^22.19.17",
"@types/node-forge": "^1.3.14", "@types/node-forge": "^1.3.14",
"@types/pg": "^8.20.0", "@types/pg": "^8.20.0",
"@types/react": "^19.2.14", "@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@zenstackhq/openapi": "^2.22.1", "@zenstackhq/openapi": "^2.22.1",
"@zenstackhq/tanstack-query": "^2.22.2", "@zenstackhq/tanstack-query": "^2.22.2",
"baseline-browser-mapping": "^2.10.12", "baseline-browser-mapping": "^2.10.17",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"esbuild": "^0.27.4", "esbuild": "^0.27.7",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-config-next": "^16.2.1", "eslint-config-next": "^16.2.3",
"eslint-plugin-tailwindcss": "^3.18.2", "eslint-plugin-tailwindcss": "^3.18.2",
"framer-motion": "^12.34.3", "framer-motion": "^12.38.0",
"node-pty": "^1.1.0", "node-pty": "^1.1.0",
"postcss": "^8.5.6", "postcss": "^8.5.9",
"release-it": "^19.2.4", "release-it": "^19.2.4",
"tailwindcss": "^4.2.2", "tailwindcss": "^4.2.2",
"tsx": "^4.21.0", "tsx": "^4.21.0",
+1703 -1841
View File
File diff suppressed because it is too large Load Diff
@@ -6,7 +6,7 @@ import z from "zod";
import { db } from "@/db"; import { db } from "@/db";
import {action} from "@/lib/safe-actions/actions"; import {action} from "@/lib/safe-actions/actions";
//todo: to be continued... //TODO: to be continued...
export const forgotPasswordAction = action export const forgotPasswordAction = action
.schema( .schema(
z.object({ z.object({
@@ -44,7 +44,7 @@ export function BreadCrumbsWrapper() {
) )
} }
const FORBIDDEN_LINKS = ["organization", "dashboard", "database", "admin", "settings", "notifications", "storages"]; const FORBIDDEN_LINKS = ["organization", "dashboard", "database", "admin", "notifications", "storages"];
export function BreadCrumbs({}: BreadCrumbsProps) { export function BreadCrumbs({}: BreadCrumbsProps) {
@@ -4,6 +4,7 @@ import React, { ComponentType, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PaginationNavigation } from "@/components/wrappers/common/pagination/pagination-navigation"; import { PaginationNavigation } from "@/components/wrappers/common/pagination/pagination-navigation";
import { PaginationSize } from "@/components/wrappers/common/pagination/pagination-size";
interface CardsWithPaginationProps<T> { interface CardsWithPaginationProps<T> {
className?: string; className?: string;
@@ -13,19 +14,21 @@ interface CardsWithPaginationProps<T> {
cardsPerPage?: number; cardsPerPage?: number;
numberOfColumns?: number; numberOfColumns?: number;
maxVisiblePages?: number; maxVisiblePages?: number;
pageSizeOptions?: number[];
[key: string]: any; [key: string]: any;
} }
export function CardsWithPagination<T>(props: CardsWithPaginationProps<T>) { export function CardsWithPagination<T>(props: CardsWithPaginationProps<T>) {
const { className, organizationSlug, data, cardItem, cardsPerPage = 5, numberOfColumns = 1, maxVisiblePages = 3, ...rest } = props; const { className, organizationSlug, data, cardItem, cardsPerPage = 5, numberOfColumns = 1, maxVisiblePages = 3, pageSizeOptions, ...rest } = props;
const CardItem = cardItem; const CardItem = cardItem;
const [pageSize, setPageSize] = useState(cardsPerPage);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const totalPages = Math.ceil(data.length / cardsPerPage); const totalPages = Math.ceil(data.length / pageSize);
const indexOfLastCard = currentPage * cardsPerPage; const indexOfLastCard = currentPage * pageSize;
const indexOfFirstCard = indexOfLastCard - cardsPerPage; const indexOfFirstCard = indexOfLastCard - pageSize;
const currentCards = data.slice(indexOfFirstCard, indexOfLastCard); const currentCards = data.slice(indexOfFirstCard, indexOfLastCard);
const goToPage = (pageNumber: number) => { const goToPage = (pageNumber: number) => {
@@ -40,6 +43,14 @@ export function CardsWithPagination<T>(props: CardsWithPaginationProps<T>) {
goToPage(Math.min(totalPages, currentPage + 1)); goToPage(Math.min(totalPages, currentPage + 1));
}; };
const handlePageSizeChange = (newSize: number) => {
if (!Number.isFinite(newSize) || newSize < 1) return;
setPageSize(newSize);
setCurrentPage(1);
};
const showSizeSelector = pageSizeOptions && pageSizeOptions.length > 0;
return ( return (
<div className={cn("flex flex-col h-full justify-between", className)}> <div className={cn("flex flex-col h-full justify-between", className)}>
<div className={cn(`grid h-max auto-rows-min gap-4 md:grid-cols-${numberOfColumns}`)}> <div className={cn(`grid h-max auto-rows-min gap-4 md:grid-cols-${numberOfColumns}`)}>
@@ -47,8 +58,16 @@ export function CardsWithPagination<T>(props: CardsWithPaginationProps<T>) {
<CardItem key={key} data={card} organizationSlug={organizationSlug} {...rest} /> <CardItem key={key} data={card} organizationSlug={organizationSlug} {...rest} />
))} ))}
</div> </div>
<div className="flex items-center justify-end mt-4 gap-4">
{showSizeSelector && (
<PaginationSize
pageSize={pageSize}
onPageSizeChange={handlePageSizeChange}
pageSizeOptions={pageSizeOptions}
/>
)}
<PaginationNavigation <PaginationNavigation
className="justify-end mt-4" className="justify-end"
totalPages={totalPages} totalPages={totalPages}
currentPage={currentPage} currentPage={currentPage}
goToPage={goToPage} goToPage={goToPage}
@@ -57,5 +76,6 @@ export function CardsWithPagination<T>(props: CardsWithPaginationProps<T>) {
maxVisiblePages={maxVisiblePages} maxVisiblePages={maxVisiblePages}
/> />
</div> </div>
</div>
); );
} }
@@ -1,6 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import {cn} from "@/lib/utils"; import {cn} from "@/lib/utils";
import {Plus} from "lucide-react"; import {CircleSlash2, Plus} from "lucide-react";
import {forwardRef, HTMLAttributes} from "react"; import {forwardRef, HTMLAttributes} from "react";
type EmptyStatePlaceholderProps = { type EmptyStatePlaceholderProps = {
@@ -8,12 +8,14 @@ type EmptyStatePlaceholderProps = {
onClick?: () => void; onClick?: () => void;
text: string; text: string;
className?: string; className?: string;
state?: string
} & HTMLAttributes<HTMLDivElement>; } & HTMLAttributes<HTMLDivElement>;
export const EmptyStatePlaceholder = forwardRef<HTMLDivElement, EmptyStatePlaceholderProps>(({ export const EmptyStatePlaceholder = forwardRef<HTMLDivElement, EmptyStatePlaceholderProps>(({
url, url,
onClick, onClick,
text, text,
state,
className, className,
...props ...props
}, ref) => { }, ref) => {
@@ -21,12 +23,17 @@ export const EmptyStatePlaceholder = forwardRef<HTMLDivElement, EmptyStatePlaceh
<div <div
className={cn( className={cn(
"flex h-full flex-col items-center justify-center w-full rounded-2xl border border-dashed border-muted p-6 lg:p-10", "flex h-full flex-col items-center justify-center w-full rounded-2xl border border-dashed border-muted p-6 lg:p-10",
"hover:bg-muted/50 transition-colors text-muted-foreground hover:text-primary text-center space-y-4", "transition-colors text-muted-foreground text-center space-y-4",
state != "empty" && "hover:bg-muted/50 hover:text-primary",
(onClick || url) && "cursor-pointer" (onClick || url) && "cursor-pointer"
)} )}
onClick={onClick} onClick={onClick}
> >
<Plus className="w-5 h-5 lg:w-6 lg:h-6" /> {state == "empty" ?
<CircleSlash2 className="w-5 h-5 lg:w-6 lg:h-6"/>
:
<Plus className="w-5 h-5 lg:w-6 lg:h-6"/>
}
<p className="text-sm">{text}</p> <p className="text-sm">{text}</p>
</div> </div>
); );
@@ -0,0 +1,35 @@
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { cn } from "@/lib/utils";
export type PaginationSizeProps = {
className?: string;
pageSize: number;
onPageSizeChange: (size: number) => void;
pageSizeOptions?: number[];
};
export const PaginationSize = (props: PaginationSizeProps) => {
const { className, onPageSizeChange, pageSizeOptions = [10, 20, 30, 40, 50] } = props;
const effectivePageSize = pageSizeOptions.includes(props.pageSize) ? props.pageSize : pageSizeOptions[0];
return (
<div className={cn("flex items-center justify-end sm:justify-center space-x-2", className)}>
<p className="whitespace-nowrap text-sm font-medium hidden md:block">Cards per page</p>
<Select
value={`${effectivePageSize}`}
onValueChange={(value) => onPageSizeChange(Number(value))}
>
<SelectTrigger className="h-8 w-[4.5rem]" aria-label="Cards per page">
<SelectValue placeholder={effectivePageSize} />
</SelectTrigger>
<SelectContent side="top">
{pageSizeOptions.map((size) => (
<SelectItem key={size} value={`${size}`}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
};
@@ -1,7 +1,7 @@
import {z} from "zod"; import {z} from "zod";
export const ChannelsOrganizationSchema = z.object({ export const ChannelsOrganizationSchema = z.object({
organizations: z.array(z.string()) organizations: z.array(z.string().uuid())
}); });
export type ChannelsOrganizationType = z.infer<typeof ChannelsOrganizationSchema>; export type ChannelsOrganizationType = z.infer<typeof ChannelsOrganizationSchema>;
@@ -5,6 +5,7 @@ import { eq } from "drizzle-orm";
import { db } from "@/db"; import { db } from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {userAction} from "@/lib/safe-actions/actions"; import {userAction} from "@/lib/safe-actions/actions";
import {withUpdatedAt} from "@/db/utils";
export const updateEmailSettingsAction = userAction export const updateEmailSettingsAction = userAction
.schema( .schema(
@@ -18,9 +19,9 @@ export const updateEmailSettingsAction = userAction
const [updatedSettings] = await db const [updatedSettings] = await db
.update(drizzleDb.schemas.setting) .update(drizzleDb.schemas.setting)
.set({ .set(withUpdatedAt({
...data, ...data,
}) }))
.where(eq(drizzleDb.schemas.setting.name, name)) .where(eq(drizzleDb.schemas.setting.name, name))
.returning(); .returning();
@@ -10,6 +10,7 @@ import {z} from "zod";
import { import {
DefaultNotificationSchema DefaultNotificationSchema
} from "@/components/wrappers/dashboard/admin/settings/notification/settings-notification.schema"; } from "@/components/wrappers/dashboard/admin/settings/notification/settings-notification.schema";
import {withUpdatedAt} from "@/db/utils";
export const updateNotificationSettingsAction = userAction export const updateNotificationSettingsAction = userAction
.schema( .schema(
@@ -24,9 +25,9 @@ export const updateNotificationSettingsAction = userAction
try { try {
const [updatedSettings] = await db const [updatedSettings] = await db
.update(drizzleDb.schemas.setting) .update(drizzleDb.schemas.setting)
.set({ .set(withUpdatedAt({
defaultNotificationChannelId: data.notificationChannelId ?? null, defaultNotificationChannelId: data.notificationChannelId ?? null,
}) }))
.where(eq(drizzleDb.schemas.setting.name, name)) .where(eq(drizzleDb.schemas.setting.name, name))
.returning(); .returning();
return { return {
@@ -8,6 +8,7 @@ import {ServerActionResult} from "@/types/action-type";
import {Setting} from "@/db/schema/01_setting"; import {Setting} from "@/db/schema/01_setting";
import {z} from "zod"; import {z} from "zod";
import {DefaultStorageSchema} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage.schema"; import {DefaultStorageSchema} from "@/components/wrappers/dashboard/admin/settings/storage/settings-storage.schema";
import {withUpdatedAt} from "@/db/utils";
export const updateStorageSettingsAction = userAction export const updateStorageSettingsAction = userAction
.schema( .schema(
@@ -22,10 +23,10 @@ export const updateStorageSettingsAction = userAction
try { try {
const [updatedSettings] = await db const [updatedSettings] = await db
.update(drizzleDb.schemas.setting) .update(drizzleDb.schemas.setting)
.set({ .set(withUpdatedAt({
defaultStorageChannelId: data.storageChannelId, defaultStorageChannelId: data.storageChannelId,
encryption: data.encryption, encryption: data.encryption,
}) }))
.where(eq(drizzleDb.schemas.setting.name, name)) .where(eq(drizzleDb.schemas.setting.name, name))
.returning(); .returning();
return { return {
@@ -6,6 +6,7 @@ import { S3FormSchema, StorageSwitchSchema } from "@/components/wrappers/dashboa
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {userAction} from "@/lib/safe-actions/actions"; import {userAction} from "@/lib/safe-actions/actions";
import {withUpdatedAt} from "@/db/utils";
export const updateS3SettingsAction = userAction export const updateS3SettingsAction = userAction
.schema( .schema(
@@ -19,7 +20,7 @@ export const updateS3SettingsAction = userAction
const [updatedSettings] = await db const [updatedSettings] = await db
.update(drizzleDb.schemas.setting) .update(drizzleDb.schemas.setting)
.set({ ...data }) .set(withUpdatedAt({ ...data }))
.where(eq(drizzleDb.schemas.setting.name, name)) .where(eq(drizzleDb.schemas.setting.name, name))
.returning(); .returning();
@@ -40,6 +41,7 @@ export const updateStorageSettingsAction = userAction
const [updatedSettings] = await db const [updatedSettings] = await db
.update(drizzleDb.schemas.setting) .update(drizzleDb.schemas.setting)
// @ts-ignore
.set({ ...data }) .set({ ...data })
.where(eq(drizzleDb.schemas.setting.name, name)) .where(eq(drizzleDb.schemas.setting.name, name))
.returning(); .returning();
@@ -15,6 +15,7 @@ import {useAgentUpdateCheck} from "@/features/agents/hooks/use-agent-update-chec
export type agentCardProps = { export type agentCardProps = {
data: AgentWith; data: AgentWith;
organizationView?: boolean;
}; };
export const AgentCard = (props: agentCardProps) => { export const AgentCard = (props: agentCardProps) => {
@@ -34,7 +35,7 @@ export const AgentCard = (props: agentCardProps) => {
return ( return (
<Link <Link
href={`/dashboard/agents/${agent.id}`} href={props.organizationView ? `/dashboard/settings/agents/${agent.id}`: `/dashboard/agents/${agent.id}`}
className="group block transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl" className="group block transition-all duration-200 outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 rounded-xl"
> >
<Card className="flex flex-row items-center p-4 gap-5 transition-all border-border/50 bg-card hover:bg-accent/50 hover:border-primary/50 group-hover:shadow-md overflow-hidden"> <Card className="flex flex-row items-center p-4 gap-5 transition-all border-border/50 bg-card hover:bg-accent/50 hover:border-primary/50 group-hover:shadow-md overflow-hidden">
@@ -9,8 +9,10 @@ import {deleteAgentAction} from "@/components/wrappers/dashboard/agent/button-de
import {useIsMobile} from "@/hooks/use-mobile"; import {useIsMobile} from "@/hooks/use-mobile";
export type ButtonDeleteAgentProps = { export type ButtonDeleteAgentProps = {
text?: string; text?: string,
agentId: string; agentId: string,
organizationId?: string
organizationIds?: string[]
}; };
export const ButtonDeleteAgent = (props: ButtonDeleteAgentProps) => { export const ButtonDeleteAgent = (props: ButtonDeleteAgentProps) => {
@@ -18,11 +20,11 @@ export const ButtonDeleteAgent = (props: ButtonDeleteAgentProps) => {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => deleteAgentAction(props.agentId), mutationFn: () => deleteAgentAction({agentId: props.agentId, organizationId: props.organizationId, organizationIds: props.organizationIds}),
onSuccess: async (result: any) => { onSuccess: async (result: any) => {
if (result.data?.success) { if (result.data?.success) {
toast.success(result.data.actionSuccess.message); toast.success(result.data.actionSuccess.message);
router.push("/dashboard/agents"); router.push(props.organizationId ? "/dashboard/settings?tab=agents" : "/dashboard/agents");
} else { } else {
toast.error(result.data.actionError.message || "Unknown error occurred."); toast.error(result.data.actionError.message || "Unknown error occurred.");
} }
@@ -35,7 +37,7 @@ export const ButtonDeleteAgent = (props: ButtonDeleteAgentProps) => {
description="Are you sure you want to remove this agent? This action cannot be undone." description="Are you sure you want to remove this agent? This action cannot be undone."
button={{ button={{
main: { main: {
text: props.text ? !isMobile ? props.text: "" : "", text: props.text ? !isMobile ? props.text : "" : "",
variant: "outline", variant: "outline",
icon: <Trash2 color="red"/>, icon: <Trash2 color="red"/>,
}, },
@@ -3,40 +3,124 @@
import {z} from "zod"; import {z} from "zod";
import {v4 as uuidv4} from "uuid"; import {v4 as uuidv4} from "uuid";
import {ServerActionResult} from "@/types/action-type"; import {ServerActionResult} from "@/types/action-type";
import {eq} from "drizzle-orm"; import {and, eq, inArray} from "drizzle-orm";
import {db} from "@/db"; import {db} from "@/db";
import * as drizzleDb from "@/db"; import * as drizzleDb from "@/db";
import {Agent} from "@/db/schema/08_agent"; import {Agent} from "@/db/schema/08_agent";
import {userAction} from "@/lib/safe-actions/actions"; import {userAction} from "@/lib/safe-actions/actions";
import {zString} from "@/lib/zod";
import {withUpdatedAt} from "@/db/utils";
export const deleteAgentAction = userAction
.schema(
z.object({
agentId: zString(),
organizationId: zString().optional(),
organizationIds: z.array(z.string()).optional()
})
)
.action(async ({parsedInput}): Promise<ServerActionResult<Agent>> => {
const {agentId, organizationId, organizationIds} = parsedInput;
export const deleteAgentAction = userAction.schema(z.string()).action(async ({parsedInput}): Promise<ServerActionResult<Agent>> => {
try { try {
let projectIds: string[] = [];
// const deletedAgent: Agent[] = await db.delete(drizzleDb.schemas.agent).where(eq(drizzleDb.schemas.agent.id, parsedInput)).returning();
const uuid = uuidv4(); const uuid = uuidv4();
if (organizationId) {
await db
.delete(drizzleDb.schemas.organizationAgent)
.where(
and(
eq(drizzleDb.schemas.organizationAgent.organizationId, organizationId),
eq(drizzleDb.schemas.organizationAgent.agentId, agentId)
)
);
const organization = await db.query.organization.findFirst({
where: eq(drizzleDb.schemas.organization.id, organizationId),
with: {
projects: true,
}
});
projectIds = organization?.projects?.map(project => project.id) ?? [];
} else if (organizationIds) {
const organizationsToRemoveDetails = await db.query.organization.findMany({
where: inArray(drizzleDb.schemas.organization.id, organizationIds),
with: {
projects: true
}
});
projectIds = organizationsToRemoveDetails.flatMap(org =>
org.projects.map(project => project.id)
);
}
if (projectIds?.length > 0) {
const databases = await db.query.database.findMany({
where: (db, {inArray}) => inArray(db.projectId, projectIds),
columns: {id: true}
});
const databaseIds = databases.map(d => d.id);
await db
.update(drizzleDb.schemas.database)
.set(withUpdatedAt({
backupPolicy: null,
projectId: null
}))
.where(inArray(drizzleDb.schemas.database.projectId, projectIds))
.execute();
await db.delete(drizzleDb.schemas.retentionPolicy)
.where(inArray(drizzleDb.schemas.retentionPolicy.databaseId, databaseIds))
.execute();
await db.delete(drizzleDb.schemas.alertPolicy)
.where(inArray(drizzleDb.schemas.alertPolicy.databaseId, databaseIds))
.execute();
await db.delete(drizzleDb.schemas.storagePolicy)
.where(inArray(drizzleDb.schemas.storagePolicy.databaseId, databaseIds))
.execute();
}
const updatedAgent = await db const updatedAgent = await db
.update(drizzleDb.schemas.agent) .update(drizzleDb.schemas.agent)
.set({ .set(withUpdatedAt({
isArchived: true, isArchived: true,
slug: uuid, slug: uuid,
}) deletedAt: new Date()
.where(eq(drizzleDb.schemas.agent.id, parsedInput)) }))
.where(eq(drizzleDb.schemas.agent.id, agentId))
.returning(); .returning();
if (!updatedAgent[0]) { if (!updatedAgent[0]) {
throw new Error("Agent not found or update failed"); return {
success: false,
actionError: {
message: "Agent not found or update failed",
status: 404,
messageParams: {agentId: agentId},
},
};
} }
return { return {
success: true, success: true,
value: updatedAgent[0], value: updatedAgent[0],
actionSuccess: { actionSuccess: {
message: "Agent has been successfully deleted.", message: "Agent has been successfully deleted.",
messageParams: {projectId: parsedInput}, messageParams: {projectId: agentId},
}, },
}; };
} catch (error) { } catch (error) {
@@ -46,8 +130,8 @@ export const deleteAgentAction = userAction.schema(z.string()).action(async ({pa
message: "Failed to delete agent.", message: "Failed to delete agent.",
status: 500, status: 500,
cause: error instanceof Error ? error.message : "Unknown error", cause: error instanceof Error ? error.message : "Unknown error",
messageParams: {projectId: parsedInput}, messageParams: {agentId: agentId},
}, },
}; };
} }
}); });
@@ -55,9 +55,7 @@ export const UploadBackupZone = ({onSuccessAction, database}: UploadRetentionZon
}, },
}); });
const acceptDbImportFiles = getFileHeadersBasedOnDbms(database.dbms) const acceptDbImportFiles = getFileHeadersBasedOnDbms(database.dbms)
console.log(acceptDbImportFiles)
const fileKindDescription = Object.values(acceptDbImportFiles) const fileKindDescription = Object.values(acceptDbImportFiles)
.flat() .flat()
@@ -10,6 +10,7 @@ import {useIsMobile} from "@/hooks/use-mobile";
export type DeleteOrganizationButtonProps = { export type DeleteOrganizationButtonProps = {
organizationSlug: string; organizationSlug: string;
disabled?: boolean
}; };
export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) => { export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) => {
@@ -51,6 +52,7 @@ export const DeleteOrganizationButton = (props: DeleteOrganizationButtonProps) =
main: { main: {
text: !isMobile ? "Delete Organization" : "", text: !isMobile ? "Delete Organization" : "",
variant: "outline", variant: "outline",
disabled: props.disabled,
icon: <Trash2 color="red"/>, icon: <Trash2 color="red"/>,
}, },
confirm: { confirm: {
@@ -0,0 +1,38 @@
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {cn} from "@/lib/utils";
import {Agent} from "@/db/schema/08_agent";
import {CardsWithPagination} from "@/components/wrappers/common/cards-with-pagination";
import {AgentCard} from "@/components/wrappers/dashboard/agent/agent-card/agent-card";
import {AgentDialog} from "@/features/agents/components/agent.dialog";
export type OrganizationAgentsTabProps = {
organization: OrganizationWithMembers;
agents: Agent[];
};
export const OrganizationAgentsTab = ({
organization,
agents,
}: OrganizationAgentsTabProps) => {
const hasAgent = agents.length > 0;
return (
<div className="flex flex-col gap-y-6 h-full py-4">
<div className="h-full flex flex-col gap-y-6">
<div className={cn("hidden flex-row justify-between items-start", hasAgent && "flex")}>
<div className="max-w-2xl ">
<h3 className="text-xl font-semibold text-balance mb-1">
Agent Settings
</h3>
</div>
<AgentDialog organization={organization} typeTrigger="create"/>
</div>
{hasAgent ? (
<CardsWithPagination data={agents} organizationView={true} cardItem={AgentCard} cardsPerPage={4} numberOfColumns={1}/>
) : (
<AgentDialog organization={organization} typeTrigger="empty"/>
)}
</div>
</div>
);
};
@@ -29,11 +29,6 @@ export const OrganizationNotifiersTab = ({
Notification Settings Notification Settings
</h3> </h3>
</div> </div>
{/*<NotifierAddEditModal*/}
{/* organization={organization}*/}
{/* open={isAddModalOpen}*/}
{/* onOpenChangeAction={setIsAddModalOpen}*/}
{/*/>*/}
<ChannelAddEditModal <ChannelAddEditModal
kind={kind} kind={kind}
organization={organization} organization={organization}
@@ -16,22 +16,32 @@ import {
import { import {
OrganizationStoragesTab OrganizationStoragesTab
} from "@/components/wrappers/dashboard/organization/tabs/organization-channels-tab/organization-storages-tab"; } from "@/components/wrappers/dashboard/organization/tabs/organization-channels-tab/organization-storages-tab";
import {
OrganizationAgentsTab
} from "@/components/wrappers/dashboard/organization/tabs/organization-channels-tab/organization-agents-tab";
import {Agent} from "@/db/schema/08_agent";
export type OrganizationTabsProps = { export type OrganizationTabsProps = {
organization: OrganizationWithMembers; organization: OrganizationWithMembers;
notificationChannels: NotificationChannel[]; notificationChannels: NotificationChannel[];
storageChannels: StorageChannel[]; storageChannels: StorageChannel[];
activeMember: MemberWithUser activeMember: MemberWithUser;
agents: Agent[]
}; };
export const OrganizationTabs = ({activeMember, organization, notificationChannels, storageChannels}: OrganizationTabsProps) => { export const OrganizationTabs = ({
activeMember,
organization,
notificationChannels,
storageChannels,
agents
}: OrganizationTabsProps) => {
const router = useRouter(); const router = useRouter();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [tab, setTab] = useState<string>(() => searchParams.get("tab") ?? "users"); const [tab, setTab] = useState<string>(() => searchParams.get("tab") ?? "users");
const { const {
canManageUsers,
canManageNotifications, canManageNotifications,
canManageStorages canManageStorages
} = useOrganizationPermissions(activeMember); } = useOrganizationPermissions(activeMember);
@@ -71,6 +81,12 @@ export const OrganizationTabs = ({activeMember, organization, notificationChanne
> >
Storages Storages
</TabsTrigger> </TabsTrigger>
<TabsTrigger
className="w-full"
value="agents"
>
Agents
</TabsTrigger>
</TabsList> </TabsList>
<TabsContent className="h-full" value="users"> <TabsContent className="h-full" value="users">
<SettingsOrganizationMembersTable organization={organization}/> <SettingsOrganizationMembersTable organization={organization}/>
@@ -87,6 +103,12 @@ export const OrganizationTabs = ({activeMember, organization, notificationChanne
storageChannels={storageChannels} storageChannels={storageChannels}
/> />
</TabsContent> </TabsContent>
<TabsContent className="h-full" value="agents">
<OrganizationAgentsTab
organization={organization}
agents={agents}
/>
</TabsContent>
</Tabs> </Tabs>
: :
<SettingsOrganizationMembersTable organization={organization}/> <SettingsOrganizationMembersTable organization={organization}/>
@@ -72,7 +72,7 @@ export const AvatarWithUpload = (props: AvatarWithUploadProps) => {
<Avatar className="w-24 h-24 lg:w-32 lg:h-32 border-4 border-muted/20"> <Avatar className="w-24 h-24 lg:w-32 lg:h-32 border-4 border-muted/20">
<AvatarImage className="object-cover" src={user.image || undefined}/> <AvatarImage className="object-cover" src={user.image || undefined}/>
<AvatarFallback className="text-3xl">{user.name.charAt(0)}</AvatarFallback> <AvatarFallback className="text-3xl">{user.name.charAt(0).toUpperCase()}</AvatarFallback>
</Avatar> </Avatar>
<div <div
+10
View File
@@ -0,0 +1,10 @@
CREATE TABLE "organization_agents" (
"organization_id" uuid NOT NULL,
"agent_id" uuid NOT NULL,
CONSTRAINT "organization_agents_organization_id_agent_id_unique" UNIQUE("organization_id","agent_id")
);
--> statement-breakpoint
ALTER TABLE "agents" ADD COLUMN "organization_id" uuid;--> statement-breakpoint
ALTER TABLE "organization_agents" ADD CONSTRAINT "organization_agents_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "organization_agents" ADD CONSTRAINT "organization_agents_agent_id_agents_id_fk" FOREIGN KEY ("agent_id") REFERENCES "public"."agents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "agents" ADD CONSTRAINT "agents_organization_id_organization_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organization"("id") ON DELETE cascade ON UPDATE no action;
+31
View File
@@ -0,0 +1,31 @@
-- Custom SQL migration file, put your code below! --
DO $$
DECLARE
org RECORD;
proj RECORD;
db RECORD;
BEGIN
FOR org IN SELECT id FROM organization LOOP
FOR proj IN
SELECT id FROM projects WHERE organization_id = org.id
LOOP
FOR db IN
SELECT agent_id FROM databases WHERE project_id = proj.id
LOOP
IF db.agent_id IS NOT NULL THEN
INSERT INTO organization_agents (
organization_id,
agent_id
)
VALUES (
org.id,
db.agent_id
)
ON CONFLICT (organization_id, agent_id) DO NOTHING;
END IF;
END LOOP;
END LOOP;
END LOOP;
END $$;
@@ -0,0 +1,3 @@
ALTER TABLE "organization_agents" ADD COLUMN "updated_at" timestamp;--> statement-breakpoint
ALTER TABLE "organization_agents" ADD COLUMN "created_at" timestamp DEFAULT now() NOT NULL;--> statement-breakpoint
ALTER TABLE "organization_agents" ADD COLUMN "deleted_at" timestamp;
+1
View File
@@ -0,0 +1 @@
ALTER TABLE "two_factor" ADD COLUMN "verified" boolean NOT NULL;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+28
View File
@@ -344,6 +344,34 @@
"when": 1774886139855, "when": 1774886139855,
"tag": "0048_yellow_eddie_brock", "tag": "0048_yellow_eddie_brock",
"breakpoints": true "breakpoints": true
},
{
"idx": 49,
"version": "7",
"when": 1775585355435,
"tag": "0049_chief_terrax",
"breakpoints": true
},
{
"idx": 50,
"version": "7",
"when": 1775761395545,
"tag": "0050_dark_saracen",
"breakpoints": true
},
{
"idx": 51,
"version": "7",
"when": 1775762288699,
"tag": "0051_young_senator_kelly",
"breakpoints": true
},
{
"idx": 52,
"version": "7",
"when": 1775803959723,
"tag": "0052_cute_punisher",
"breakpoints": true
} }
] ]
} }
+1
View File
@@ -97,6 +97,7 @@ export const twoFactor = pgTable("two_factor", {
id: uuid().defaultRandom().primaryKey(), id: uuid().defaultRandom().primaryKey(),
secret: text("secret").notNull(), secret: text("secret").notNull(),
backupCodes: text("backup_codes").notNull(), backupCodes: text("backup_codes").notNull(),
verified: boolean("verified").notNull(),
userId: uuid("user_id") userId: uuid("user_id")
.notNull() .notNull()
.references(() => user.id, { onDelete: "cascade" }), .references(() => user.id, { onDelete: "cascade" }),
+2 -2
View File
@@ -1,5 +1,5 @@
import {pgTable, text, boolean, timestamp, uuid, integer, pgEnum} from "drizzle-orm/pg-core"; import {pgTable, text, boolean, timestamp, uuid, integer, pgEnum} from "drizzle-orm/pg-core";
import {Agent, agent} from "./08_agent"; import {Agent, agent, AgentWith} from "./08_agent";
import {Project, project} from "./06_project"; import {Project, project} from "./06_project";
import {relations} from "drizzle-orm"; import {relations} from "drizzle-orm";
import {dbmsEnum, statusEnum} from "./types"; import {dbmsEnum, statusEnum} from "./types";
@@ -123,7 +123,7 @@ export type RetentionPolicy = z.infer<typeof retentionPolicySchema>;
export type DatabaseWith = Database & { export type DatabaseWith = Database & {
agent?: Agent | null; agent?: Agent | AgentWith | null;
project?: Project | null; project?: Project | null;
backups?: Backup[] | null; backups?: Backup[] | null;
restorations?: Restoration[] | null; restorations?: Restoration[] | null;
+36 -1
View File
@@ -1,9 +1,10 @@
import {boolean, pgTable, text, timestamp, uuid, integer} from "drizzle-orm/pg-core"; import {boolean, pgTable, text, timestamp, uuid, integer, unique} from "drizzle-orm/pg-core";
import {createSelectSchema} from "drizzle-zod"; import {createSelectSchema} from "drizzle-zod";
import {z} from "zod"; import {z} from "zod";
import {Database, database} from "@/db/schema/07_database"; import {Database, database} from "@/db/schema/07_database";
import {relations} from "drizzle-orm"; import {relations} from "drizzle-orm";
import {timestamps} from "@/db/schema/00_common"; import {timestamps} from "@/db/schema/00_common";
import {organization} from "@/db/schema/03_organization";
export const agent = pgTable("agents", { export const agent = pgTable("agents", {
id: uuid("id").primaryKey().defaultRandom(), id: uuid("id").primaryKey().defaultRandom(),
@@ -14,22 +15,56 @@ export const agent = pgTable("agents", {
description: text("description").notNull(), description: text("description").notNull(),
isArchived: boolean("is_archived").default(false), isArchived: boolean("is_archived").default(false),
lastContact: timestamp("last_contact"), lastContact: timestamp("last_contact"),
organizationId: uuid("organization_id").references(() => organization.id, {onDelete: "cascade"}),
...timestamps ...timestamps
}); });
export const organizationAgent = pgTable(
"organization_agents",
{
organizationId: uuid('organization_id')
.notNull()
.references(() => organization.id, {onDelete: 'cascade'}),
agentId: uuid('agent_id')
.notNull()
.references(() => agent.id, {onDelete: 'cascade'}),
...timestamps
},
(t) => [unique().on(t.organizationId, t.agentId)]
);
export const agentSchema = createSelectSchema(agent); export const agentSchema = createSelectSchema(agent);
export type Agent = z.infer<typeof agentSchema>; export type Agent = z.infer<typeof agentSchema>;
export const agentRelations = relations(agent, ({many}) => ({ export const agentRelations = relations(agent, ({many}) => ({
databases: many(database), databases: many(database),
organizations: many(organizationAgent),
}));
export const organizationAgentRelations = relations(organizationAgent, ({one}) => ({
organization: one(organization, {
fields: [organizationAgent.organizationId],
references: [organization.id],
}),
agent: one(agent, {
fields: [organizationAgent.agentId],
references: [agent.id],
}),
})); }));
export type AgentWith = Agent & { export type AgentWith = Agent & {
databases?: Database[] | null; databases?: Database[] | null;
organizations: {
organizationId: string;
agentId: string;
}[];
}; };
export type AgentWithDatabases = Agent & { export type AgentWithDatabases = Agent & {
databases: Database[] | []; databases: Database[] | [];
}; };
-3
View File
@@ -4,8 +4,6 @@ import {organization} from "@/db/schema/03_organization";
import {relations} from "drizzle-orm"; import {relations} from "drizzle-orm";
import {createSelectSchema} from "drizzle-zod"; import {createSelectSchema} from "drizzle-zod";
import {z} from "zod"; import {z} from "zod";
import {database} from "@/db/schema/07_database";
export const providerStorageKindEnum = pgEnum('provider_storage_kind', ['local', 's3', 'google-drive']); export const providerStorageKindEnum = pgEnum('provider_storage_kind', ['local', 's3', 'google-drive']);
@@ -32,7 +30,6 @@ export const organizationStorageChannel = pgTable(
(t) => [unique().on(t.organizationId, t.storageChannelId)] (t) => [unique().on(t.organizationId, t.storageChannelId)]
); );
export const storageChannelRelations = relations(storageChannel, ({many}) => ({ export const storageChannelRelations = relations(storageChannel, ({many}) => ({
organizations: many(organizationStorageChannel), organizations: many(organizationStorageChannel),
})); }));
+1 -4
View File
@@ -1,13 +1,10 @@
import {boolean, pgTable, uuid} from "drizzle-orm/pg-core"; import {boolean, pgTable, uuid} from "drizzle-orm/pg-core";
import {timestamps} from "@/db/schema/00_common"; import {timestamps} from "@/db/schema/00_common";
import {relations} from "drizzle-orm"; import {relations} from "drizzle-orm";
import {Backup, Database, database, Restoration, RetentionPolicy} from "@/db/schema/07_database"; import {database} from "@/db/schema/07_database";
import {createSelectSchema} from "drizzle-zod"; import {createSelectSchema} from "drizzle-zod";
import {z} from "zod"; import {z} from "zod";
import {StorageChannel, storageChannel} from "@/db/schema/12_storage-channel"; import {StorageChannel, storageChannel} from "@/db/schema/12_storage-channel";
import {Agent} from "@/db/schema/08_agent";
import {Project} from "@/db/schema/06_project";
import {AlertPolicy} from "@/db/schema/10_alert-policy";
export const storagePolicy = pgTable('storage_policy', { export const storagePolicy = pgTable('storage_policy', {
id: uuid('id').defaultRandom().primaryKey(), id: uuid('id').defaultRandom().primaryKey(),
+43
View File
@@ -0,0 +1,43 @@
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
.select({
id: agent.id,
name: agent.name,
organizationId: agent.organizationId,
slug: agent.slug,
healthErrorCount: agent.healthErrorCount,
description: agent.description,
isArchived: agent.isArchived,
lastContact: agent.lastContact,
version: agent.version,
updatedAt: agent.updatedAt,
createdAt: agent.createdAt,
deletedAt: agent.deletedAt,
databases: sql<Database[]>`
COALESCE(
json_agg(${database}.*) FILTER (WHERE ${database}.id IS NOT NULL),
'[]'
)
`,
})
.from(organizationAgent)
.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[];
}
+39
View File
@@ -0,0 +1,39 @@
"use server"
import {db} from "@/db";
import {DatabaseWith} from "@/db/schema/07_database";
import {AgentWith} from "@/db/schema/08_agent";
export async function getOrganizationAvailableDatabases(
organizationId: string,
projectId?: string
) {
const availableDatabases = (
await db.query.database.findMany({
where: (db, { eq, or, isNull }) =>
projectId
? or(isNull(db.projectId), eq(db.projectId, projectId))
: isNull(db.projectId),
with: {
agent: {
with: {
organizations: true
}
},
project: true,
backups: true,
restorations: true,
},
orderBy: (db, {desc}) => [desc(db.createdAt)],
})
) as DatabaseWith[];
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)
);
})
}
+17 -5
View File
@@ -10,18 +10,30 @@ import {getHealthLast12hLogs} from "@/db/services/healthcheck";
const verifySlugUniqueness = async (slug: string, agentId?: string) => { const verifySlugUniqueness = async (slug: string, agentId?: string) => {
const conditions = agentId ? and(eq(drizzleDb.schemas.agent.slug, slug), ne(drizzleDb.schemas.agent.id, agentId)) : eq(drizzleDb.schemas.agent.slug, slug); const conditions = agentId ? and(eq(drizzleDb.schemas.agent.slug, slug), ne(drizzleDb.schemas.agent.id, agentId)) : eq(drizzleDb.schemas.agent.slug, slug);
const [countResult] = await db.select({count: count()}).from(drizzleDb.schemas.agent).where(conditions); const [countResult] = await db.select({count: count()}).from(drizzleDb.schemas.agent).where(conditions);
if (countResult.count > 0) { if (countResult.count > 0) {
throw new ActionError("Slug already exists"); throw new ActionError("Slug already exists");
} }
}; };
export const createAgentAction = userAction.schema(AgentSchema).action(async ({parsedInput}) => { export const createAgentAction = userAction.schema(
const slug = slugify(parsedInput.name); z.object({
organizationId: z.string().optional(),
data: AgentSchema,
})
).action(async ({parsedInput}) => {
const slug = slugify(parsedInput.data.name);
await verifySlugUniqueness(slug); await verifySlugUniqueness(slug);
const [createdAgent] = await db.insert(drizzleDb.schemas.agent).values({...parsedInput, slug: slug}).returning();
const [createdAgent] = await db.insert(drizzleDb.schemas.agent).values({...parsedInput.data, slug: slug, organizationId: parsedInput.organizationId}).returning();
if (createdAgent && parsedInput.organizationId){
await db.insert(drizzleDb.schemas.organizationAgent).values({
organizationId: parsedInput.organizationId,
agentId: createdAgent.id,
});
}
return { return {
data: createdAgent, data: createdAgent,
}; };
+1
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
export const AgentSchema = z.object({ export const AgentSchema = z.object({
name: z.string().nonempty("Name is required"), name: z.string().nonempty("Name is required"),
description: z.string(), description: z.string(),
}); });
export type AgentType = z.infer<typeof AgentSchema>; export type AgentType = z.infer<typeof AgentSchema>;
@@ -0,0 +1,129 @@
"use server"
import {userAction} from "@/lib/safe-actions/actions";
import {z} from "zod";
import {ServerActionResult} from "@/types/action-type";
import {db} from "@/db";
import {and, eq, inArray} from "drizzle-orm";
import * as drizzleDb from "@/db";
import {AgentWith} from "@/db/schema/08_agent";
import {withUpdatedAt} from "@/db/utils";
export const updateAgentOrganizationsAction = userAction
.schema(
z.object({
data: z.array(z.string()),
id: z.string(),
})
)
.action(async ({parsedInput , ctx}): Promise<ServerActionResult<null>> => {
try {
const organizationsIds = parsedInput.data;
const agentId = parsedInput.id;
const agent = await db.query.agent.findFirst({
where: eq(drizzleDb.schemas.agent.id, agentId),
with: {
organizations: true,
databases: true
}
}) as AgentWith;
if (!agent) {
return {
success: false,
actionError: {
message: "Agent not found.",
status: 404,
cause: "not_found",
},
};
}
const existingItemIds = agent.organizations.map((organization) => organization.organizationId);
const organizationsToAdd = organizationsIds.filter((id) => !existingItemIds.includes(id));
const organizationsToRemove = existingItemIds.filter((id) => !organizationsIds.includes(id));
if (organizationsToAdd.length > 0) {
for (const organizationToAdd of organizationsToAdd) {
await db.insert(drizzleDb.schemas.organizationAgent).values({
organizationId: organizationToAdd,
agentId: agentId
});
}
}
if (organizationsToRemove.length > 0) {
await db.delete(drizzleDb.schemas.organizationAgent).where(and(inArray(drizzleDb.schemas.organizationAgent.organizationId, organizationsToRemove), eq(drizzleDb.schemas.organizationAgent.agentId,agentId))).execute();
const organizationsToRemoveDetails = await db.query.organization.findMany({
where: inArray(drizzleDb.schemas.organization.id, organizationsToRemove),
with: {
projects: true
}
});
const projectIds = organizationsToRemoveDetails.flatMap(org =>
org.projects.map(project => project.id)
);
if (projectIds.length > 0) {
const databases = await db.query.database.findMany({
where: (db, { inArray }) => inArray(db.projectId, projectIds),
columns: { id: true }
});
const databaseIds = databases.map(d => d.id);
await db
.update(drizzleDb.schemas.database)
.set(withUpdatedAt({
backupPolicy: null,
projectId: null
}))
.where(inArray(drizzleDb.schemas.database.projectId, projectIds))
.execute();
await db.delete(drizzleDb.schemas.retentionPolicy)
.where(inArray(drizzleDb.schemas.retentionPolicy.databaseId, databaseIds))
.execute();
await db.delete(drizzleDb.schemas.alertPolicy)
.where(inArray(drizzleDb.schemas.alertPolicy.databaseId, databaseIds))
.execute();
await db.delete(drizzleDb.schemas.storagePolicy)
.where(inArray(drizzleDb.schemas.storagePolicy.databaseId, databaseIds))
.execute();
}
}
return {
success: true,
value: null,
actionSuccess: {
message: "Agent organizations has been successfully updated.",
messageParams: {agentId: agentId},
},
};
} catch (error) {
console.error("Error updating agent organizations:", error);
return {
success: false,
actionError: {
message: "Failed to update agent organizations.",
status: 500,
cause: "server_error",
messageParams: {message: "Error updating the agent organizations"},
},
};
}
});
@@ -0,0 +1,106 @@
"use client";
import {useRouter} from "next/navigation";
import {useMutation} from "@tanstack/react-query";
import {Form, FormControl, FormField, FormItem, useZodForm} from "@/components/ui/form";
import {ButtonWithLoading} from "@/components/wrappers/common/button/button-with-loading";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {MultiSelect} from "@/components/wrappers/common/multiselect/multi-select";
import {toast} from "sonner";
import {AgentWith} from "@/db/schema/08_agent";
import {AgentOrganizationSchema, AgentOrganizationType} from "@/features/agents/components/agent-organizations.schema";
import {updateAgentOrganizationsAction} from "@/features/agents/components/agent-organizations.action";
type AgentOrganisationFormProps = {
organizations?: OrganizationWithMembers[];
defaultValues?: AgentWith
};
export const AgentOrganisationForm = ({
organizations,
defaultValues,
}: AgentOrganisationFormProps) => {
const router = useRouter();
const defaultOrganizationIds = defaultValues?.organizations?.map(organization => organization.organizationId) ?? []
const form = useZodForm({
schema: AgentOrganizationSchema,
// @ts-ignore
defaultValues: {
organizations: defaultOrganizationIds
},
});
const formatOrganizationsList = (organizations: OrganizationWithMembers[]) => {
return organizations
.map((organization) => ({
value: organization.id,
label: `${organization.name}`,
}));
};
const mutation = useMutation({
mutationFn: async (values: AgentOrganizationType) => {
const payload = {
data: values.organizations,
id: defaultValues?.id ?? ""
};
const result = await updateAgentOrganizationsAction(payload)
const inner = result?.data;
if (inner?.success) {
toast.success(inner.actionSuccess?.message);
router.refresh();
} else {
toast.error(inner?.actionError?.message);
}
}
});
return (
<Form
form={form}
className="flex flex-col gap-4"
onSubmit={async (values) => {
await mutation.mutateAsync(values);
}}
>
<FormField
control={form.control}
name={`organizations`}
render={({field}) => (
<FormItem>
<FormControl>
<MultiSelect
options={formatOrganizationsList(organizations ?? [])}
onValueChange={field.onChange}
defaultValue={field.value ?? []}
placeholder="Select organization(s)"
variant="inverted"
animation={0}
/>
</FormControl>
</FormItem>
)}
/>
<div className="flex justify-end">
<div className="flex gap-2 justify-end">
<ButtonWithLoading isPending={mutation.isPending}>
Save
</ButtonWithLoading>
</div>
</div>
</Form>
);
};
@@ -0,0 +1,7 @@
import {z} from "zod";
export const AgentOrganizationSchema = z.object({
organizations: z.array(z.string().uuid())
});
export type AgentOrganizationType = z.infer<typeof AgentOrganizationSchema>;
@@ -10,19 +10,24 @@ import {
import {AgentForm} from "@/features/agents/components/agent.form"; import {AgentForm} from "@/features/agents/components/agent.form";
import {Button, buttonVariants} from "@/components/ui/button"; import {Button, buttonVariants} from "@/components/ui/button";
import {Plus} from "lucide-react"; import {Plus} from "lucide-react";
import {AgentType} from "@/features/agents/agents.schema";
import {GearIcon} from "@radix-ui/react-icons"; import {GearIcon} from "@radix-ui/react-icons";
import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder"; import {EmptyStatePlaceholder} from "@/components/wrappers/common/empty-state-placeholder";
import {useState} from "react"; import {useState} from "react";
import {useRouter} from "next/navigation"; import {useRouter} from "next/navigation";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
import {Tabs, TabsContent, TabsList, TabsTrigger} from "@/components/ui/tabs";
import {AgentOrganisationForm} from "@/features/agents/components/agent-organizations.form";
import {AgentWith} from "@/db/schema/08_agent";
type AgentDialogProps = { type AgentDialogProps = {
agent?: AgentType & { id: string }; agent?: AgentWith;
typeTrigger: "edit" | "empty" | "create"; typeTrigger: "edit" | "empty" | "create";
organization?: OrganizationWithMembers;
adminView?: boolean,
organizations?: OrganizationWithMembers[];
}; };
export const AgentDialog = ({agent, typeTrigger, organization, adminView, organizations}: AgentDialogProps) => {
export const AgentDialog = ({agent, typeTrigger}: AgentDialogProps) => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const isEdit = !!agent; const isEdit = !!agent;
const router = useRouter(); const router = useRouter();
@@ -36,7 +41,7 @@ export const AgentDialog = ({agent, typeTrigger}: AgentDialogProps) => {
</div> </div>
); );
case "empty": case "empty":
return <EmptyStatePlaceholder text="Create new Agent"/>; return <EmptyStatePlaceholder className="h-full" text="Create new Agent"/>;
case "create": case "create":
return <Button><Plus className="mr-2 h-4 w-4"/> Create Agent</Button>; return <Button><Plus className="mr-2 h-4 w-4"/> Create Agent</Button>;
default: default:
@@ -53,7 +58,16 @@ export const AgentDialog = ({agent, typeTrigger}: AgentDialogProps) => {
<DialogHeader> <DialogHeader>
<DialogTitle>{isEdit ? `Edit ${agent.name}` : "Create new agent"}</DialogTitle> <DialogTitle>{isEdit ? `Edit ${agent.name}` : "Create new agent"}</DialogTitle>
</DialogHeader> </DialogHeader>
<>
{adminView ?
<Tabs className="flex flex-col flex-1" defaultValue="configuration">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="configuration">Configuration</TabsTrigger>
<TabsTrigger value="organizations">Organizations</TabsTrigger>
</TabsList>
<TabsContent className="h-full justify-between" value="configuration">
<AgentForm <AgentForm
organization={organization}
onSuccess={() => { onSuccess={() => {
setOpen(false) setOpen(false)
router.refresh() router.refresh()
@@ -61,6 +75,28 @@ export const AgentDialog = ({agent, typeTrigger}: AgentDialogProps) => {
defaultValues={agent} defaultValues={agent}
agentId={agent?.id} agentId={agent?.id}
/> />
</TabsContent>
<TabsContent className="h-full justify-between" value="organizations">
<AgentOrganisationForm
defaultValues={agent}
organizations={organizations}
/>
</TabsContent>
</Tabs>
:
<>
<AgentForm
organization={organization}
onSuccess={() => {
setOpen(false)
router.refresh()
}}
defaultValues={agent}
agentId={agent?.id}
/>
</>
}
</>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
); );
@@ -18,11 +18,14 @@ import {TooltipProvider} from "@/components/ui/tooltip";
import {AgentSchema, AgentType} from "@/features/agents/agents.schema"; import {AgentSchema, AgentType} from "@/features/agents/agents.schema";
import {toast} from "sonner"; import {toast} from "sonner";
import {createAgentAction, updateAgentAction} from "@/features/agents/agents.action"; import {createAgentAction, updateAgentAction} from "@/features/agents/agents.action";
import {OrganizationWithMembers} from "@/db/schema/03_organization";
export type agentFormProps = { export type agentFormProps = {
defaultValues?: AgentType; defaultValues?: AgentType;
agentId?: string; agentId?: string;
onSuccess?: (data: any) => void; onSuccess?: (data: any) => void;
organization?: OrganizationWithMembers;
}; };
export const AgentForm = (props: agentFormProps) => { export const AgentForm = (props: agentFormProps) => {
@@ -40,7 +43,10 @@ export const AgentForm = (props: agentFormProps) => {
mutationFn: async (values: AgentType) => { mutationFn: async (values: AgentType) => {
const createAgent = isCreate const createAgent = isCreate
? await createAgentAction(values) ? await createAgentAction({
organizationId: props.organization?.id ?? undefined,
data: values
})
: await updateAgentAction({ : await updateAgentAction({
id: props.agentId ?? "-", id: props.agentId ?? "-",
data: values, data: values,
@@ -61,7 +67,7 @@ export const AgentForm = (props: agentFormProps) => {
if (props.onSuccess) { if (props.onSuccess) {
props.onSuccess(data); props.onSuccess(data);
} else { } else {
router.push(`/dashboard/agents/${data.id}`); router.push(props.organization ?`/dashboard/settings/agents/${data.id}` : `/dashboard/agents/${data.id}`);
} }
}, },
}); });
+2
View File
@@ -7,6 +7,7 @@ export type OrganizationPermissions = {
isAdmin: boolean; isAdmin: boolean;
isMember: boolean; isMember: boolean;
canManageAgents: boolean;
canManageSettings: boolean; canManageSettings: boolean;
canManageUsers: boolean; canManageUsers: boolean;
canManageNotifications: boolean; canManageNotifications: boolean;
@@ -31,6 +32,7 @@ export const computeOrganizationPermissions = (
isMember, isMember,
canManageSettings: isOwner || isAdmin, canManageSettings: isOwner || isAdmin,
canManageAgents: isOwner || isAdmin,
canManageUsers: isOwner || isAdmin, canManageUsers: isOwner || isAdmin,
canManageNotifications: isOwner || isAdmin, canManageNotifications: isOwner || isAdmin,
canManageStorages: isOwner || isAdmin, canManageStorages: isOwner || isAdmin,
+1 -1
View File
@@ -30,7 +30,7 @@ export const auth = betterAuth({
onAPIError: { onAPIError: {
errorURL: "/error", errorURL: "/error",
onError: (error, ctx) => { onError: (error, ctx) => {
//todo: capture errors in a monitoring service //TODO: capture errors in a monitoring service
}, },
}, },
database: drizzleAdapter(db, { database: drizzleAdapter(db, {
+2 -1
View File
@@ -6,6 +6,7 @@ import {cleaningHealthcheckLogsJob, cleaningJob, healthcheckAgentAndDatabaseJob,
import { generateRSAKeys, getOrCreateMasterKey } from "@/utils/rsa-keys"; import { generateRSAKeys, getOrCreateMasterKey } from "@/utils/rsa-keys";
import { StorageProviderKind } from "@/features/storages/types"; import { StorageProviderKind } from "@/features/storages/types";
import {logger} from "@/lib/logger"; import {logger} from "@/lib/logger";
import {withUpdatedAt} from "@/db/utils";
const log = logger.child({module: "init"}); const log = logger.child({module: "init"});
@@ -103,7 +104,7 @@ async function createSettingsIfNotExist() {
if (!finalSystemSetting.defaultStorageChannelId) { if (!finalSystemSetting.defaultStorageChannelId) {
await tx await tx
.update(drizzleDb.schemas.setting) .update(drizzleDb.schemas.setting)
.set({ defaultStorageChannelId: localStorage.id }) .set(withUpdatedAt({ defaultStorageChannelId: localStorage.id }))
.where(eq(drizzleDb.schemas.setting.id, finalSystemSetting.id)); .where(eq(drizzleDb.schemas.setting.id, finalSystemSetting.id));
} }
}); });