openwebui: enable user scoped integrations to avoid cross user data leakage
This commit is contained in:
1 parent
3bb6037d35
commit
6f6a989ba6
17 files changed
+2731
-10
No files matched your search
@@ -46,7 +46,7 @@ const nextConfig: NextConfig = {
|
||||
// Ship server source maps so production Node stack traces in logs reference original source, see https://github.com/homarr-labs/homarr/issues/5891
|
||||
serverSourceMaps: true,
|
||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||
turbopackFileSystemCacheForDev: true,
|
||||
turbopackFileSystemCacheForDev: false,
|
||||
preloadEntriesOnStart: false,
|
||||
webpackMemoryOptimizations: true,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
|
||||
const FloatingChatButton = dynamic(
|
||||
() => import("~/components/board/floating-chat-button").then((mod) => mod.FloatingChatButton),
|
||||
{ ssr: false },
|
||||
);
|
||||
|
||||
export const FloatingChatButtonProvider = () => {
|
||||
return <FloatingChatButton />;
|
||||
};
|
||||
@@ -32,6 +32,7 @@ import { SearchEngineOptimization } from "~/components/layout/search-engine-opti
|
||||
import { ServiceWorkerRegistration } from "~/components/layout/service-worker-registration";
|
||||
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
|
||||
import { DayJsLoader } from "./_client-providers/dayjs-loader";
|
||||
import { FloatingChatButtonProvider } from "./_client-providers/floating-chat";
|
||||
import { JotaiProvider } from "./_client-providers/jotai";
|
||||
import { CustomMantineProvider } from "./_client-providers/mantine";
|
||||
import { AuthProvider } from "./_client-providers/session";
|
||||
@@ -147,6 +148,7 @@ export default async function Layout(props: {
|
||||
<StackedProvider>
|
||||
<Notifications pauseResetOnHover="notification" />
|
||||
<ServiceWorkerRegistration />
|
||||
<FloatingChatButtonProvider />
|
||||
{props.children}
|
||||
</StackedProvider>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { ActionIcon, Box, Dialog, Modal } from "@mantine/core";
|
||||
import { useDisclosure, useMediaQuery } from "@mantine/hooks";
|
||||
import { IconRobot } from "@tabler/icons-react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { useSession } from "@homarr/auth/client";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
import { Chat } from "@homarr/widgets/_open-webui/chat";
|
||||
|
||||
export const FloatingChatButton = () => {
|
||||
const t = useI18n();
|
||||
const { data: session } = useSession();
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const isMobile = useMediaQuery("(max-width: 767px)");
|
||||
|
||||
const { data: integrations = [] } = clientApi.integration.all.useQuery(undefined, {
|
||||
enabled: !!session?.user?.id,
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
// Find the first OpenWebUI integration where the current user is the creator.
|
||||
const myOpenWebUiIntegration = useMemo(() => {
|
||||
if (!session?.user?.id) return null;
|
||||
return integrations.find(
|
||||
(integration) =>
|
||||
integration.kind === "openWebUi" && integration.creatorId === session.user.id,
|
||||
);
|
||||
}, [integrations, session?.user?.id]);
|
||||
|
||||
if (!myOpenWebUiIntegration) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{!opened && (
|
||||
<ActionIcon
|
||||
size={56}
|
||||
radius="xl"
|
||||
variant="filled"
|
||||
onClick={open}
|
||||
aria-label={t("widget.openWebUi.name")}
|
||||
style={{
|
||||
position: "fixed",
|
||||
bottom: 24,
|
||||
right: 24,
|
||||
zIndex: 1000,
|
||||
boxShadow: "var(--mantine-shadow-lg)",
|
||||
}}
|
||||
>
|
||||
<IconRobot size={28} />
|
||||
</ActionIcon>
|
||||
)}
|
||||
|
||||
{isMobile ? (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
title={t("widget.openWebUi.name")}
|
||||
fullScreen
|
||||
styles={{
|
||||
body: {
|
||||
height: "calc(100dvh - 60px)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
|
||||
<Chat
|
||||
options={{
|
||||
systemPrompt: "",
|
||||
showHistory: true,
|
||||
}}
|
||||
integrationIds={[myOpenWebUiIntegration.id]}
|
||||
itemId={undefined}
|
||||
boardId={undefined}
|
||||
isEditMode={false}
|
||||
setOptions={() => {
|
||||
// No-op: options are fixed for the floating chat.
|
||||
}}
|
||||
width={800}
|
||||
height={600}
|
||||
/>
|
||||
</Box>
|
||||
</Modal>
|
||||
) : (
|
||||
<Dialog
|
||||
opened={opened}
|
||||
onClose={close}
|
||||
withCloseButton
|
||||
size="35vw"
|
||||
position={{ bottom: 100, right: 24 }}
|
||||
transitionProps={{ transition: "pop-top-right", duration: 200 }}
|
||||
styles={{
|
||||
root: {
|
||||
height: "50dvh",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
padding: 0,
|
||||
overflow: "hidden",
|
||||
},
|
||||
closeButton: {
|
||||
zIndex: 10,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Box style={{ flex: 1, display: "flex", flexDirection: "column", minHeight: 0 }}>
|
||||
<Chat
|
||||
options={{
|
||||
systemPrompt: "",
|
||||
showHistory: true,
|
||||
}}
|
||||
integrationIds={[myOpenWebUiIntegration.id]}
|
||||
itemId={undefined}
|
||||
boardId={undefined}
|
||||
isEditMode={false}
|
||||
setOptions={() => {
|
||||
// No-op: options are fixed for the floating chat.
|
||||
}}
|
||||
width={800}
|
||||
height={600}
|
||||
/>
|
||||
</Box>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -99,6 +99,7 @@ export const integrationRouter = createTRPCRouter({
|
||||
name: integration.name,
|
||||
kind: integration.kind,
|
||||
url: integration.url,
|
||||
creatorId: integration.creatorId,
|
||||
permissions: {
|
||||
hasUseAccess:
|
||||
permissions.includes("use") || permissions.includes("interact") || permissions.includes("full"),
|
||||
@@ -350,6 +351,15 @@ export const integrationRouter = createTRPCRouter({
|
||||
url: input.url,
|
||||
kind: input.kind,
|
||||
appId,
|
||||
creatorId: ctx.session.user.id,
|
||||
});
|
||||
|
||||
// Auto-grant the creator "full" permission so they can immediately use
|
||||
// and manage the integration they just created.
|
||||
await ctx.db.insert(integrationUserPermissions).values({
|
||||
integrationId,
|
||||
userId: ctx.session.user.id,
|
||||
permission: "full",
|
||||
});
|
||||
|
||||
if (input.secrets.length >= 1) {
|
||||
|
||||
@@ -301,6 +301,10 @@ describe("create should create a new integration", () => {
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-create"]),
|
||||
});
|
||||
|
||||
// The creator must exist in the DB for the creatorId foreign key
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
|
||||
const input = {
|
||||
name: "Jellyfin",
|
||||
kind: "jellyfin" as const,
|
||||
@@ -317,10 +321,18 @@ describe("create should create a new integration", () => {
|
||||
|
||||
const dbIntegration = await db.query.integrations.findFirst();
|
||||
const dbSecret = await db.query.integrationSecrets.findFirst();
|
||||
const dbPermission = await db.query.integrationUserPermissions.findFirst();
|
||||
expect(dbIntegration).toBeDefined();
|
||||
expect(dbIntegration!.name).toBe(input.name);
|
||||
expect(dbIntegration!.kind).toBe(input.kind);
|
||||
expect(dbIntegration!.url).toBe(input.url);
|
||||
expect(dbIntegration!.creatorId).toBe(defaultUserId);
|
||||
|
||||
// Creator should be auto-granted "full" permission
|
||||
expect(dbPermission).toBeDefined();
|
||||
expect(dbPermission!.integrationId).toBe(dbIntegration!.id);
|
||||
expect(dbPermission!.userId).toBe(defaultUserId);
|
||||
expect(dbPermission!.permission).toBe("full");
|
||||
|
||||
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
|
||||
expect(dbSecret).toBeDefined();
|
||||
@@ -336,6 +348,10 @@ describe("create should create a new integration", () => {
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-create"]),
|
||||
});
|
||||
|
||||
// The creator must exist in the DB for the creatorId foreign key
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
|
||||
const input = {
|
||||
name: "Jellyseerr",
|
||||
kind: "jellyseerr" as const,
|
||||
@@ -357,6 +373,7 @@ describe("create should create a new integration", () => {
|
||||
expect(dbIntegration!.name).toBe(input.name);
|
||||
expect(dbIntegration!.kind).toBe(input.kind);
|
||||
expect(dbIntegration!.url).toBe(input.url);
|
||||
expect(dbIntegration!.creatorId).toBe(defaultUserId);
|
||||
|
||||
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
|
||||
expect(dbSecret).toBeDefined();
|
||||
@@ -374,6 +391,10 @@ describe("create should create a new integration", () => {
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-create", "app-create"]),
|
||||
});
|
||||
|
||||
// The creator must exist in the DB for the creatorId foreign key
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
|
||||
const input = {
|
||||
name: "Jellyfin",
|
||||
kind: "jellyfin" as const,
|
||||
@@ -405,6 +426,7 @@ describe("create should create a new integration", () => {
|
||||
expect(dbIntegration!.name).toBe(input.name);
|
||||
expect(dbIntegration!.kind).toBe(input.kind);
|
||||
expect(dbIntegration!.url).toBe(input.url);
|
||||
expect(dbIntegration!.creatorId).toBe(defaultUserId);
|
||||
expect(dbIntegration!.app!.name).toBe(input.app.name);
|
||||
expect(dbIntegration!.app!.pingUrl).toBe(input.app.pingUrl);
|
||||
expect(dbIntegration!.app!.href).toBe(input.app.href);
|
||||
@@ -431,6 +453,10 @@ describe("create should create a new integration", () => {
|
||||
deviceType: undefined,
|
||||
session: defaultSessionWithPermissions(["integration-create"]),
|
||||
});
|
||||
|
||||
// The creator must exist in the DB for the creatorId foreign key
|
||||
await db.insert(users).values({ id: defaultUserId });
|
||||
|
||||
const input = {
|
||||
name: "Jellyfin",
|
||||
kind: "jellyfin" as const,
|
||||
@@ -455,6 +481,7 @@ describe("create should create a new integration", () => {
|
||||
expect(dbIntegration!.kind).toBe(input.kind);
|
||||
expect(dbIntegration!.url).toBe(input.url);
|
||||
expect(dbIntegration!.appId).toBe(appId);
|
||||
expect(dbIntegration!.creatorId).toBe(defaultUserId);
|
||||
|
||||
expect(dbSecret!.integrationId).toBe(dbIntegration!.id);
|
||||
expect(dbSecret).toBeDefined();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
@@ -19,6 +20,24 @@ import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trp
|
||||
|
||||
const logger = createLogger({ module: "openWebUiRouter" });
|
||||
|
||||
/**
|
||||
* Open WebUI integrations hold a personal API key that grants access to a
|
||||
* single user's data (chat history, files, notes, knowledge bases). To prevent
|
||||
* other Homarr users from reading that data, every procedure that touches the
|
||||
* integration verifies that the caller is the integration's creator.
|
||||
*
|
||||
* The check is intentionally placed **after** the integration middleware (which
|
||||
* already validates the caller's permission level) so the error message does
|
||||
* not leak whether the integration exists.
|
||||
*/
|
||||
const throwIfNotCreator = (integration: { creatorId?: string | null }, userId: string | undefined) => {
|
||||
// Existing integrations that predate the creatorId column have a null
|
||||
// creatorId; we skip the check for backward compatibility.
|
||||
if (integration.creatorId && integration.creatorId !== userId) {
|
||||
throw new TRPCError({ code: "FORBIDDEN", message: "Only the integration creator can access this data" });
|
||||
}
|
||||
};
|
||||
|
||||
const messageSchema = z.object({
|
||||
role: z.enum(["system", "user", "assistant"]),
|
||||
content: z.string(),
|
||||
@@ -104,30 +123,39 @@ const groundMessagesAsync = async (
|
||||
|
||||
export const openWebUiRouter = createTRPCRouter({
|
||||
getModels: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
|
||||
const handler = openWebUiModelsRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
}),
|
||||
|
||||
getChats: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
|
||||
const handler = openWebUiChatsRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
}),
|
||||
// Chat history exposes private user data, so we require "interact" access
|
||||
// (not just "query") and verify the caller is the integration's creator.
|
||||
getChats: protectedProcedure
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.query(async ({ ctx }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const handler = openWebUiChatsRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
}),
|
||||
|
||||
getKnowledge: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
|
||||
const handler = openWebUiKnowledgeRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
}),
|
||||
|
||||
getFiles: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
|
||||
const handler = openWebUiFilesRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
}),
|
||||
|
||||
getNotes: publicProcedure.concat(createOneIntegrationMiddleware("query", "openWebUi")).query(async ({ ctx }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
|
||||
const handler = openWebUiNotesRequestHandler.handler(ctx.integration, {});
|
||||
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
@@ -137,6 +165,7 @@ export const openWebUiRouter = createTRPCRouter({
|
||||
.input(z.object({ noteId: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("query", "openWebUi"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
|
||||
const handler = openWebUiNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
|
||||
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
@@ -146,6 +175,7 @@ export const openWebUiRouter = createTRPCRouter({
|
||||
.input(z.object({ knowledgeId: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("query", "openWebUi"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
|
||||
const handler = openWebUiKnowledgeFilesRequestHandler.handler(ctx.integration, {
|
||||
knowledgeId: input.knowledgeId,
|
||||
});
|
||||
@@ -159,6 +189,7 @@ export const openWebUiRouter = createTRPCRouter({
|
||||
.input(z.object({ url: z.string().url() }))
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const client = await createIntegrationAsync(ctx.integration);
|
||||
return await client.processWebAsync(input.url);
|
||||
}),
|
||||
@@ -169,6 +200,7 @@ export const openWebUiRouter = createTRPCRouter({
|
||||
.input(z.object({ filename: z.string(), contentBase64: z.string(), contentType: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const client = await createIntegrationAsync(ctx.integration);
|
||||
return await client.uploadFileAsync(input.filename, input.contentBase64, input.contentType);
|
||||
}),
|
||||
@@ -178,15 +210,18 @@ export const openWebUiRouter = createTRPCRouter({
|
||||
.input(z.object({ filename: z.string(), contentBase64: z.string(), contentType: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const client = await createIntegrationAsync(ctx.integration);
|
||||
const text = await client.transcribeAudioAsync(input.filename, input.contentBase64, input.contentType);
|
||||
return { text };
|
||||
}),
|
||||
|
||||
getChat: publicProcedure
|
||||
// Full chat messages contain private user data; restrict to the creator.
|
||||
getChat: protectedProcedure
|
||||
.input(z.object({ chatId: z.string() }))
|
||||
.concat(createOneIntegrationMiddleware("query", "openWebUi"))
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.query(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const handler = openWebUiChatRequestHandler.handler(ctx.integration, { chatId: input.chatId });
|
||||
const { data } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return data;
|
||||
@@ -196,6 +231,7 @@ export const openWebUiRouter = createTRPCRouter({
|
||||
.input(chatPayloadSchema)
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const client = await createIntegrationAsync(ctx.integration);
|
||||
const chat = await client.createChatAsync({
|
||||
title: input.title,
|
||||
@@ -210,6 +246,7 @@ export const openWebUiRouter = createTRPCRouter({
|
||||
.input(z.object({ chatId: z.string(), chat: chatPayloadSchema }))
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session.user.id);
|
||||
const client = await createIntegrationAsync(ctx.integration);
|
||||
const chat = await client.updateChatAsync(input.chatId, input.chat);
|
||||
await openWebUiChatsRequestHandler.handler(ctx.integration, {}).invalidateAsync();
|
||||
@@ -237,6 +274,7 @@ export const openWebUiRouter = createTRPCRouter({
|
||||
)
|
||||
.concat(createOneIntegrationMiddleware("interact", "openWebUi"))
|
||||
.subscription(({ ctx, input }) => {
|
||||
throwIfNotCreator(ctx.integration, ctx.session?.user.id);
|
||||
return observable<StreamEvent>((emit) => {
|
||||
const controller = new AbortController();
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ interface IntegrationContextProps {
|
||||
name: string;
|
||||
url: string;
|
||||
kind: IntegrationKind;
|
||||
creatorId: string | null;
|
||||
permissions: {
|
||||
hasFullAccess: boolean;
|
||||
hasInteractAccess: boolean;
|
||||
|
||||
@@ -15,6 +15,7 @@ export const getIntegrationsWithPermissionsAsync = async (session: Session | nul
|
||||
name: true,
|
||||
url: true,
|
||||
kind: true,
|
||||
creatorId: true,
|
||||
},
|
||||
with: {
|
||||
userPermissions: {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `integration` ADD `creator_id` text REFERENCES user(id);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -288,6 +288,13 @@
|
||||
"when": 1782836728121,
|
||||
"tag": "0040_charming_tag",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 41,
|
||||
"version": "6",
|
||||
"when": 1782837354691,
|
||||
"tag": "0041_adorable_marten_broadcloak",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -209,6 +209,7 @@ export const integrations = mysqlTable(
|
||||
url: text().notNull(),
|
||||
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
|
||||
appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }),
|
||||
creatorId: varchar({ length: 64 }).references(() => users.id, { onDelete: "set null" }),
|
||||
},
|
||||
(integrations) => ({
|
||||
kindIdx: index("integration__kind_idx").on(integrations.kind),
|
||||
@@ -669,6 +670,10 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
|
||||
fields: [integrations.appId],
|
||||
references: [apps.id],
|
||||
}),
|
||||
creator: one(users, {
|
||||
fields: [integrations.creatorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
|
||||
|
||||
@@ -208,6 +208,7 @@ export const integrations = pgTable(
|
||||
url: text().notNull(),
|
||||
kind: varchar({ length: 128 }).$type<IntegrationKind>().notNull(),
|
||||
appId: varchar({ length: 128 }).references(() => apps.id, { onDelete: "set null" }),
|
||||
creatorId: varchar({ length: 64 }).references(() => users.id, { onDelete: "set null" }),
|
||||
},
|
||||
(integrations) => ({
|
||||
kindIdx: index("integration__kind_idx").on(integrations.kind),
|
||||
@@ -668,6 +669,10 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
|
||||
fields: [integrations.appId],
|
||||
references: [apps.id],
|
||||
}),
|
||||
creator: one(users, {
|
||||
fields: [integrations.creatorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
|
||||
|
||||
@@ -195,6 +195,7 @@ export const integrations = sqliteTable(
|
||||
url: text().notNull(),
|
||||
kind: text().$type<IntegrationKind>().notNull(),
|
||||
appId: text().references(() => apps.id, { onDelete: "set null" }),
|
||||
creatorId: text().references(() => users.id, { onDelete: "set null" }),
|
||||
},
|
||||
(integrations) => ({
|
||||
kindIdx: index("integration__kind_idx").on(integrations.kind),
|
||||
@@ -658,6 +659,10 @@ export const integrationRelations = relations(integrations, ({ one, many }) => (
|
||||
fields: [integrations.appId],
|
||||
references: [apps.id],
|
||||
}),
|
||||
creator: one(users, {
|
||||
fields: [integrations.creatorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const integrationUserPermissionRelations = relations(integrationUserPermissions, ({ one }) => ({
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
"./errors/classes": "./src/errors/index.ts",
|
||||
"./widget-integration-select": "./src/widget-integration-select.tsx",
|
||||
"./modals": "./src/modals/index.ts",
|
||||
"./prefetch": "./src/prefetch.ts"
|
||||
"./prefetch": "./src/prefetch.ts",
|
||||
"./_open-webui/chat": "./src/open-webui/chat.tsx"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "rm -rf .turbo node_modules",
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
right: 0;
|
||||
bottom: var(--mantine-spacing-xs);
|
||||
z-index: 3;
|
||||
width: min(50vw, calc(100% - var(--mantine-spacing-md)));
|
||||
width: calc(100% - var(--mantine-spacing-md));
|
||||
margin-inline: auto;
|
||||
box-shadow: var(--mantine-shadow-md);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user