mirror of
https://github.com/Portabase/portabase.git
synced 2026-07-14 11:16:13 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
666d677349 | ||
|
|
1afc6b8c62 | ||
|
|
9f15b49345 | ||
|
|
6d19fb8833 | ||
|
|
df692c4729 | ||
|
|
cd72cfbff4 | ||
|
|
20636bf6f2 | ||
|
|
8f2d6f0b01 | ||
|
|
64b3bf1b16 | ||
|
|
dc4190aa1b | ||
|
|
6efcb55638 | ||
|
|
6b89cdfa1d | ||
|
|
693ef6ec3e | ||
|
|
de33dea50d | ||
|
|
d5e453c390 | ||
|
|
ff65590c97 | ||
|
|
503d5469d4 | ||
|
|
96c51a08c6 | ||
|
|
fe1d077754 | ||
|
|
f2acc103a7 | ||
|
|
c073a30d20 | ||
|
|
bdc863de1f | ||
|
|
97e2c21d52 | ||
|
|
60f57a734d | ||
|
|
6952ac5f48 | ||
|
|
45acc781f2 | ||
|
|
9c2359e87b | ||
|
|
8dcb406790 | ||
|
|
a3d0a6e1df |
+1
-1
@@ -33,5 +33,5 @@ keywords:
|
||||
- web-ui
|
||||
- agent
|
||||
license: Apache-2.0
|
||||
version: 1.21.1
|
||||
version: 1.22.0
|
||||
date-released: '2026-03-02'
|
||||
|
||||
@@ -78,6 +78,7 @@ You have 5 ways to install Portabase:
|
||||
| **Valkey** | ✅ Stable | 7.2+ | No |
|
||||
| **Firebird** | ✅ Stable | 3.0, 4.0, 5.0 | Yes |
|
||||
| **MSSQL Server** | ✅ Stable | 2017, 2019, 2022, Azure SQL | Yes |
|
||||
| **Docker Volume** | ✅ Stable | Docker Engine 20.10+ | Yes |
|
||||
|
||||
See the [Database Servers documentation](https://portabase.io/docs/agent/db) for version-specific backup and restore details.
|
||||
|
||||
@@ -111,5 +112,3 @@ Distributed under the Apache License. See `LICENSE.txt` for more details.
|
||||
[ShadcnUI-url]: https://ui.shadcn.com/
|
||||
|
||||
[Docker-url]: https://www.docker.com/
|
||||
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ export default async function RoutePage(
|
||||
</PageDescription>
|
||||
)}
|
||||
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
|
||||
<AgentContentPage agent={agent} edgeKey={edgeKey} />
|
||||
<AgentContentPage agent={agent} edgeKey={edgeKey} canDeleteDatabases={true} />
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Page, PageContent, PageTitle } from "@/features/layout/components/page"
|
||||
import { ButtonDeleteProject } from "@/features/projects/components/project-delete-button";
|
||||
import { CardsWithPagination } from "@/components/common/cards-with-pagination";
|
||||
import { ProjectDatabaseCard } from "@/features/projects/components/project-database-card";
|
||||
import { ProjectDatabaseSelection } from "@/features/projects/components/project-database-selection";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { db } from "@/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
@@ -87,20 +88,27 @@ export default async function RoutePage(
|
||||
</div>
|
||||
<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(),
|
||||
)}
|
||||
organizationSlug={organization.slug}
|
||||
// @ts-ignore
|
||||
cardItem={ProjectDatabaseCard}
|
||||
cardsPerPage={20}
|
||||
numberOfColumns={3}
|
||||
pageSizeOptions={[10, 20, 50]}
|
||||
extendedProps={proj}
|
||||
/>
|
||||
isMember ? (
|
||||
<CardsWithPagination
|
||||
data={[...proj.databases].sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() -
|
||||
new Date(a.createdAt).getTime(),
|
||||
)}
|
||||
organizationSlug={organization.slug}
|
||||
// @ts-ignore
|
||||
cardItem={ProjectDatabaseCard}
|
||||
cardsPerPage={20}
|
||||
numberOfColumns={3}
|
||||
pageSizeOptions={[10, 20, 50]}
|
||||
extendedProps={proj}
|
||||
/>
|
||||
) : (
|
||||
<ProjectDatabaseSelection
|
||||
projectId={proj.id}
|
||||
databases={proj.databases}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
<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>
|
||||
|
||||
@@ -94,7 +94,7 @@ export default async function RoutePage(
|
||||
</PageDescription>
|
||||
)}
|
||||
<PageContent className="flex flex-col w-full h-full justify-between gap-6">
|
||||
<AgentContentPage agent={agent} edgeKey={edgeKey}/>
|
||||
<AgentContentPage agent={agent} edgeKey={edgeKey} canDeleteDatabases={!!agent.organizationId}/>
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
|
||||
@@ -77,10 +77,7 @@ export async function handleDatabases(body: Body, agent: Agent, lastContact: Dat
|
||||
})
|
||||
.returning();
|
||||
|
||||
|
||||
if (databaseCreated) {
|
||||
|
||||
|
||||
await dbClient
|
||||
.insert(drizzleDb.schemas.healthcheckLog)
|
||||
.values({
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import {NextResponse} from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {and, eq} from "drizzle-orm";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {env} from "@/env.mjs";
|
||||
import {logger} from "@/lib/logger";
|
||||
|
||||
const log = logger.child({module: "api/tus/hooks"});
|
||||
|
||||
const FILE_PATH_RE = /^backups\/\d{4}-\d{2}-\d{2}\/[A-Za-z0-9._-]+$/;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
@@ -24,17 +29,61 @@ export async function POST(request: Request) {
|
||||
event.Upload.Offset === event.Upload.Size
|
||||
) {
|
||||
const id = event.Upload.ID;
|
||||
const fileName = headers["X-File-Name"]?.[0];
|
||||
const filePath = headers["X-File-Path"]?.[0];
|
||||
|
||||
if (!filePath) {
|
||||
return NextResponse.json({error: "Missing X-File-Path"}, {status: 500});
|
||||
if (!filePath || !FILE_PATH_RE.test(filePath)) {
|
||||
log.warn({filePath}, "Rejected invalid X-File-Path in TUS hook");
|
||||
return NextResponse.json({error: "Invalid file path"}, {status: 400});
|
||||
}
|
||||
|
||||
const generatedId = headers["X-Generated-Id"]?.[0];
|
||||
const backupStorageId = headers["X-Backup-Storage-Id"]?.[0];
|
||||
|
||||
|
||||
console.log(`Backup Storage ID : ${backupStorageId}`);
|
||||
console.log(`generatedId : ${generatedId}`);
|
||||
|
||||
if (backupStorageId) {
|
||||
if (!generatedId) {
|
||||
log.warn("Missing X-Generated-Id in TUS hook");
|
||||
return NextResponse.json({error: "Forbidden"}, {status: 403});
|
||||
}
|
||||
|
||||
const database = await db.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.agentDatabaseId, generatedId),
|
||||
});
|
||||
|
||||
if (!database) {
|
||||
log.warn({generatedId}, "No database for X-Generated-Id in TUS hook");
|
||||
return NextResponse.json({error: "Forbidden"}, {status: 403});
|
||||
}
|
||||
|
||||
const storage = await db.query.backupStorage.findFirst({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.backupStorage.id, backupStorageId),
|
||||
),
|
||||
with: {backup: true},
|
||||
});
|
||||
|
||||
if (!storage || storage.backup?.databaseId !== database.id) {
|
||||
log.warn({backupStorageId, generatedId}, "No pending backup storage matches TUS hook");
|
||||
return NextResponse.json({error: "Forbidden"}, {status: 403});
|
||||
}
|
||||
} else {
|
||||
log.warn("TUS hook without X-Backup-Storage-Id (legacy agent)");
|
||||
}
|
||||
|
||||
const uploadDir = path.join(env.PRIVATE_PATH!, "/uploads/");
|
||||
|
||||
const oldFilePath = path.join(uploadDir, "tmp", id);
|
||||
const newFilePath = path.join(uploadDir, filePath);
|
||||
|
||||
const resolvedBase = path.resolve(uploadDir);
|
||||
const newFilePath = path.resolve(resolvedBase, filePath);
|
||||
const rel = path.relative(resolvedBase, newFilePath);
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||
log.warn({filePath}, "Rejected path traversal in TUS hook");
|
||||
return NextResponse.json({error: "Invalid file path"}, {status: 400});
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(newFilePath), {recursive: true});
|
||||
|
||||
@@ -74,7 +123,7 @@ export async function POST(request: Request) {
|
||||
}
|
||||
return NextResponse.json({});
|
||||
} catch (error) {
|
||||
log.error({error: error},"TUS Hook error");
|
||||
log.error({error: error}, "TUS Hook error");
|
||||
return NextResponse.json({error: "Internal server error"}, {status: 500});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-1
@@ -14,7 +14,12 @@ const queryClient = new QueryClient();
|
||||
export const Providers = (props: ProviderProps) => {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<ThemeProvider
|
||||
attribute="class"
|
||||
defaultTheme="light"
|
||||
enableSystem
|
||||
storageKey="portabase_theme"
|
||||
>
|
||||
<ThemeMetaUpdater />
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ErrorLayout>
|
||||
|
||||
@@ -30,6 +30,11 @@ http {
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
location = /api/tus/hooks {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:3000;
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "portabase",
|
||||
"version": "1.21.1",
|
||||
"version": "1.22.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 8887",
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import Image from "next/image";
|
||||
import {useState} from "react";
|
||||
import {Card} from "@/components/ui/card";
|
||||
@@ -8,28 +6,54 @@ import {ConnectionIndicator} from "@/components/common/connection-indicator";
|
||||
import {formatDateLastContact} from "@/utils/date-formatting";
|
||||
import {Database} from "@/db/schema/07_database";
|
||||
import {ChevronRight, Activity, Fingerprint, Copy, Check} from "lucide-react";
|
||||
import {cn} from "@/lib/utils";
|
||||
|
||||
export type DatabaseCardProps = {
|
||||
data: Database;
|
||||
withDetails?: boolean;
|
||||
selectable?: boolean;
|
||||
selected?: boolean;
|
||||
onToggleSelect?: (databaseId: string) => void;
|
||||
deleteButton?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const DatabaseCard = (props: DatabaseCardProps) => {
|
||||
const {data: database, withDetails = true} = props;
|
||||
const {data: database, withDetails = true, selectable = false, selected = false, onToggleSelect, deleteButton} = props;
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
|
||||
const handleCopy = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
navigator.clipboard.writeText(database.agentDatabaseId);
|
||||
navigator.clipboard.writeText(database.agentDatabaseId ?? "");
|
||||
setIsCopied(true);
|
||||
setTimeout(() => setIsCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="relative h-full flex flex-col p-4 transition-all border-border/50 bg-card hover:bg-accent/50 hover:border-primary/50 group-hover:shadow-lg overflow-hidden gap-0">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
className={cn(
|
||||
"relative h-full flex flex-col p-4 transition-all border-border/50 bg-card hover:bg-accent/50 hover:border-primary/50 group-hover:shadow-lg overflow-hidden gap-0",
|
||||
selected && "ring-1 ring-primary border-primary",
|
||||
)}>
|
||||
{selectable && (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
aria-label={selected ? "Deselect database" : "Select database"}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onToggleSelect?.(database.id);
|
||||
}}
|
||||
className={cn(
|
||||
"absolute top-3 left-3 z-20 w-5 h-5 rounded-md border-2 flex items-center justify-center transition-colors",
|
||||
selected ? "bg-primary border-primary text-primary-foreground" : "border-muted-foreground/50 bg-background/70",
|
||||
)}
|
||||
>
|
||||
{selected && <Check className="w-3 h-3" />}
|
||||
</button>
|
||||
)}
|
||||
<div className={cn("flex items-start justify-between mb-2", selectable && "pl-7")}>
|
||||
<div
|
||||
className="relative w-12 h-12 p-2 bg-background rounded-xl border border-border/50 shadow-sm flex items-center justify-center group-hover:border-primary/30 transition-all duration-300 group-hover:scale-105">
|
||||
<Image
|
||||
@@ -82,7 +106,12 @@ export const DatabaseCard = (props: DatabaseCardProps) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{withDetails && (
|
||||
{deleteButton ? (
|
||||
<div className="mt-4 flex items-center justify-end pt-3 border-t border-border/50"
|
||||
onClick={(e) => e.preventDefault()}>
|
||||
{deleteButton}
|
||||
</div>
|
||||
) : withDetails ? (
|
||||
<div className="mt-4 flex items-center justify-between pt-3 border-t border-border/50">
|
||||
<span
|
||||
className="text-[10px] font-black uppercase tracking-widest text-primary opacity-0 group-hover:opacity-100 transition-all duration-300 transform translate-x-[-10px] group-hover:translate-x-0">Open</span>
|
||||
@@ -93,7 +122,7 @@ export const DatabaseCard = (props: DatabaseCardProps) => {
|
||||
<ChevronRight className="w-3.5 h-3.5 group-hover:translate-x-1 transition-transform"/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : null}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE "databases" ALTER COLUMN "agent_database_id" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "databases" ALTER COLUMN "agent_id" DROP NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -491,6 +491,13 @@
|
||||
"when": 1782937885439,
|
||||
"tag": "0069_worried_alex_wilder",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 70,
|
||||
"version": "7",
|
||||
"when": 1783264084846,
|
||||
"tag": "0070_illegal_joseph",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import {JobLog, jobLog} from "@/db/schema/17_job-log";
|
||||
|
||||
export const database = pgTable("databases", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
agentDatabaseId: uuid("agent_database_id").notNull().defaultRandom(),
|
||||
agentDatabaseId: uuid("agent_database_id").defaultRandom(),
|
||||
name: text("name").notNull(),
|
||||
dbms: dbmsEnum("dbms").notNull(),
|
||||
description: text("description"),
|
||||
@@ -22,7 +22,6 @@ export const database = pgTable("databases", {
|
||||
backupToRestore: text("backup_to_restore"),
|
||||
healthErrorCount: integer("health_error_count"),
|
||||
agentId: uuid("agent_id")
|
||||
.notNull()
|
||||
.references(() => agent.id, {onDelete: "cascade"}),
|
||||
lastContact: timestamp("last_contact"),
|
||||
projectId: uuid("project_id")
|
||||
|
||||
@@ -16,10 +16,13 @@ export async function getOrganizationAvailableDatabases(
|
||||
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),
|
||||
where: (db, { eq, or, and, isNull }) =>
|
||||
and(
|
||||
projectId
|
||||
? or(isNull(db.projectId), eq(db.projectId, projectId))
|
||||
: isNull(db.projectId),
|
||||
isNull(db.deletedAt),
|
||||
),
|
||||
with: {
|
||||
agent: {
|
||||
with: {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { db } from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import { and, eq, gte, isNotNull, lt } from "drizzle-orm";
|
||||
import { and, eq, gte, isNotNull, isNull, lt } from "drizzle-orm";
|
||||
import { dispatchNotification } from "@/features/notifications/utils/notifications.dispatch";
|
||||
import { EventPayload } from "@/features/notifications/types";
|
||||
import { logger } from "@/lib/logger";
|
||||
@@ -113,7 +113,10 @@ export async function checkAgentsHealthError() {
|
||||
|
||||
export async function checkDatabasesHealthError() {
|
||||
const databases = await db.query.database.findMany({
|
||||
where: isNotNull(drizzleDb.schemas.database.lastContact),
|
||||
where: and(
|
||||
isNotNull(drizzleDb.schemas.database.lastContact),
|
||||
isNull(drizzleDb.schemas.database.deletedAt),
|
||||
),
|
||||
with: {
|
||||
agent: true,
|
||||
alertPolicies: true,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { getOrganization } from "@/lib/auth/auth";
|
||||
import { db } from "@/db";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, isNull } from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import { project } from "@/db/schema/06_project";
|
||||
|
||||
@@ -10,7 +10,9 @@ export async function getOrganizationProject(organizationId: string) {
|
||||
return db.query.project.findFirst({
|
||||
where: eq(project.organizationId, organizationId),
|
||||
with: {
|
||||
databases: true,
|
||||
databases: {
|
||||
where: isNull(drizzleDb.schemas.database.deletedAt),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -39,7 +41,9 @@ export const getOrganizationProjectDatabases = async ({
|
||||
eq(drizzleDb.schemas.project.id, projectId),
|
||||
),
|
||||
with: {
|
||||
databases: true,
|
||||
databases: {
|
||||
where: isNull(drizzleDb.schemas.database.deletedAt),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 {and, eq, inArray, isNull} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {AgentWith} from "@/db/schema/08_agent";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
@@ -25,7 +25,9 @@ export const updateAgentOrganizationsAction = userAction
|
||||
where: eq(drizzleDb.schemas.agent.id, agentId),
|
||||
with: {
|
||||
organizations: true,
|
||||
databases: true
|
||||
databases: {
|
||||
where: isNull(drizzleDb.schemas.database.deletedAt),
|
||||
},
|
||||
}
|
||||
}) as AgentWith;
|
||||
|
||||
@@ -70,7 +72,7 @@ export const updateAgentOrganizationsAction = userAction
|
||||
|
||||
if (projectIds.length > 0) {
|
||||
const databases = await db.query.database.findMany({
|
||||
where: (db, { inArray }) => inArray(db.projectId, projectIds),
|
||||
where: (db, { inArray, and, isNull }) => and(inArray(db.projectId, projectIds), isNull(db.deletedAt)),
|
||||
columns: { id: true }
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import {action, ActionError, userAction} from "@/lib/safe-actions/actions";
|
||||
import {AgentSchema} from "@/features/agents/schemas/agents.schema";
|
||||
import {z} from "zod";
|
||||
import {eq, and, ne, count, desc} from "drizzle-orm";
|
||||
import {eq, and, ne, count, desc, isNull} from "drizzle-orm";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {slugify} from "@/utils/slugify";
|
||||
@@ -87,7 +87,9 @@ export const getAgentAction = userAction.schema(z.string()).action(async ({parse
|
||||
const agent = await db.query.agent.findFirst({
|
||||
where: eq(drizzleDb.schemas.agent.id, parsedInput),
|
||||
with: {
|
||||
databases: true
|
||||
databases: {
|
||||
where: isNull(drizzleDb.schemas.database.deletedAt),
|
||||
},
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -23,9 +23,10 @@ import {HealthcheckLog} from "@/db/schema/15_healthcheck-log";
|
||||
type AgentContentPageProps = {
|
||||
edgeKey: string;
|
||||
agent: AgentWithDatabases
|
||||
canDeleteDatabases?: boolean
|
||||
}
|
||||
|
||||
export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPageProps) => {
|
||||
export const AgentContentPage = ({edgeKey, agent: initialAgent, canDeleteDatabases = false}: AgentContentPageProps) => {
|
||||
|
||||
const {data} = useQuery({
|
||||
queryKey: ["agent-data", initialAgent.id],
|
||||
@@ -121,6 +122,7 @@ export const AgentContentPage = ({edgeKey, agent: initialAgent}: AgentContentPag
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
)}
|
||||
cardItem={AgentDatabaseCard}
|
||||
canDeleteDatabases={canDeleteDatabases}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
"use client";
|
||||
|
||||
import {Database} from "@/db/schema/07_database";
|
||||
import {DatabaseCard} from "@/components/common/database-card";
|
||||
import { Database } from "@/db/schema/07_database";
|
||||
import { DatabaseCard } from "@/components/common/database-card";
|
||||
import { DatabaseDeleteButton } from "@/features/agents/components/database-delete-button";
|
||||
|
||||
export type agentDatabaseCardProps = {
|
||||
export type AgentDatabaseCardProps = {
|
||||
data: Database;
|
||||
canDeleteDatabases?: boolean;
|
||||
};
|
||||
|
||||
export const AgentDatabaseCard = (props: agentDatabaseCardProps) => {
|
||||
const {data: database} = props;
|
||||
export const AgentDatabaseCard = (props: AgentDatabaseCardProps) => {
|
||||
const { data: database, canDeleteDatabases = false } = props;
|
||||
|
||||
return <DatabaseCard withDetails={false} data={database}/>;
|
||||
};
|
||||
return (
|
||||
<DatabaseCard
|
||||
withDetails={false}
|
||||
data={database}
|
||||
deleteButton={
|
||||
canDeleteDatabases && database.agentId ? (
|
||||
<DatabaseDeleteButton
|
||||
databaseId={database.id}
|
||||
agentId={database.agentId}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
"use client";
|
||||
|
||||
import {Trash2} from "lucide-react";
|
||||
import {ButtonWithConfirm} from "@/components/common/button-with-confirm";
|
||||
import {useMutation, useQueryClient} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
import {deleteDatabaseAction} from "@/features/database/actions/database-delete.action";
|
||||
|
||||
export type DatabaseDeleteButtonProps = {
|
||||
databaseId: string;
|
||||
agentId: string;
|
||||
};
|
||||
|
||||
export const DatabaseDeleteButton = (props: DatabaseDeleteButtonProps) => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => deleteDatabaseAction({databaseId: props.databaseId}),
|
||||
onSuccess: async (result: any) => {
|
||||
if (result.data?.success) {
|
||||
toast.success(result.data.actionSuccess.message);
|
||||
await queryClient.invalidateQueries({queryKey: ["agent-data", props.agentId]});
|
||||
} else {
|
||||
toast.error(result.data?.actionError?.message || "Unknown error occurred.");
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to delete database.");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<ButtonWithConfirm
|
||||
title="Delete database"
|
||||
description="Are you sure you want to delete this database? It will be unlinked from this agent."
|
||||
button={{
|
||||
main: {
|
||||
text: "",
|
||||
variant: "outline",
|
||||
size: "sm",
|
||||
icon: <Trash2 color="red"/>,
|
||||
},
|
||||
confirm: {
|
||||
className: "w-full",
|
||||
text: "Delete",
|
||||
icon: <Trash2/>,
|
||||
variant: "destructive",
|
||||
onClick: () => {
|
||||
mutation.mutate();
|
||||
},
|
||||
},
|
||||
cancel: {
|
||||
className: "w-full",
|
||||
text: "Cancel",
|
||||
variant: "outline",
|
||||
},
|
||||
}}
|
||||
isPending={mutation.isPending}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,7 @@
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import {z} from "zod";
|
||||
import {db} from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {and, eq, isNull} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {BackupWith, RestorationWith} from "@/db/schema/07_database";
|
||||
import {getOrganizationChannels} from "@/db/services/notification-channel";
|
||||
@@ -19,7 +19,10 @@ export const getDatabaseDataAction = userAction
|
||||
const {databaseId} = parsedInput;
|
||||
|
||||
const database = await db.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.id, databaseId),
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.database.id, databaseId),
|
||||
isNull(drizzleDb.schemas.database.deletedAt),
|
||||
),
|
||||
with: {
|
||||
project: true,
|
||||
retentionPolicy: true,
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use server";
|
||||
|
||||
import {z} from "zod";
|
||||
import {and, inArray, isNull} from "drizzle-orm";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import {assertDatabasesInOrgProject} from "@/features/database/actions/bulk-restore.action";
|
||||
|
||||
const bulkSchema = z.object({
|
||||
projectId: z.uuid(),
|
||||
databaseIds: z.array(z.uuid()).min(1),
|
||||
});
|
||||
|
||||
export const bulkBackupAction = userAction
|
||||
.schema(bulkSchema)
|
||||
.action(async ({parsedInput}): Promise<ServerActionResult<{queued: number; skipped: {databaseId: string; reason: string}[]}>> => {
|
||||
try {
|
||||
const {projectId, databaseIds} = parsedInput;
|
||||
await assertDatabasesInOrgProject(projectId, databaseIds);
|
||||
|
||||
const activeBackups = await db.query.backup.findMany({
|
||||
where: and(
|
||||
inArray(drizzleDb.schemas.backup.databaseId, databaseIds),
|
||||
inArray(drizzleDb.schemas.backup.status, ["waiting", "ongoing"]),
|
||||
isNull(drizzleDb.schemas.backup.deletedAt),
|
||||
),
|
||||
columns: {databaseId: true},
|
||||
});
|
||||
const busy = new Set(activeBackups.map((b) => b.databaseId));
|
||||
const toQueue = databaseIds.filter((id) => !busy.has(id));
|
||||
const skipped = [...busy].map((databaseId) => ({databaseId, reason: "backup already in progress"}));
|
||||
|
||||
if (toQueue.length > 0) {
|
||||
await db
|
||||
.insert(drizzleDb.schemas.backup)
|
||||
.values(toQueue.map((databaseId) => ({databaseId, status: "waiting" as const})));
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: {queued: toQueue.length, skipped},
|
||||
actionSuccess: {
|
||||
message: `Queued ${toQueue.length} backup(s)${skipped.length ? `, ${skipped.length} skipped` : ""}.`,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to queue backups.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
"use server";
|
||||
|
||||
import {z} from "zod";
|
||||
import {and, desc, eq, inArray, isNull} from "drizzle-orm";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {userAction, ActionError} from "@/lib/safe-actions/actions";
|
||||
import {getOrganization} from "@/lib/auth/auth";
|
||||
|
||||
export async function assertDatabasesInOrgProject(projectId: string, databaseIds: string[]) {
|
||||
const organization = await getOrganization({});
|
||||
if (!organization) throw new ActionError("No active organization.");
|
||||
|
||||
const project = await db.query.project.findFirst({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.project.id, projectId),
|
||||
eq(drizzleDb.schemas.project.organizationId, organization.id),
|
||||
),
|
||||
with: {databases: {where: isNull(drizzleDb.schemas.database.deletedAt)}},
|
||||
});
|
||||
if (!project) throw new ActionError("Project not found.");
|
||||
|
||||
const allowed = new Set(project.databases.map((d) => d.id));
|
||||
for (const id of databaseIds) {
|
||||
if (!allowed.has(id)) throw new ActionError("Database not in project.");
|
||||
}
|
||||
}
|
||||
|
||||
export type RestorePreviewRow = {
|
||||
databaseId: string;
|
||||
name: string;
|
||||
backupId?: string;
|
||||
backupStorageId?: string;
|
||||
backupDate?: string;
|
||||
restorable: boolean;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
async function resolveLatestRestorable(projectId: string, databaseIds: string[]): Promise<RestorePreviewRow[]> {
|
||||
const project = await db.query.project.findFirst({
|
||||
where: eq(drizzleDb.schemas.project.id, projectId),
|
||||
with: {databases: {where: isNull(drizzleDb.schemas.database.deletedAt)}},
|
||||
});
|
||||
const nameById = new Map((project?.databases ?? []).map((d) => [d.id, d.name] as const));
|
||||
|
||||
const rows: RestorePreviewRow[] = [];
|
||||
for (const databaseId of databaseIds) {
|
||||
const activeRestore = await db.query.restoration.findFirst({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.restoration.databaseId, databaseId),
|
||||
inArray(drizzleDb.schemas.restoration.status, ["waiting", "ongoing"]),
|
||||
),
|
||||
});
|
||||
if (activeRestore) {
|
||||
rows.push({
|
||||
databaseId,
|
||||
name: nameById.get(databaseId) ?? databaseId,
|
||||
restorable: false,
|
||||
reason: "restore already in progress",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const backups = await db.query.backup.findMany({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.backup.databaseId, databaseId),
|
||||
isNull(drizzleDb.schemas.backup.deletedAt),
|
||||
),
|
||||
orderBy: desc(drizzleDb.schemas.backup.createdAt),
|
||||
});
|
||||
|
||||
let picked: RestorePreviewRow | undefined;
|
||||
for (const backup of backups) {
|
||||
const storage = await db.query.backupStorage.findFirst({
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.backupStorage.backupId, backup.id),
|
||||
eq(drizzleDb.schemas.backupStorage.status, "success"),
|
||||
isNull(drizzleDb.schemas.backupStorage.deletedAt),
|
||||
),
|
||||
orderBy: desc(drizzleDb.schemas.backupStorage.createdAt),
|
||||
});
|
||||
if (storage) {
|
||||
picked = {
|
||||
databaseId,
|
||||
name: nameById.get(databaseId) ?? databaseId,
|
||||
backupId: backup.id,
|
||||
backupStorageId: storage.id,
|
||||
backupDate: backup.createdAt?.toISOString(),
|
||||
restorable: true,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
rows.push(picked ?? {databaseId, name: nameById.get(databaseId) ?? databaseId, restorable: false, reason: "no successful backup"});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
const bulkSchema = z.object({
|
||||
projectId: z.uuid(),
|
||||
databaseIds: z.array(z.uuid()).min(1),
|
||||
});
|
||||
|
||||
export const bulkRestorePreviewAction = userAction
|
||||
.schema(bulkSchema)
|
||||
.action(async ({parsedInput}): Promise<ServerActionResult<RestorePreviewRow[]>> => {
|
||||
try {
|
||||
const {projectId, databaseIds} = parsedInput;
|
||||
await assertDatabasesInOrgProject(projectId, databaseIds);
|
||||
const rows = await resolveLatestRestorable(projectId, databaseIds);
|
||||
return {success: true, value: rows};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to build restore preview.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
export const bulkRestoreLatestAction = userAction
|
||||
.schema(bulkSchema)
|
||||
.action(async ({parsedInput}): Promise<ServerActionResult<{queued: number; skipped: {databaseId: string; reason: string}[]}>> => {
|
||||
try {
|
||||
const {projectId, databaseIds} = parsedInput;
|
||||
await assertDatabasesInOrgProject(projectId, databaseIds);
|
||||
const rows = await resolveLatestRestorable(projectId, databaseIds);
|
||||
|
||||
const restorable = rows.filter((r) => r.restorable);
|
||||
const skipped = rows
|
||||
.filter((r) => !r.restorable)
|
||||
.map((r) => ({databaseId: r.databaseId, reason: r.reason ?? "no successful backup"}));
|
||||
|
||||
if (restorable.length > 0) {
|
||||
await db.insert(drizzleDb.schemas.restoration).values(
|
||||
restorable.map((r) => ({
|
||||
databaseId: r.databaseId,
|
||||
backupId: r.backupId!,
|
||||
backupStorageId: r.backupStorageId!,
|
||||
status: "waiting" as const,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: {queued: restorable.length, skipped},
|
||||
actionSuccess: {
|
||||
message: `Queued ${restorable.length} restore(s)${skipped.length ? `, ${skipped.length} skipped` : ""}.`,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to queue restores.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
"use server";
|
||||
|
||||
import {z} from "zod";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {Database} from "@/db/schema/07_database";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {userAction} from "@/lib/safe-actions/actions";
|
||||
import {zString} from "@/lib/zod";
|
||||
import {withUpdatedAt} from "@/db/utils";
|
||||
import {currentUser} from "@/lib/auth/current-user";
|
||||
import {getActiveMember, getOrganization} from "@/lib/auth/auth";
|
||||
import {computeOrganizationPermissions} from "@/lib/acl/organization-acl";
|
||||
|
||||
type DeleteDatabaseInput = {
|
||||
databaseId: string;
|
||||
};
|
||||
|
||||
class DatabaseNotFoundError extends Error {
|
||||
constructor(databaseId: string) {
|
||||
super(`Database not found or update failed: ${databaseId}`);
|
||||
this.name = "DatabaseNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
class UnauthorizedError extends Error {
|
||||
constructor(databaseId: string) {
|
||||
super(`Not authorized to delete this database: ${databaseId}`);
|
||||
this.name = "UnauthorizedError";
|
||||
}
|
||||
}
|
||||
|
||||
async function assertCanDeleteDatabase(databaseId: string): Promise<void> {
|
||||
const database = await db.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.id, databaseId),
|
||||
with: {
|
||||
agent: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!database || !database.agent) {
|
||||
throw new DatabaseNotFoundError(databaseId);
|
||||
}
|
||||
|
||||
const user = await currentUser();
|
||||
if (!user) {
|
||||
throw new UnauthorizedError(databaseId);
|
||||
}
|
||||
|
||||
const agent = database.agent;
|
||||
const isAdmin = user.role === "superadmin" || user.role === "admin";
|
||||
|
||||
let authorized: boolean;
|
||||
if (agent.organizationId === null) {
|
||||
authorized = isAdmin;
|
||||
} else {
|
||||
const organization = await getOrganization({});
|
||||
const activeMember = await getActiveMember();
|
||||
const canManage = activeMember
|
||||
? computeOrganizationPermissions(activeMember).canManageAgents
|
||||
: false;
|
||||
|
||||
const hasAccess =
|
||||
!!organization && agent.organizationId === organization.id;
|
||||
authorized = canManage && hasAccess;
|
||||
}
|
||||
|
||||
if (!authorized) {
|
||||
throw new UnauthorizedError(databaseId);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteDatabaseService(input: DeleteDatabaseInput): Promise<Database> {
|
||||
const {databaseId} = input;
|
||||
|
||||
await assertCanDeleteDatabase(databaseId);
|
||||
|
||||
await db
|
||||
.delete(drizzleDb.schemas.retentionPolicy)
|
||||
.where(eq(drizzleDb.schemas.retentionPolicy.databaseId, databaseId))
|
||||
.execute();
|
||||
|
||||
await db
|
||||
.delete(drizzleDb.schemas.alertPolicy)
|
||||
.where(eq(drizzleDb.schemas.alertPolicy.databaseId, databaseId))
|
||||
.execute();
|
||||
|
||||
await db
|
||||
.delete(drizzleDb.schemas.storagePolicy)
|
||||
.where(eq(drizzleDb.schemas.storagePolicy.databaseId, databaseId))
|
||||
.execute();
|
||||
|
||||
const [updatedDatabase] = await db
|
||||
.update(drizzleDb.schemas.database)
|
||||
.set(
|
||||
withUpdatedAt({
|
||||
agentId: null,
|
||||
agentDatabaseId: null,
|
||||
projectId: null,
|
||||
backupPolicy: null,
|
||||
deletedAt: new Date(),
|
||||
})
|
||||
)
|
||||
.where(eq(drizzleDb.schemas.database.id, databaseId))
|
||||
.returning();
|
||||
|
||||
if (!updatedDatabase) {
|
||||
throw new DatabaseNotFoundError(databaseId);
|
||||
}
|
||||
|
||||
return updatedDatabase;
|
||||
}
|
||||
|
||||
export const deleteDatabaseAction = userAction
|
||||
.schema(
|
||||
z.object({
|
||||
databaseId: zString(),
|
||||
})
|
||||
)
|
||||
.action(async ({parsedInput}): Promise<ServerActionResult<Database>> => {
|
||||
try {
|
||||
const deletedDatabase = await deleteDatabaseService(parsedInput);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
value: deletedDatabase,
|
||||
actionSuccess: {
|
||||
message: "Database has been successfully deleted.",
|
||||
messageParams: {
|
||||
databaseId: parsedInput.databaseId,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedError) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Not authorized to delete this database.",
|
||||
status: 403,
|
||||
messageParams: {
|
||||
databaseId: parsedInput.databaseId,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (error instanceof DatabaseNotFoundError) {
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Database not found or update failed",
|
||||
status: 404,
|
||||
messageParams: {
|
||||
databaseId: parsedInput.databaseId,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
actionError: {
|
||||
message: "Failed to delete database.",
|
||||
status: 500,
|
||||
cause: error instanceof Error ? error.message : "Unknown error",
|
||||
messageParams: {
|
||||
databaseId: parsedInput.databaseId,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {Backup} from "@/db/schema/07_database";
|
||||
import {v4 as uuidv4} from "uuid";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {and, eq, isNull} from "drizzle-orm";
|
||||
import {z} from "zod";
|
||||
import {storeBackupFiles} from "@/features/storages/utils/storages.helpers";
|
||||
import {getFileExtension} from "@/utils/common";
|
||||
@@ -19,7 +19,10 @@ export const uploadBackupAction = userAction
|
||||
const databaseId = formData.get("databaseId") as string;
|
||||
|
||||
const database = await db.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.id, databaseId),
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.database.id, databaseId),
|
||||
isNull(drizzleDb.schemas.database.deletedAt),
|
||||
),
|
||||
with: {
|
||||
project: true,
|
||||
alertPolicies: true,
|
||||
|
||||
@@ -128,7 +128,9 @@ export const DatabaseContent = (props: DatabaseContentProps) => {
|
||||
channels={activeOrganizationStorageChannels}
|
||||
organizationId={props.organizationId}
|
||||
/>
|
||||
<ImportModal database={database}/>
|
||||
{database.dbms != "docker-volume" && (
|
||||
<ImportModal database={database}/>
|
||||
)}
|
||||
<HealthModal database={database} healthLogs={data?.health ?? []}/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import {ServerActionResult} from "@/types/action-type";
|
||||
import {z} from "zod";
|
||||
import {db} from "@/db";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {eq, inArray} from "drizzle-orm";
|
||||
import {and, eq, inArray, isNull} from "drizzle-orm";
|
||||
import {dispatchStorage} from "@/features/storages/utils/storages.dispatch";
|
||||
import {v4 as uuidv4} from "uuid";
|
||||
import {getTodayISODate} from "@/utils/date-formatting";
|
||||
@@ -24,7 +24,10 @@ export const migrationAction = userAction.schema(
|
||||
try {
|
||||
|
||||
const targetDatabase = await db.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.id, targetDatabaseId),
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.database.id, targetDatabaseId),
|
||||
isNull(drizzleDb.schemas.database.deletedAt),
|
||||
),
|
||||
with: {
|
||||
project: true,
|
||||
retentionPolicy: true,
|
||||
|
||||
@@ -2,6 +2,7 @@ export const STEP_IDS = {
|
||||
LOGIN: "login",
|
||||
ACCOUNT_INFO: "account-info",
|
||||
SECURITY: "security",
|
||||
PREFERENCES: "preferences",
|
||||
ORG_CREATE: "org-create",
|
||||
//INVITE_MEMBERS: "invite-members",
|
||||
NOTIFIER: "notifier",
|
||||
@@ -19,6 +20,7 @@ export const STEP_ORDER: string[] = [
|
||||
STEP_IDS.LOGIN,
|
||||
STEP_IDS.ACCOUNT_INFO,
|
||||
STEP_IDS.SECURITY,
|
||||
STEP_IDS.PREFERENCES,
|
||||
STEP_IDS.ORG_CREATE,
|
||||
//STEP_IDS.INVITE_MEMBERS,
|
||||
STEP_IDS.NOTIFIER,
|
||||
|
||||
@@ -61,9 +61,9 @@ export const OnboardingChecklist = () => {
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
"text-sm pb-4",
|
||||
"text-sm pb-4 ",
|
||||
isCompleted && "text-zinc-400",
|
||||
isCurrent && "text-white font-medium",
|
||||
isCurrent && "text-primary font-medium",
|
||||
!isCompleted && !isCurrent && "text-zinc-600",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -84,8 +84,8 @@ export async function resolveOnboardingState(): Promise<ResolvedOnboardingState>
|
||||
|
||||
const org = await getUserOwnOrganization(user.id);
|
||||
if (!org) {
|
||||
meta.resumeStepId = "org-create";
|
||||
return { stepId: "org-create", flowData: { meta } };
|
||||
meta.resumeStepId = "preferences";
|
||||
return { stepId: "preferences", flowData: { meta } };
|
||||
}
|
||||
|
||||
const orgData = { id: org.id, name: org.name };
|
||||
|
||||
@@ -2,6 +2,7 @@ import { OnboardingStep } from "@onboardjs/react";
|
||||
import { StepLogin } from "@/features/onboarding/steps/step-login";
|
||||
import { StepAccountInfo } from "@/features/onboarding/steps/step-account-info";
|
||||
import { StepSecurity } from "@/features/onboarding/steps/step-security";
|
||||
import { StepPreferences } from "@/features/onboarding/steps/step-preferences";
|
||||
import { StepOrgCreate } from "@/features/onboarding/steps/step-org-create";
|
||||
import { StepInviteMembers } from "@/features/onboarding/steps/step-invite-members";
|
||||
import { StepNotifier } from "@/features/onboarding/steps/step-notifier";
|
||||
@@ -20,19 +21,26 @@ export const onboardingSteps: OnboardingStep[] = [
|
||||
component: StepLogin,
|
||||
isSkippable: false,
|
||||
nextStep: (ctx: any) =>
|
||||
ctx.flowData?.meta?.hasExistingUsers ? "org-create" : "account-info",
|
||||
ctx.flowData?.meta?.hasExistingUsers ? "preferences" : "account-info",
|
||||
},
|
||||
{
|
||||
id: "account-info",
|
||||
component: StepAccountInfo,
|
||||
isSkippable: false,
|
||||
nextStep: (ctx: any) =>
|
||||
ctx.flowData?.meta?.passkeyEnabled ? "org-create" : "security",
|
||||
ctx.flowData?.meta?.passkeyEnabled ? "preferences" : "security",
|
||||
},
|
||||
{
|
||||
id: "security",
|
||||
component: StepSecurity,
|
||||
isSkippable: true,
|
||||
skipToStep: "preferences",
|
||||
nextStep: "preferences",
|
||||
},
|
||||
{
|
||||
id: "preferences",
|
||||
component: StepPreferences,
|
||||
isSkippable: true,
|
||||
skipToStep: "org-create",
|
||||
nextStep: "org-create",
|
||||
},
|
||||
|
||||
@@ -70,7 +70,16 @@ export const StepAgentCreate = () => {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button type="button" onClick={() => next()}>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => next()}
|
||||
disabled={!!name.trim() || createAgent.isPending}
|
||||
title={
|
||||
name.trim()
|
||||
? "Add or clear the agent name before continuing"
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,8 @@ export const StepDefaults = () => {
|
||||
const [storageId, setStorageId] = useState<string | undefined>(
|
||||
existingDefaults.storageId || undefined,
|
||||
);
|
||||
const [notifierOpen, setNotifierOpen] = useState(false);
|
||||
const [storageOpen, setStorageOpen] = useState(false);
|
||||
|
||||
const selectNotifier = async (value: string) => {
|
||||
const prev = notifierId;
|
||||
@@ -56,6 +58,26 @@ export const StepDefaults = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const clearNotifier = async () => {
|
||||
const prev = notifierId;
|
||||
setNotifierId(undefined);
|
||||
try {
|
||||
await updateNotificationSettingsAction({
|
||||
name: "system",
|
||||
data: { notificationChannelId: null },
|
||||
});
|
||||
await updateContext({
|
||||
flowData: {
|
||||
...state?.context.flowData,
|
||||
defaults: { notifierId: undefined, storageId },
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
setNotifierId(prev);
|
||||
toast.error("Failed to clear default notifier");
|
||||
}
|
||||
};
|
||||
|
||||
const selectStorage = async (value: string) => {
|
||||
const prev = storageId;
|
||||
setStorageId(value);
|
||||
@@ -76,6 +98,26 @@ export const StepDefaults = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const clearStorage = async () => {
|
||||
const prev = storageId;
|
||||
setStorageId(undefined);
|
||||
try {
|
||||
await updateStorageSettingsAction({
|
||||
name: "system",
|
||||
data: { storageChannelId: null, encryption: false },
|
||||
});
|
||||
await updateContext({
|
||||
flowData: {
|
||||
...state?.context.flowData,
|
||||
defaults: { notifierId, storageId: undefined },
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
setStorageId(prev);
|
||||
toast.error("Failed to clear default storage");
|
||||
}
|
||||
};
|
||||
|
||||
const onContinue = async () => {
|
||||
await updateContext({
|
||||
flowData: {
|
||||
@@ -102,8 +144,10 @@ export const StepDefaults = () => {
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Default notifier</Label>
|
||||
<Select
|
||||
value={selectedNotifier ? notifierId : undefined}
|
||||
value={selectedNotifier ? notifierId : ""}
|
||||
onValueChange={selectNotifier}
|
||||
open={notifierOpen}
|
||||
onOpenChange={setNotifierOpen}
|
||||
disabled={notifiers.length === 0}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
@@ -128,7 +172,17 @@ export const StepDefaults = () => {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{notifiers.map((n) => (
|
||||
<SelectItem key={n.id} value={n.id}>
|
||||
<SelectItem
|
||||
key={n.id}
|
||||
value={n.id}
|
||||
onPointerUp={(e) => {
|
||||
if (n.id === notifierId) {
|
||||
e.preventDefault();
|
||||
clearNotifier();
|
||||
setNotifierOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full min-w-0">
|
||||
<div className="text-muted-foreground scale-90 shrink-0">
|
||||
{getChannelIcon(n.provider)}
|
||||
@@ -149,8 +203,10 @@ export const StepDefaults = () => {
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Default storage</Label>
|
||||
<Select
|
||||
value={selectedStorage ? storageId : undefined}
|
||||
value={selectedStorage ? storageId : ""}
|
||||
onValueChange={selectStorage}
|
||||
open={storageOpen}
|
||||
onOpenChange={setStorageOpen}
|
||||
disabled={storages.length === 0}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
@@ -175,7 +231,17 @@ export const StepDefaults = () => {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{storages.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
<SelectItem
|
||||
key={s.id}
|
||||
value={s.id}
|
||||
onPointerUp={(e) => {
|
||||
if (s.id === storageId) {
|
||||
e.preventDefault();
|
||||
clearStorage();
|
||||
setStorageOpen(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 w-full min-w-0">
|
||||
<div className="text-muted-foreground scale-90 shrink-0">
|
||||
{getChannelIcon(s.provider)}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
"use client";
|
||||
|
||||
import { useOnboarding } from "@onboardjs/react";
|
||||
import { useTheme } from "next-themes";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { authClient } from "@/lib/auth/auth-client";
|
||||
import { ThemeKey, ThemeSelector } from "@/components/common/theme-selector";
|
||||
|
||||
export const StepPreferences = () => {
|
||||
const { next, updateContext, state } = useOnboarding();
|
||||
const { theme: currentTheme, setTheme } = useTheme();
|
||||
const preferences = state?.context.flowData.preferences ?? {
|
||||
theme: (currentTheme ?? "system") as ThemeKey,
|
||||
};
|
||||
|
||||
const selectTheme = async (theme: ThemeKey) => {
|
||||
setTheme(theme);
|
||||
try {
|
||||
await authClient.updateUser({ theme });
|
||||
await updateContext({
|
||||
flowData: {
|
||||
...state?.context.flowData,
|
||||
preferences: { ...preferences, theme },
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
toast.error("Failed to save theme preference");
|
||||
setTheme(preferences.theme as ThemeKey);
|
||||
}
|
||||
};
|
||||
|
||||
const onContinue = async () => {
|
||||
await next();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Your preferences</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Optional — personalise your workspace.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-sm font-medium">Theme</p>
|
||||
<ThemeSelector value={preferences.theme} onSelect={selectTheme} />
|
||||
</div>
|
||||
|
||||
<Button type="button" onClick={onContinue}>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -5,7 +5,7 @@ import {ProjectSchema} from "@/features/projects/schemas/projects.schema";
|
||||
import {z} from "zod";
|
||||
import {ServerActionResult} from "@/types/action-type";
|
||||
import {db} from "@/db";
|
||||
import {and, eq, inArray} from "drizzle-orm";
|
||||
import {and, eq, inArray, isNull} from "drizzle-orm";
|
||||
import {Project} from "@/db/schema/06_project";
|
||||
import * as drizzleDb from "@/db";
|
||||
import {Database} from "@/db/schema/07_database";
|
||||
@@ -88,7 +88,9 @@ export const updateProjectAction = userAction
|
||||
const existing = await db.query.project.findFirst({
|
||||
where: eq(drizzleDb.schemas.project.id, parsedInput.projectId),
|
||||
with: {
|
||||
databases: true,
|
||||
databases: {
|
||||
where: isNull(drizzleDb.schemas.database.deletedAt),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import {DatabaseZap, RotateCcw, X, Loader2} from "lucide-react";
|
||||
import {Button} from "@/components/ui/button";
|
||||
|
||||
export type BulkActionBarProps = {
|
||||
count: number;
|
||||
isPending: boolean;
|
||||
onBackup: () => void;
|
||||
onRestore: () => void;
|
||||
onClear: () => void;
|
||||
};
|
||||
|
||||
export const BulkActionBar = ({count, isPending, onBackup, onRestore, onClear}: BulkActionBarProps) => {
|
||||
if (count === 0) return null;
|
||||
return (
|
||||
<div
|
||||
className="fixed bottom-4 sm:bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-2 sm:gap-3 rounded-xl border border-primary/50 bg-card shadow-lg px-3 sm:px-4 py-2 sm:py-3 max-w-[calc(100vw-1.5rem)]">
|
||||
<span className="flex-shrink-0 rounded-full bg-primary text-primary-foreground text-xs font-bold px-2.5 sm:px-3 py-1">
|
||||
{count}
|
||||
<span className="hidden sm:inline"> selected</span>
|
||||
</span>
|
||||
<Button size="sm" variant="outline" onClick={onBackup} disabled={isPending} title="Backup">
|
||||
{isPending ? <Loader2 className="animate-spin"/> : <DatabaseZap/>}
|
||||
<span className="hidden sm:inline">Backup</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" onClick={onRestore} disabled={isPending} title="Restore latest">
|
||||
<RotateCcw/>
|
||||
<span className="hidden sm:inline">Restore latest</span>
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={onClear} disabled={isPending} title="Clear">
|
||||
<X/>
|
||||
<span className="hidden sm:inline">Clear</span>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
"use client";
|
||||
|
||||
import {useState} from "react";
|
||||
import {Loader2} from "lucide-react";
|
||||
import {Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter} from "@/components/ui/dialog";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {Input} from "@/components/ui/input";
|
||||
import type {RestorePreviewRow} from "@/features/database/actions/bulk-restore.action";
|
||||
|
||||
const CONFIRM_WORD = "restore";
|
||||
|
||||
export type BulkRestoreModalProps = {
|
||||
open: boolean;
|
||||
loading: boolean;
|
||||
submitting: boolean;
|
||||
rows: RestorePreviewRow[];
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
};
|
||||
|
||||
export const BulkRestoreModal = (props: BulkRestoreModalProps) => {
|
||||
const {open, loading, submitting, rows, onConfirm, onCancel} = props;
|
||||
const [typed, setTyped] = useState("");
|
||||
|
||||
const restorableCount = rows.filter((r) => r.restorable).length;
|
||||
const canConfirm = restorableCount > 0 && typed.trim().toLowerCase() === CONFIRM_WORD && !submitting;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => (!o ? onCancel() : undefined)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Restore {rows.length} database(s) to latest backup
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
This overwrites current data and cannot be undone.
|
||||
</p>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-8"><Loader2 className="animate-spin" /></div>
|
||||
) : (
|
||||
<div className="max-h-64 overflow-auto rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="text-left text-muted-foreground border-b">
|
||||
<th className="p-2">Database</th>
|
||||
<th className="p-2">Will restore from</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r) => (
|
||||
<tr key={r.databaseId} className="border-b last:border-0">
|
||||
<td className={`p-2 ${r.restorable ? "" : "opacity-50"}`}>{r.name}</td>
|
||||
<td className="p-2">
|
||||
{r.restorable
|
||||
? <span className="text-green-600">{r.backupDate ? new Date(r.backupDate).toLocaleString() : "latest backup"}</span>
|
||||
: <span className="text-amber-500">{r.reason ?? "no successful backup"} — skipped</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{restorableCount > 0 && (
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-muted-foreground">Type "{CONFIRM_WORD}" to confirm</label>
|
||||
<Input value={typed} onChange={(e) => setTyped(e.target.value)} placeholder={CONFIRM_WORD} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onCancel} disabled={submitting}>Cancel</Button>
|
||||
<Button onClick={onConfirm} disabled={!canConfirm}>
|
||||
{submitting && <Loader2 className="animate-spin" />}
|
||||
{restorableCount > 0 ? `Restore ${restorableCount} database(s)` : "Nothing to restore"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
"use client";
|
||||
|
||||
import {useMemo, useState} from "react";
|
||||
import Link from "next/link";
|
||||
import {useRouter} from "next/navigation";
|
||||
import {useQueryClient} from "@tanstack/react-query";
|
||||
import {toast} from "sonner";
|
||||
|
||||
import {Database} from "@/db/schema/07_database";
|
||||
import {DatabaseCard} from "@/components/common/database-card";
|
||||
import {Button} from "@/components/ui/button";
|
||||
import {PaginationNavigation} from "@/components/common/pagination-navigation";
|
||||
import {PaginationSize} from "@/components/common/pagination-size";
|
||||
import {BulkActionBar} from "@/features/projects/components/bulk-action-bar";
|
||||
import {BulkRestoreModal} from "@/features/projects/components/bulk-restore-modal";
|
||||
import {bulkBackupAction} from "@/features/database/actions/bulk-backup.action";
|
||||
import {
|
||||
bulkRestorePreviewAction,
|
||||
bulkRestoreLatestAction,
|
||||
type RestorePreviewRow,
|
||||
} from "@/features/database/actions/bulk-restore.action";
|
||||
|
||||
export type ProjectDatabaseSelectionProps = {
|
||||
projectId: string;
|
||||
databases: Database[];
|
||||
};
|
||||
|
||||
export const ProjectDatabaseSelection = ({projectId, databases}: ProjectDatabaseSelectionProps) => {
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [pageSize, setPageSize] = useState(20);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const [restoreOpen, setRestoreOpen] = useState(false);
|
||||
const [previewLoading, setPreviewLoading] = useState(false);
|
||||
const [previewRows, setPreviewRows] = useState<RestorePreviewRow[]>([]);
|
||||
|
||||
const sorted = useMemo(
|
||||
() => [...databases].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
|
||||
[databases],
|
||||
);
|
||||
const totalPages = Math.max(1, Math.ceil(sorted.length / pageSize));
|
||||
const pageItems = sorted.slice((currentPage - 1) * pageSize, currentPage * pageSize);
|
||||
const selectedIds = Array.from(selected);
|
||||
|
||||
const toggle = (id: string) =>
|
||||
setSelected((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
const selectAll = () => setSelected(new Set(sorted.map((d) => d.id)));
|
||||
const clear = () => setSelected(new Set());
|
||||
|
||||
const afterQueue = () => {
|
||||
clear();
|
||||
queryClient.invalidateQueries({queryKey: ["database-data"]});
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const handleBackup = async () => {
|
||||
setPending(true);
|
||||
const res = await bulkBackupAction({projectId, databaseIds: selectedIds});
|
||||
setPending(false);
|
||||
const data = res?.data;
|
||||
if (data?.success) {
|
||||
toast.success(data.actionSuccess?.message ?? "Backups queued.");
|
||||
afterQueue();
|
||||
} else {
|
||||
toast.error(res?.serverError ?? data?.actionError?.message ?? "Failed to queue backups.");
|
||||
}
|
||||
};
|
||||
|
||||
const openRestore = async () => {
|
||||
setRestoreOpen(true);
|
||||
setPreviewLoading(true);
|
||||
const res = await bulkRestorePreviewAction({projectId, databaseIds: selectedIds});
|
||||
setPreviewLoading(false);
|
||||
if (res?.data?.success && res.data.value) {
|
||||
setPreviewRows(res.data.value);
|
||||
} else {
|
||||
toast.error(res?.serverError ?? "Failed to build preview.");
|
||||
setRestoreOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const confirmRestore = async () => {
|
||||
setPending(true);
|
||||
const res = await bulkRestoreLatestAction({projectId, databaseIds: selectedIds});
|
||||
setPending(false);
|
||||
setRestoreOpen(false);
|
||||
const data = res?.data;
|
||||
if (data?.success) {
|
||||
toast.success(data.actionSuccess?.message ?? "Restores queued.");
|
||||
afterQueue();
|
||||
} else {
|
||||
toast.error(res?.serverError ?? data?.actionError?.message ?? "Failed to queue restores.");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full justify-between">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<span className="text-sm text-muted-foreground">{selected.size} selected</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={selectAll}>Select all</Button>
|
||||
<Button variant="ghost" size="sm" onClick={clear} disabled={selected.size === 0}>Clear</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid h-max auto-rows-min gap-4 md:grid-cols-3">
|
||||
{pageItems.map((database) => (
|
||||
<Link
|
||||
key={database.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"
|
||||
href={`/dashboard/projects/${projectId}/database/${database.id}`}
|
||||
>
|
||||
<DatabaseCard
|
||||
data={database}
|
||||
selectable
|
||||
selected={selected.has(database.id)}
|
||||
onToggleSelect={toggle}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end mt-4 gap-4">
|
||||
<PaginationSize
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
pageSizeOptions={[10, 20, 50]}
|
||||
/>
|
||||
<PaginationNavigation
|
||||
className="justify-end"
|
||||
totalPages={totalPages}
|
||||
currentPage={currentPage}
|
||||
goToPage={setCurrentPage}
|
||||
goToPrevPage={() => setCurrentPage(Math.max(1, currentPage - 1))}
|
||||
goToNextPage={() => setCurrentPage(Math.min(totalPages, currentPage + 1))}
|
||||
maxVisiblePages={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BulkActionBar
|
||||
count={selected.size}
|
||||
isPending={pending}
|
||||
onBackup={handleBackup}
|
||||
onRestore={openRestore}
|
||||
onClear={clear}
|
||||
/>
|
||||
|
||||
<BulkRestoreModal
|
||||
open={restoreOpen}
|
||||
loading={previewLoading}
|
||||
submitting={pending}
|
||||
rows={previewRows}
|
||||
onConfirm={confirmRestore}
|
||||
onCancel={() => setRestoreOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -24,7 +24,7 @@ export const updateStorageSettingsAction = userAction
|
||||
const [updatedSettings] = await db
|
||||
.update(drizzleDb.schemas.setting)
|
||||
.set(withUpdatedAt({
|
||||
defaultStorageChannelId: data.storageChannelId,
|
||||
defaultStorageChannelId: data.storageChannelId ?? null,
|
||||
encryption: data.encryption,
|
||||
}))
|
||||
.where(eq(drizzleDb.schemas.setting.name, name))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {z} from "zod";
|
||||
|
||||
export const DefaultStorageSchema = z.object({
|
||||
storageChannelId: z.string(),
|
||||
storageChannelId: z.string().optional().nullable(),
|
||||
encryption: z.boolean(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import {ApiKeyContextUser} from "@/lib/api-v1/types";
|
||||
import {getAccessibleDatabaseIds} from "@/lib/api-v1/services/databases";
|
||||
import {db} from "@/db";
|
||||
import {eq} from "drizzle-orm";
|
||||
import {and, eq, isNull} from "drizzle-orm";
|
||||
import * as drizzleDb from "@/db";
|
||||
|
||||
export async function resolveDatabaseAccess(id: string, user: ApiKeyContextUser) {
|
||||
const accessibleIds = await getAccessibleDatabaseIds(user);
|
||||
if (accessibleIds.includes(id)) return "ok";
|
||||
const exists = await db.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.id, id),
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.database.id, id),
|
||||
isNull(drizzleDb.schemas.database.deletedAt),
|
||||
),
|
||||
columns: { id: true },
|
||||
});
|
||||
return exists ? "forbidden" : "not_found";
|
||||
|
||||
@@ -44,7 +44,10 @@ export const backupCleanTask = async () => {
|
||||
|
||||
try {
|
||||
const database = await db.query.database.findFirst({
|
||||
where: eq(drizzleDb.schemas.database.id, backup.databaseId),
|
||||
where: and(
|
||||
eq(drizzleDb.schemas.database.id, backup.databaseId),
|
||||
isNull(drizzleDb.schemas.database.deletedAt),
|
||||
),
|
||||
with: {alertPolicies: true},
|
||||
});
|
||||
if (database) {
|
||||
|
||||
@@ -12,6 +12,7 @@ const log = logger.child({ module: "tasks/database" });
|
||||
export const retentionCleanTask = async () => {
|
||||
try {
|
||||
const databases = await db.query.database.findMany({
|
||||
where: isNull(drizzleDb.schemas.database.deletedAt),
|
||||
with: {
|
||||
retentionPolicy: true,
|
||||
backups: {
|
||||
|
||||
Reference in New Issue
Block a user