From 3164b6981cb9a084e6f035270e97154ab661d155 Mon Sep 17 00:00:00 2001
From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com>
Date: Tue, 7 Oct 2025 21:05:13 +0100
Subject: [PATCH] feat: api keys management (EE) (#1665)
* feat: api keys (EE)
* improvements
* fix table
* fix route
* remove token suffix
* api settings
* Fix
* fix
* fix
* fix
---
apps/client/package.json | 1 +
.../public/locales/en-US/translation.json | 24 ++-
apps/client/src/App.tsx | 4 +
.../components/common/no-table-results.tsx | 5 +-
.../components/settings/settings-queries.tsx | 15 ++
.../components/settings/settings-sidebar.tsx | 25 +++
.../components/api-key-created-modal.tsx | 72 +++++++++
.../ee/api-key/components/api-key-table.tsx | 143 ++++++++++++++++
.../components/create-api-key-modal.tsx | 153 ++++++++++++++++++
.../components/revoke-api-key-modal.tsx | 62 +++++++
.../components/update-api-key-modal.tsx | 80 +++++++++
apps/client/src/ee/api-key/index.ts | 11 ++
.../src/ee/api-key/pages/user-api-keys.tsx | 106 ++++++++++++
.../ee/api-key/pages/workspace-api-keys.tsx | 117 ++++++++++++++
.../src/ee/api-key/queries/api-key-query.ts | 97 +++++++++++
.../ee/api-key/services/api-key-service.ts | 32 ++++
.../src/ee/api-key/types/api-key.types.ts | 23 +++
.../group/components/create-group-form.tsx | 3 +-
.../group/components/edit-group-form.tsx | 3 +-
.../src/features/group/queries/group-query.ts | 13 +-
apps/client/src/lib/types.ts | 1 +
apps/client/src/main.tsx | 2 +
apps/server/src/core/auth/dto/jwt-payload.ts | 8 +
.../src/core/auth/services/token.service.ts | 27 +++-
.../src/core/auth/strategies/jwt.strategy.ts | 42 ++++-
.../abilities/workspace-ability.factory.ts | 3 +
.../casl/interfaces/workspace-ability.type.ts | 4 +-
.../workspace/dto/update-workspace.dto.ts | 4 +
.../workspace/services/workspace.service.ts | 9 ++
.../migrations/20250912T101500-api-keys.ts | 30 ++++
.../database/pagination/pagination-options.ts | 5 +
.../repos/workspace/workspace.repo.ts | 18 +++
apps/server/src/database/types/db.d.ts | 28 +++-
.../server/src/database/types/entity.types.ts | 6 +
apps/server/src/ee | 2 +-
pnpm-lock.yaml | 21 +++
36 files changed, 1176 insertions(+), 23 deletions(-)
create mode 100644 apps/client/src/ee/api-key/components/api-key-created-modal.tsx
create mode 100644 apps/client/src/ee/api-key/components/api-key-table.tsx
create mode 100644 apps/client/src/ee/api-key/components/create-api-key-modal.tsx
create mode 100644 apps/client/src/ee/api-key/components/revoke-api-key-modal.tsx
create mode 100644 apps/client/src/ee/api-key/components/update-api-key-modal.tsx
create mode 100644 apps/client/src/ee/api-key/index.ts
create mode 100644 apps/client/src/ee/api-key/pages/user-api-keys.tsx
create mode 100644 apps/client/src/ee/api-key/pages/workspace-api-keys.tsx
create mode 100644 apps/client/src/ee/api-key/queries/api-key-query.ts
create mode 100644 apps/client/src/ee/api-key/services/api-key-service.ts
create mode 100644 apps/client/src/ee/api-key/types/api-key.types.ts
create mode 100644 apps/server/src/database/migrations/20250912T101500-api-keys.ts
diff --git a/apps/client/package.json b/apps/client/package.json
index 0cff05cc..37ecb93d 100644
--- a/apps/client/package.json
+++ b/apps/client/package.json
@@ -17,6 +17,7 @@
"@emoji-mart/react": "^1.1.1",
"@excalidraw/excalidraw": "0.18.0-864353b",
"@mantine/core": "^8.1.3",
+ "@mantine/dates": "^8.3.2",
"@mantine/form": "^8.1.3",
"@mantine/hooks": "^8.1.3",
"@mantine/modals": "^8.1.3",
diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index 6d9e548b..b89dcf61 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -533,5 +533,27 @@
"Remove image": "Remove image",
"Failed to remove image": "Failed to remove image",
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.",
- "Image removed successfully": "Image removed successfully"
+ "Image removed successfully": "Image removed successfully",
+ "API key": "API key",
+ "API key created successfully": "API key created successfully",
+ "API keys": "API keys",
+ "API management": "API management",
+ "Are you sure you want to revoke this API key": "Are you sure you want to revoke this API key",
+ "Create API Key": "Create API Key",
+ "Custom expiration date": "Custom expiration date",
+ "Enter a descriptive token name": "Enter a descriptive token name",
+ "Expiration": "Expiration",
+ "Expired": "Expired",
+ "Expires": "Expires",
+ "I've saved my API key": "I've saved my API key",
+ "Last use": "Last Used",
+ "No API keys found": "No API keys found",
+ "No expiration": "No expiration",
+ "Revoke API key": "Revoke API key",
+ "Revoked successfully": "Revoked successfully",
+ "Select expiration date": "Select expiration date",
+ "This action cannot be undone. Any applications using this API key will stop working.": "This action cannot be undone. Any applications using this API key will stop working.",
+ "Token name": "Token name",
+ "Update API key": "Update API key",
+ "Manage API keys for all users in the workspace": "Manage API keys for all users in the workspace"
}
diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx
index 3995191d..7048f08a 100644
--- a/apps/client/src/App.tsx
+++ b/apps/client/src/App.tsx
@@ -35,6 +35,8 @@ import SpacesPage from "@/pages/spaces/spaces.tsx";
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
import SpaceTrash from "@/pages/space/space-trash.tsx";
+import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
+import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
export default function App() {
const { t } = useTranslation();
@@ -96,8 +98,10 @@ export default function App() {
path={"account/preferences"}
element={}
/>
+ } />
} />
} />
+ } />
} />
} />
} />
diff --git a/apps/client/src/components/common/no-table-results.tsx b/apps/client/src/components/common/no-table-results.tsx
index 124bbb9b..0f34fa2f 100644
--- a/apps/client/src/components/common/no-table-results.tsx
+++ b/apps/client/src/components/common/no-table-results.tsx
@@ -4,14 +4,15 @@ import { useTranslation } from "react-i18next";
interface NoTableResultsProps {
colSpan: number;
+ text?: string;
}
-export default function NoTableResults({ colSpan }: NoTableResultsProps) {
+export default function NoTableResults({ colSpan, text }: NoTableResultsProps) {
const { t } = useTranslation();
return (
- {t("No results found...")}
+ {text || t("No results found...")}
diff --git a/apps/client/src/components/settings/settings-queries.tsx b/apps/client/src/components/settings/settings-queries.tsx
index 2f3b46bd..bc528466 100644
--- a/apps/client/src/components/settings/settings-queries.tsx
+++ b/apps/client/src/components/settings/settings-queries.tsx
@@ -10,6 +10,7 @@ import { getWorkspaceMembers } from "@/features/workspace/services/workspace-ser
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
import { getSsoProviders } from "@/ee/security/services/security-service.ts";
import { getShares } from "@/features/share/services/share-service.ts";
+import { getApiKeys } from "@/ee/api-key";
export const prefetchWorkspaceMembers = () => {
const params = { limit: 100, page: 1, query: "" } as QueryParams;
@@ -65,3 +66,17 @@ export const prefetchShares = () => {
queryFn: () => getShares({ page: 1, limit: 100 }),
});
};
+
+export const prefetchApiKeys = () => {
+ queryClient.prefetchQuery({
+ queryKey: ["api-key-list", { page: 1 }],
+ queryFn: () => getApiKeys({ page: 1 }),
+ });
+};
+
+export const prefetchApiKeyManagement = () => {
+ queryClient.prefetchQuery({
+ queryKey: ["api-key-list", { page: 1 }],
+ queryFn: () => getApiKeys({ page: 1, adminView: true }),
+ });
+};
diff --git a/apps/client/src/components/settings/settings-sidebar.tsx b/apps/client/src/components/settings/settings-sidebar.tsx
index fe0c7e88..abd3f962 100644
--- a/apps/client/src/components/settings/settings-sidebar.tsx
+++ b/apps/client/src/components/settings/settings-sidebar.tsx
@@ -21,6 +21,8 @@ import useUserRole from "@/hooks/use-user-role.tsx";
import { useAtom } from "jotai/index";
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import {
+ prefetchApiKeyManagement,
+ prefetchApiKeys,
prefetchBilling,
prefetchGroups,
prefetchLicense,
@@ -60,6 +62,14 @@ const groupedData: DataGroup[] = [
icon: IconBrush,
path: "/settings/account/preferences",
},
+ {
+ label: "API keys",
+ icon: IconKey,
+ path: "/settings/account/api-keys",
+ isCloud: true,
+ isEnterprise: true,
+ showDisabledInNonEE: true,
+ },
],
},
{
@@ -90,6 +100,15 @@ const groupedData: DataGroup[] = [
{ label: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
+ {
+ label: "API management",
+ icon: IconKey,
+ path: "/settings/api-keys",
+ isCloud: true,
+ isEnterprise: true,
+ isAdmin: true,
+ showDisabledInNonEE: true,
+ },
],
},
{
@@ -195,6 +214,12 @@ export default function SettingsSidebar() {
case "Public sharing":
prefetchHandler = prefetchShares;
break;
+ case "API keys":
+ prefetchHandler = prefetchApiKeys;
+ break;
+ case "API management":
+ prefetchHandler = prefetchApiKeyManagement;
+ break;
default:
break;
}
diff --git a/apps/client/src/ee/api-key/components/api-key-created-modal.tsx b/apps/client/src/ee/api-key/components/api-key-created-modal.tsx
new file mode 100644
index 00000000..6a01ee3c
--- /dev/null
+++ b/apps/client/src/ee/api-key/components/api-key-created-modal.tsx
@@ -0,0 +1,72 @@
+import {
+ Modal,
+ Text,
+ Stack,
+ Alert,
+ Group,
+ Button,
+ TextInput,
+} from "@mantine/core";
+import { IconAlertTriangle } from "@tabler/icons-react";
+import { useTranslation } from "react-i18next";
+import { IApiKey } from "@/ee/api-key";
+import CopyTextButton from "@/components/common/copy.tsx";
+
+interface ApiKeyCreatedModalProps {
+ opened: boolean;
+ onClose: () => void;
+ apiKey: IApiKey;
+}
+
+export function ApiKeyCreatedModal({
+ opened,
+ onClose,
+ apiKey,
+}: ApiKeyCreatedModalProps) {
+ const { t } = useTranslation();
+
+ if (!apiKey) return null;
+
+ return (
+
+
+ }
+ title={t("Important")}
+ color="red"
+ >
+ {t(
+ "Make sure to copy your API key now. You won't be able to see it again!",
+ )}
+
+
+
+
+ {t("API key")}
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/api-key/components/api-key-table.tsx b/apps/client/src/ee/api-key/components/api-key-table.tsx
new file mode 100644
index 00000000..48757acc
--- /dev/null
+++ b/apps/client/src/ee/api-key/components/api-key-table.tsx
@@ -0,0 +1,143 @@
+import { ActionIcon, Group, Menu, Table, Text } from "@mantine/core";
+import { IconDots, IconEdit, IconTrash } from "@tabler/icons-react";
+import { format } from "date-fns";
+import { useTranslation } from "react-i18next";
+import { IApiKey } from "@/ee/api-key";
+import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
+import React from "react";
+import NoTableResults from "@/components/common/no-table-results";
+
+interface ApiKeyTableProps {
+ apiKeys: IApiKey[];
+ isLoading?: boolean;
+ showUserColumn?: boolean;
+ onUpdate?: (apiKey: IApiKey) => void;
+ onRevoke?: (apiKey: IApiKey) => void;
+}
+
+export function ApiKeyTable({
+ apiKeys,
+ isLoading,
+ showUserColumn = false,
+ onUpdate,
+ onRevoke,
+}: ApiKeyTableProps) {
+ const { t } = useTranslation();
+
+ const formatDate = (date: Date | string | null) => {
+ if (!date) return t("Never");
+ return format(new Date(date), "MMM dd, yyyy");
+ };
+
+ const isExpired = (expiresAt: string | null) => {
+ if (!expiresAt) return false;
+ return new Date(expiresAt) < new Date();
+ };
+
+ return (
+
+
+
+
+ {t("Name")}
+ {showUserColumn && {t("User")}}
+ {t("Last used")}
+ {t("Expires")}
+ {t("Created")}
+
+
+
+
+
+ {apiKeys && apiKeys.length > 0 ? (
+ apiKeys.map((apiKey: IApiKey, index: number) => (
+
+
+
+ {apiKey.name}
+
+
+
+ {showUserColumn && apiKey.creator && (
+
+
+
+
+ {apiKey.creator.name}
+
+
+
+ )}
+
+
+
+ {formatDate(apiKey.lastUsedAt)}
+
+
+
+
+ {apiKey.expiresAt ? (
+ isExpired(apiKey.expiresAt) ? (
+
+ {t("Expired")}
+
+ ) : (
+
+ {formatDate(apiKey.expiresAt)}
+
+ )
+ ) : (
+
+ {t("Never")}
+
+ )}
+
+
+
+
+ {formatDate(apiKey.createdAt)}
+
+
+
+
+
+
+
+ ))
+ ) : (
+
+ )}
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/api-key/components/create-api-key-modal.tsx b/apps/client/src/ee/api-key/components/create-api-key-modal.tsx
new file mode 100644
index 00000000..cade36e8
--- /dev/null
+++ b/apps/client/src/ee/api-key/components/create-api-key-modal.tsx
@@ -0,0 +1,153 @@
+import { lazy, Suspense, useState } from "react";
+import { Modal, TextInput, Button, Group, Stack, Select } from "@mantine/core";
+import { useForm } from "@mantine/form";
+import { zodResolver } from "mantine-form-zod-resolver";
+import { z } from "zod";
+import { useTranslation } from "react-i18next";
+import { useCreateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
+import { IconCalendar } from "@tabler/icons-react";
+import { IApiKey } from "@/ee/api-key";
+
+const DateInput = lazy(() =>
+ import("@mantine/dates").then((module) => ({
+ default: module.DateInput,
+ })),
+);
+
+interface CreateApiKeyModalProps {
+ opened: boolean;
+ onClose: () => void;
+ onSuccess: (response: IApiKey) => void;
+}
+
+const formSchema = z.object({
+ name: z.string().min(1, "Name is required"),
+ expiresAt: z.string().optional(),
+});
+type FormValues = z.infer;
+
+export function CreateApiKeyModal({
+ opened,
+ onClose,
+ onSuccess,
+}: CreateApiKeyModalProps) {
+ const { t } = useTranslation();
+ const [expirationOption, setExpirationOption] = useState("30");
+ const createApiKeyMutation = useCreateApiKeyMutation();
+
+ const form = useForm({
+ validate: zodResolver(formSchema),
+ initialValues: {
+ name: "",
+ expiresAt: "",
+ },
+ });
+
+ const getExpirationDate = (): string | undefined => {
+ if (expirationOption === "never") {
+ return undefined;
+ }
+ if (expirationOption === "custom") {
+ return form.values.expiresAt;
+ }
+ const days = parseInt(expirationOption);
+ const date = new Date();
+ date.setDate(date.getDate() + days);
+ return date.toISOString();
+ };
+
+ const getExpirationLabel = (days: number) => {
+ const date = new Date();
+ date.setDate(date.getDate() + days);
+ const formatted = date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "2-digit",
+ year: "numeric",
+ });
+ return `${days} days (${formatted})`;
+ };
+
+ const expirationOptions = [
+ { value: "30", label: getExpirationLabel(30) },
+ { value: "60", label: getExpirationLabel(60) },
+ { value: "90", label: getExpirationLabel(90) },
+ { value: "365", label: getExpirationLabel(365) },
+ { value: "custom", label: t("Custom") },
+ { value: "never", label: t("No expiration") },
+ ];
+
+ const handleSubmit = async (data: {
+ name?: string;
+ expiresAt?: string | Date;
+ }) => {
+ const groupData = {
+ name: data.name,
+ expiresAt: getExpirationDate(),
+ };
+
+ try {
+ const createdKey = await createApiKeyMutation.mutateAsync(groupData);
+ onSuccess(createdKey);
+ form.reset();
+ onClose();
+ } catch (err) {
+ //
+ }
+ };
+
+ const handleClose = () => {
+ form.reset();
+ setExpirationOption("30");
+ onClose();
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/api-key/components/revoke-api-key-modal.tsx b/apps/client/src/ee/api-key/components/revoke-api-key-modal.tsx
new file mode 100644
index 00000000..3092ead4
--- /dev/null
+++ b/apps/client/src/ee/api-key/components/revoke-api-key-modal.tsx
@@ -0,0 +1,62 @@
+import { Modal, Text, Button, Group, Stack } from "@mantine/core";
+import { useTranslation } from "react-i18next";
+
+import { useRevokeApiKeyMutation } from "@/ee/api-key/queries/api-key-query.ts";
+import { IApiKey } from "@/ee/api-key";
+
+interface RevokeApiKeyModalProps {
+ opened: boolean;
+ onClose: () => void;
+ apiKey: IApiKey | null;
+}
+
+export function RevokeApiKeyModal({
+ opened,
+ onClose,
+ apiKey,
+}: RevokeApiKeyModalProps) {
+ const { t } = useTranslation();
+ const revokeApiKeyMutation = useRevokeApiKeyMutation();
+
+ const handleRevoke = async () => {
+ if (!apiKey) return;
+ await revokeApiKeyMutation.mutateAsync({
+ apiKeyId: apiKey.id,
+ });
+ onClose();
+ };
+
+ return (
+
+
+
+ {t("Are you sure you want to revoke this API key")}{" "}
+ {apiKey?.name}?
+
+
+ {t(
+ "This action cannot be undone. Any applications using this API key will stop working.",
+ )}
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/api-key/components/update-api-key-modal.tsx b/apps/client/src/ee/api-key/components/update-api-key-modal.tsx
new file mode 100644
index 00000000..536c003e
--- /dev/null
+++ b/apps/client/src/ee/api-key/components/update-api-key-modal.tsx
@@ -0,0 +1,80 @@
+import { Modal, TextInput, Button, Group, Stack } from "@mantine/core";
+import { useForm } from "@mantine/form";
+import { zodResolver } from "mantine-form-zod-resolver";
+import { z } from "zod";
+import { useTranslation } from "react-i18next";
+import { useUpdateApiKeyMutation } from "@/ee/api-key/queries/api-key-query";
+import { IApiKey } from "@/ee/api-key";
+import { useEffect } from "react";
+
+const formSchema = z.object({
+ name: z.string().min(1, "Name is required"),
+});
+type FormValues = z.infer;
+
+interface UpdateApiKeyModalProps {
+ opened: boolean;
+ onClose: () => void;
+ apiKey: IApiKey | null;
+}
+
+export function UpdateApiKeyModal({
+ opened,
+ onClose,
+ apiKey,
+}: UpdateApiKeyModalProps) {
+ const { t } = useTranslation();
+ const updateApiKeyMutation = useUpdateApiKeyMutation();
+
+ const form = useForm({
+ validate: zodResolver(formSchema),
+ initialValues: {
+ name: "",
+ },
+ });
+
+ useEffect(() => {
+ if (opened && apiKey) {
+ form.setValues({ name: apiKey.name });
+ }
+ }, [opened, apiKey]);
+
+ const handleSubmit = async (data: { name?: string }) => {
+ const apiKeyData = {
+ apiKeyId: apiKey.id,
+ name: data.name,
+ };
+
+ await updateApiKeyMutation.mutateAsync(apiKeyData);
+ onClose();
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/api-key/index.ts b/apps/client/src/ee/api-key/index.ts
new file mode 100644
index 00000000..24bc3d1c
--- /dev/null
+++ b/apps/client/src/ee/api-key/index.ts
@@ -0,0 +1,11 @@
+export { ApiKeyTable } from "./components/api-key-table";
+export { CreateApiKeyModal } from "./components/create-api-key-modal";
+export { ApiKeyCreatedModal } from "./components/api-key-created-modal";
+export { UpdateApiKeyModal } from "./components/update-api-key-modal";
+export { RevokeApiKeyModal } from "./components/revoke-api-key-modal";
+
+// Services
+export * from "./services/api-key-service";
+
+// Types
+export * from "./types/api-key.types";
diff --git a/apps/client/src/ee/api-key/pages/user-api-keys.tsx b/apps/client/src/ee/api-key/pages/user-api-keys.tsx
new file mode 100644
index 00000000..a0aae3b6
--- /dev/null
+++ b/apps/client/src/ee/api-key/pages/user-api-keys.tsx
@@ -0,0 +1,106 @@
+import React, { useState } from "react";
+import { Button, Group, Space } from "@mantine/core";
+import { Helmet } from "react-helmet-async";
+import { useTranslation } from "react-i18next";
+import SettingsTitle from "@/components/settings/settings-title";
+import { getAppName } from "@/lib/config";
+import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
+import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
+import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
+import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
+import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
+import Paginate from "@/components/common/paginate";
+import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
+import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
+import { IApiKey } from "@/ee/api-key";
+
+export default function UserApiKeys() {
+ const { t } = useTranslation();
+ const { page, setPage } = usePaginateAndSearch();
+ const [createModalOpened, setCreateModalOpened] = useState(false);
+ const [createdApiKey, setCreatedApiKey] = useState(null);
+ const [updateModalOpened, setUpdateModalOpened] = useState(false);
+ const [revokeModalOpened, setRevokeModalOpened] = useState(false);
+ const [selectedApiKey, setSelectedApiKey] = useState(null);
+ const { data, isLoading } = useGetApiKeysQuery({ page });
+
+ const handleCreateSuccess = (response: IApiKey) => {
+ setCreatedApiKey(response);
+ };
+
+ const handleUpdate = (apiKey: IApiKey) => {
+ setSelectedApiKey(apiKey);
+ setUpdateModalOpened(true);
+ };
+
+ const handleRevoke = (apiKey: IApiKey) => {
+ setSelectedApiKey(apiKey);
+ setRevokeModalOpened(true);
+ };
+
+ return (
+ <>
+
+
+ {t("API keys")} - {getAppName()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {data?.items.length > 0 && (
+
+ )}
+
+ setCreateModalOpened(false)}
+ onSuccess={handleCreateSuccess}
+ />
+
+ setCreatedApiKey(null)}
+ apiKey={createdApiKey}
+ />
+
+ {
+ setUpdateModalOpened(false);
+ setSelectedApiKey(null);
+ }}
+ apiKey={selectedApiKey}
+ />
+
+ {
+ setRevokeModalOpened(false);
+ setSelectedApiKey(null);
+ }}
+ apiKey={selectedApiKey}
+ />
+ >
+ );
+}
diff --git a/apps/client/src/ee/api-key/pages/workspace-api-keys.tsx b/apps/client/src/ee/api-key/pages/workspace-api-keys.tsx
new file mode 100644
index 00000000..cf459373
--- /dev/null
+++ b/apps/client/src/ee/api-key/pages/workspace-api-keys.tsx
@@ -0,0 +1,117 @@
+import React, { useState } from "react";
+import { Button, Group, Space, Text } from "@mantine/core";
+import { Helmet } from "react-helmet-async";
+import { useTranslation } from "react-i18next";
+import SettingsTitle from "@/components/settings/settings-title";
+import { getAppName } from "@/lib/config";
+import { ApiKeyTable } from "@/ee/api-key/components/api-key-table";
+import { CreateApiKeyModal } from "@/ee/api-key/components/create-api-key-modal";
+import { ApiKeyCreatedModal } from "@/ee/api-key/components/api-key-created-modal";
+import { UpdateApiKeyModal } from "@/ee/api-key/components/update-api-key-modal";
+import { RevokeApiKeyModal } from "@/ee/api-key/components/revoke-api-key-modal";
+import Paginate from "@/components/common/paginate";
+import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search";
+import { useGetApiKeysQuery } from "@/ee/api-key/queries/api-key-query.ts";
+import { IApiKey } from "@/ee/api-key";
+import useUserRole from '@/hooks/use-user-role.tsx';
+
+export default function WorkspaceApiKeys() {
+ const { t } = useTranslation();
+ const { page, setPage } = usePaginateAndSearch();
+ const [createModalOpened, setCreateModalOpened] = useState(false);
+ const [createdApiKey, setCreatedApiKey] = useState(null);
+ const [updateModalOpened, setUpdateModalOpened] = useState(false);
+ const [revokeModalOpened, setRevokeModalOpened] = useState(false);
+ const [selectedApiKey, setSelectedApiKey] = useState(null);
+ const { data, isLoading } = useGetApiKeysQuery({ page, adminView: true });
+ const { isAdmin } = useUserRole();
+
+ if (!isAdmin) {
+ return null;
+ }
+
+ const handleCreateSuccess = (response: IApiKey) => {
+ setCreatedApiKey(response);
+ };
+
+ const handleUpdate = (apiKey: IApiKey) => {
+ setSelectedApiKey(apiKey);
+ setUpdateModalOpened(true);
+ };
+
+ const handleRevoke = (apiKey: IApiKey) => {
+ setSelectedApiKey(apiKey);
+ setRevokeModalOpened(true);
+ };
+
+ return (
+ <>
+
+
+ {t("API management")} - {getAppName()}
+
+
+
+
+
+
+ {t("Manage API keys for all users in the workspace")}
+
+
+
+
+
+
+
+
+
+
+ {data?.items.length > 0 && (
+
+ )}
+
+ setCreateModalOpened(false)}
+ onSuccess={handleCreateSuccess}
+ />
+
+ setCreatedApiKey(null)}
+ apiKey={createdApiKey}
+ />
+
+ {
+ setUpdateModalOpened(false);
+ setSelectedApiKey(null);
+ }}
+ apiKey={selectedApiKey}
+ />
+
+ {
+ setRevokeModalOpened(false);
+ setSelectedApiKey(null);
+ }}
+ apiKey={selectedApiKey}
+ />
+ >
+ );
+}
diff --git a/apps/client/src/ee/api-key/queries/api-key-query.ts b/apps/client/src/ee/api-key/queries/api-key-query.ts
new file mode 100644
index 00000000..75f2d351
--- /dev/null
+++ b/apps/client/src/ee/api-key/queries/api-key-query.ts
@@ -0,0 +1,97 @@
+import { IPagination, QueryParams } from "@/lib/types.ts";
+import {
+ keepPreviousData,
+ useMutation,
+ useQuery,
+ useQueryClient,
+ UseQueryResult,
+} from "@tanstack/react-query";
+import {
+ createApiKey,
+ getApiKeys,
+ IApiKey,
+ ICreateApiKeyRequest,
+ IUpdateApiKeyRequest,
+ revokeApiKey,
+ updateApiKey,
+} from "@/ee/api-key";
+import { notifications } from "@mantine/notifications";
+import { useTranslation } from "react-i18next";
+
+export function useGetApiKeysQuery(
+ params?: QueryParams,
+): UseQueryResult, Error> {
+ return useQuery({
+ queryKey: ["api-key-list", params],
+ queryFn: () => getApiKeys(params),
+ staleTime: 0,
+ gcTime: 0,
+ placeholderData: keepPreviousData,
+ });
+}
+
+export function useRevokeApiKeyMutation() {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+
+ return useMutation<
+ void,
+ Error,
+ {
+ apiKeyId: string;
+ }
+ >({
+ mutationFn: (data) => revokeApiKey(data),
+ onSuccess: (data, variables) => {
+ notifications.show({ message: t("Revoked successfully") });
+ queryClient.invalidateQueries({
+ predicate: (item) =>
+ ["api-key-list"].includes(item.queryKey[0] as string),
+ });
+ },
+ onError: (error) => {
+ const errorMessage = error["response"]?.data?.message;
+ notifications.show({ message: errorMessage, color: "red" });
+ },
+ });
+}
+
+export function useCreateApiKeyMutation() {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: (data) => createApiKey(data),
+ onSuccess: () => {
+ notifications.show({ message: t("API key created successfully") });
+ queryClient.invalidateQueries({
+ predicate: (item) =>
+ ["api-key-list"].includes(item.queryKey[0] as string),
+ });
+ },
+ onError: (error) => {
+ const errorMessage = error["response"]?.data?.message;
+ notifications.show({ message: errorMessage, color: "red" });
+ },
+ });
+}
+
+export function useUpdateApiKeyMutation() {
+ const queryClient = useQueryClient();
+ const { t } = useTranslation();
+
+ return useMutation({
+ mutationFn: (data) => updateApiKey(data),
+ onSuccess: (data, variables) => {
+ notifications.show({ message: t("Updated successfully") });
+ queryClient.invalidateQueries({
+ predicate: (item) =>
+ ["api-key-list"].includes(item.queryKey[0] as string),
+ });
+ },
+ onError: (error) => {
+ const errorMessage = error["response"]?.data?.message;
+ notifications.show({ message: errorMessage, color: "red" });
+ },
+ });
+}
diff --git a/apps/client/src/ee/api-key/services/api-key-service.ts b/apps/client/src/ee/api-key/services/api-key-service.ts
new file mode 100644
index 00000000..c83e25d0
--- /dev/null
+++ b/apps/client/src/ee/api-key/services/api-key-service.ts
@@ -0,0 +1,32 @@
+import api from "@/lib/api-client";
+import {
+ ICreateApiKeyRequest,
+ IApiKey,
+ IUpdateApiKeyRequest,
+} from "@/ee/api-key/types/api-key.types";
+import { IPagination, QueryParams } from "@/lib/types.ts";
+
+export async function getApiKeys(
+ params?: QueryParams,
+): Promise> {
+ const req = await api.post("/api-keys", { ...params });
+ return req.data;
+}
+
+export async function createApiKey(
+ data: ICreateApiKeyRequest,
+): Promise {
+ const req = await api.post("/api-keys/create", data);
+ return req.data;
+}
+
+export async function updateApiKey(
+ data: IUpdateApiKeyRequest,
+): Promise {
+ const req = await api.post("/api-keys/update", data);
+ return req.data;
+}
+
+export async function revokeApiKey(data: { apiKeyId: string }): Promise {
+ await api.post("/api-keys/revoke", data);
+}
diff --git a/apps/client/src/ee/api-key/types/api-key.types.ts b/apps/client/src/ee/api-key/types/api-key.types.ts
new file mode 100644
index 00000000..57890d1a
--- /dev/null
+++ b/apps/client/src/ee/api-key/types/api-key.types.ts
@@ -0,0 +1,23 @@
+import { IUser } from "@/features/user/types/user.types.ts";
+
+export interface IApiKey {
+ id: string;
+ name: string;
+ token?: string;
+ creatorId: string;
+ workspaceId: string;
+ expiresAt: string | null;
+ lastUsedAt: string | null;
+ createdAt: string;
+ creator: Partial;
+}
+
+export interface ICreateApiKeyRequest {
+ name: string;
+ expiresAt?: string;
+}
+
+export interface IUpdateApiKeyRequest {
+ apiKeyId: string;
+ name: string;
+}
diff --git a/apps/client/src/features/group/components/create-group-form.tsx b/apps/client/src/features/group/components/create-group-form.tsx
index 10d4375f..a08e725a 100644
--- a/apps/client/src/features/group/components/create-group-form.tsx
+++ b/apps/client/src/features/group/components/create-group-form.tsx
@@ -1,11 +1,12 @@
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
import React, { useState } from "react";
import { useCreateGroupMutation } from "@/features/group/queries/group-query.ts";
-import { useForm, zodResolver } from "@mantine/form";
+import { useForm } from "@mantine/form";
import * as z from "zod";
import { useNavigate } from "react-router-dom";
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
import { useTranslation } from "react-i18next";
+import { zodResolver } from 'mantine-form-zod-resolver';
const formSchema = z.object({
name: z.string().trim().min(2).max(50),
diff --git a/apps/client/src/features/group/components/edit-group-form.tsx b/apps/client/src/features/group/components/edit-group-form.tsx
index 0ea679b4..a682e0b4 100644
--- a/apps/client/src/features/group/components/edit-group-form.tsx
+++ b/apps/client/src/features/group/components/edit-group-form.tsx
@@ -4,10 +4,11 @@ import {
useGroupQuery,
useUpdateGroupMutation,
} from "@/features/group/queries/group-query.ts";
-import { useForm, zodResolver } from "@mantine/form";
+import { useForm } from "@mantine/form";
import * as z from "zod";
import { useParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
+import { zodResolver } from "mantine-form-zod-resolver";
const formSchema = z.object({
name: z.string().min(2).max(50),
diff --git a/apps/client/src/features/group/queries/group-query.ts b/apps/client/src/features/group/queries/group-query.ts
index aeb7787f..01f4509e 100644
--- a/apps/client/src/features/group/queries/group-query.ts
+++ b/apps/client/src/features/group/queries/group-query.ts
@@ -22,6 +22,7 @@ import { IUser } from "@/features/user/types/user.types.ts";
import { useEffect } from "react";
import { validate as isValidUuid } from "uuid";
import { queryClient } from "@/main.tsx";
+import { useTranslation } from 'react-i18next';
export function useGetGroupsQuery(
params?: QueryParams,
@@ -73,11 +74,12 @@ export function useCreateGroupMutation() {
export function useUpdateGroupMutation() {
const queryClient = useQueryClient();
+ const { t } = useTranslation();
return useMutation>({
mutationFn: (data) => updateGroup(data),
onSuccess: (data, variables) => {
- notifications.show({ message: "Group updated successfully" });
+ notifications.show({ message: t("Group updated successfully") });
queryClient.invalidateQueries({
queryKey: ["group", variables.groupId],
});
@@ -91,11 +93,12 @@ export function useUpdateGroupMutation() {
export function useDeleteGroupMutation() {
const queryClient = useQueryClient();
+ const { t } = useTranslation();
return useMutation({
mutationFn: (groupId: string) => deleteGroup({ groupId }),
onSuccess: (data, variables) => {
- notifications.show({ message: "Group deleted successfully" });
+ notifications.show({ message: t("Group deleted successfully") });
queryClient.refetchQueries({ queryKey: ["groups"] });
},
onError: (error) => {
@@ -119,11 +122,12 @@ export function useGroupMembersQuery(
export function useAddGroupMemberMutation() {
const queryClient = useQueryClient();
+ const { t } = useTranslation();
return useMutation({
mutationFn: (data) => addGroupMember(data),
onSuccess: (data, variables) => {
- notifications.show({ message: "Added successfully" });
+ notifications.show({ message: t("Added successfully") });
queryClient.invalidateQueries({
queryKey: ["groupMembers", variables.groupId],
});
@@ -139,6 +143,7 @@ export function useAddGroupMemberMutation() {
export function useRemoveGroupMemberMutation() {
const queryClient = useQueryClient();
+ const { t } = useTranslation();
return useMutation<
void,
@@ -150,7 +155,7 @@ export function useRemoveGroupMemberMutation() {
>({
mutationFn: (data) => removeGroupMember(data),
onSuccess: (data, variables) => {
- notifications.show({ message: "Removed successfully" });
+ notifications.show({ message: t("Removed successfully") });
queryClient.invalidateQueries({
queryKey: ["groupMembers", variables.groupId],
});
diff --git a/apps/client/src/lib/types.ts b/apps/client/src/lib/types.ts
index fd0e3212..2a926d46 100644
--- a/apps/client/src/lib/types.ts
+++ b/apps/client/src/lib/types.ts
@@ -2,6 +2,7 @@ export interface QueryParams {
query?: string;
page?: number;
limit?: number;
+ adminView?: boolean;
}
export enum UserRole {
diff --git a/apps/client/src/main.tsx b/apps/client/src/main.tsx
index 22c3f1c1..f084b089 100644
--- a/apps/client/src/main.tsx
+++ b/apps/client/src/main.tsx
@@ -1,6 +1,8 @@
import "@mantine/core/styles.css";
import "@mantine/spotlight/styles.css";
import "@mantine/notifications/styles.css";
+import '@mantine/dates/styles.css';
+
import ReactDOM from "react-dom/client";
import App from "./App.tsx";
import { mantineCssResolver, theme } from "@/theme";
diff --git a/apps/server/src/core/auth/dto/jwt-payload.ts b/apps/server/src/core/auth/dto/jwt-payload.ts
index 06322712..0f7db401 100644
--- a/apps/server/src/core/auth/dto/jwt-payload.ts
+++ b/apps/server/src/core/auth/dto/jwt-payload.ts
@@ -4,6 +4,7 @@ export enum JwtType {
EXCHANGE = 'exchange',
ATTACHMENT = 'attachment',
MFA_TOKEN = 'mfa_token',
+ API_KEY = 'api_key',
}
export type JwtPayload = {
sub: string;
@@ -36,3 +37,10 @@ export interface JwtMfaTokenPayload {
workspaceId: string;
type: 'mfa_token';
}
+
+export type JwtApiKeyPayload = {
+ sub: string;
+ workspaceId: string;
+ apiKeyId: string;
+ type: 'api_key';
+};
diff --git a/apps/server/src/core/auth/services/token.service.ts b/apps/server/src/core/auth/services/token.service.ts
index f1c4c5c0..0415b22d 100644
--- a/apps/server/src/core/auth/services/token.service.ts
+++ b/apps/server/src/core/auth/services/token.service.ts
@@ -6,6 +6,7 @@ import {
import { JwtService } from '@nestjs/jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
import {
+ JwtApiKeyPayload,
JwtAttachmentPayload,
JwtCollabPayload,
JwtExchangePayload,
@@ -77,10 +78,7 @@ export class TokenService {
return this.jwtService.sign(payload, { expiresIn: '1h' });
}
- async generateMfaToken(
- user: User,
- workspaceId: string,
- ): Promise {
+ async generateMfaToken(user: User, workspaceId: string): Promise {
if (user.deactivatedAt || user.deletedAt) {
throw new ForbiddenException();
}
@@ -93,6 +91,27 @@ export class TokenService {
return this.jwtService.sign(payload, { expiresIn: '5m' });
}
+ async generateApiToken(opts: {
+ apiKeyId: string;
+ user: User;
+ workspaceId: string;
+ expiresIn?: string | number;
+ }): Promise {
+ const { apiKeyId, user, workspaceId, expiresIn } = opts;
+ if (user.deactivatedAt || user.deletedAt) {
+ throw new ForbiddenException();
+ }
+
+ const payload: JwtApiKeyPayload = {
+ sub: user.id,
+ apiKeyId: apiKeyId,
+ workspaceId,
+ type: JwtType.API_KEY,
+ };
+
+ return this.jwtService.sign(payload, expiresIn ? { expiresIn } : {});
+ }
+
async verifyJwt(token: string, tokenType: string) {
const payload = await this.jwtService.verifyAsync(token, {
secret: this.environmentService.getAppSecret(),
diff --git a/apps/server/src/core/auth/strategies/jwt.strategy.ts b/apps/server/src/core/auth/strategies/jwt.strategy.ts
index c31a597b..7616fb61 100644
--- a/apps/server/src/core/auth/strategies/jwt.strategy.ts
+++ b/apps/server/src/core/auth/strategies/jwt.strategy.ts
@@ -2,11 +2,12 @@ import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-jwt';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
-import { JwtPayload, JwtType } from '../dto/jwt-payload';
+import { JwtApiKeyPayload, JwtPayload, JwtType } from '../dto/jwt-payload';
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { FastifyRequest } from 'fastify';
import { extractBearerTokenFromHeader } from '../../../common/helpers';
+import { ModuleRef } from '@nestjs/core';
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
@@ -16,6 +17,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
private userRepo: UserRepo,
private workspaceRepo: WorkspaceRepo,
private readonly environmentService: EnvironmentService,
+ private moduleRef: ModuleRef,
) {
super({
jwtFromRequest: (req: FastifyRequest) => {
@@ -27,8 +29,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
});
}
- async validate(req: any, payload: JwtPayload) {
- if (!payload.workspaceId || payload.type !== JwtType.ACCESS) {
+ async validate(req: any, payload: JwtPayload | JwtApiKeyPayload) {
+ if (!payload.workspaceId) {
throw new UnauthorizedException();
}
@@ -36,6 +38,14 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
throw new UnauthorizedException('Workspace does not match');
}
+ if (payload.type === JwtType.API_KEY) {
+ return this.validateApiKey(req, payload as JwtApiKeyPayload);
+ }
+
+ if (payload.type !== JwtType.ACCESS) {
+ throw new UnauthorizedException();
+ }
+
const workspace = await this.workspaceRepo.findById(payload.workspaceId);
if (!workspace) {
@@ -49,4 +59,30 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
return { user, workspace };
}
+
+ private async validateApiKey(req: any, payload: JwtApiKeyPayload) {
+ let ApiKeyModule: any;
+ let isApiKeyModuleReady = false;
+
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ ApiKeyModule = require('./../../../ee/api-key/api-key.service');
+ isApiKeyModuleReady = true;
+ } catch (err) {
+ this.logger.debug(
+ 'API Key module requested but enterprise module not bundled in this build',
+ );
+ isApiKeyModuleReady = false;
+ }
+
+ if (isApiKeyModuleReady) {
+ const ApiKeyService = this.moduleRef.get(ApiKeyModule.ApiKeyService, {
+ strict: false,
+ });
+
+ return ApiKeyService.validateApiKey(payload);
+ }
+
+ throw new UnauthorizedException('Enterprise API Key module missing');
+ }
}
diff --git a/apps/server/src/core/casl/abilities/workspace-ability.factory.ts b/apps/server/src/core/casl/abilities/workspace-ability.factory.ts
index 2fcac679..7344fcbb 100644
--- a/apps/server/src/core/casl/abilities/workspace-ability.factory.ts
+++ b/apps/server/src/core/casl/abilities/workspace-ability.factory.ts
@@ -40,6 +40,7 @@ function buildWorkspaceOwnerAbility() {
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
+ can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
return build();
}
@@ -55,6 +56,7 @@ function buildWorkspaceAdminAbility() {
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
+ can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
return build();
}
@@ -68,6 +70,7 @@ function buildWorkspaceMemberAbility() {
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Space);
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Group);
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
+ can(WorkspaceCaslAction.Create, WorkspaceCaslSubject.API);
return build();
}
diff --git a/apps/server/src/core/casl/interfaces/workspace-ability.type.ts b/apps/server/src/core/casl/interfaces/workspace-ability.type.ts
index 99d56ffe..00d1677d 100644
--- a/apps/server/src/core/casl/interfaces/workspace-ability.type.ts
+++ b/apps/server/src/core/casl/interfaces/workspace-ability.type.ts
@@ -11,6 +11,7 @@ export enum WorkspaceCaslSubject {
Space = 'space',
Group = 'group',
Attachment = 'attachment',
+ API = 'api_key',
}
export type IWorkspaceAbility =
@@ -18,4 +19,5 @@ export type IWorkspaceAbility =
| [WorkspaceCaslAction, WorkspaceCaslSubject.Member]
| [WorkspaceCaslAction, WorkspaceCaslSubject.Space]
| [WorkspaceCaslAction, WorkspaceCaslSubject.Group]
- | [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment];
+ | [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment]
+ | [WorkspaceCaslAction, WorkspaceCaslSubject.API];
diff --git a/apps/server/src/core/workspace/dto/update-workspace.dto.ts b/apps/server/src/core/workspace/dto/update-workspace.dto.ts
index a0182a77..8e4df4e1 100644
--- a/apps/server/src/core/workspace/dto/update-workspace.dto.ts
+++ b/apps/server/src/core/workspace/dto/update-workspace.dto.ts
@@ -18,4 +18,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@IsBoolean()
enforceMfa: boolean;
+
+ @IsOptional()
+ @IsBoolean()
+ restrictApiToAdmins: boolean;
}
diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts
index 694eaa44..a3db431e 100644
--- a/apps/server/src/core/workspace/services/workspace.service.ts
+++ b/apps/server/src/core/workspace/services/workspace.service.ts
@@ -303,6 +303,15 @@ export class WorkspaceService {
}
}
+ if (typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined') {
+ await this.workspaceRepo.updateApiSettings(
+ workspaceId,
+ 'restrictToAdmins',
+ updateWorkspaceDto.restrictApiToAdmins,
+ );
+ delete updateWorkspaceDto.restrictApiToAdmins;
+ }
+
await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId);
const workspace = await this.workspaceRepo.findById(workspaceId, {
diff --git a/apps/server/src/database/migrations/20250912T101500-api-keys.ts b/apps/server/src/database/migrations/20250912T101500-api-keys.ts
new file mode 100644
index 00000000..da130ff7
--- /dev/null
+++ b/apps/server/src/database/migrations/20250912T101500-api-keys.ts
@@ -0,0 +1,30 @@
+import { Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely): Promise {
+ await db.schema
+ .createTable('api_keys')
+ .addColumn('id', 'uuid', (col) =>
+ col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
+ )
+ .addColumn('name', 'text', (col) => col)
+ .addColumn('creator_id', 'uuid', (col) =>
+ col.notNull().references('users.id').onDelete('cascade'),
+ )
+ .addColumn('workspace_id', 'uuid', (col) =>
+ col.notNull().references('workspaces.id').onDelete('cascade'),
+ )
+ .addColumn('expires_at', 'timestamptz')
+ .addColumn('last_used_at', 'timestamptz')
+ .addColumn('created_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .addColumn('updated_at', 'timestamptz', (col) =>
+ col.notNull().defaultTo(sql`now()`),
+ )
+ .addColumn('deleted_at', 'timestamptz', (col) => col)
+ .execute();
+}
+
+export async function down(db: Kysely): Promise {
+ await db.schema.dropTable('api_keys').execute();
+}
diff --git a/apps/server/src/database/pagination/pagination-options.ts b/apps/server/src/database/pagination/pagination-options.ts
index e0481910..63d02f18 100644
--- a/apps/server/src/database/pagination/pagination-options.ts
+++ b/apps/server/src/database/pagination/pagination-options.ts
@@ -1,4 +1,5 @@
import {
+ IsBoolean,
IsNumber,
IsOptional,
IsPositive,
@@ -23,4 +24,8 @@ export class PaginationOptions {
@IsOptional()
@IsString()
query: string;
+
+ @IsOptional()
+ @IsBoolean()
+ adminView: boolean;
}
diff --git a/apps/server/src/database/repos/workspace/workspace.repo.ts b/apps/server/src/database/repos/workspace/workspace.repo.ts
index 6a15fcd9..790d6402 100644
--- a/apps/server/src/database/repos/workspace/workspace.repo.ts
+++ b/apps/server/src/database/repos/workspace/workspace.repo.ts
@@ -157,4 +157,22 @@ export class WorkspaceRepo {
return activeUsers.length;
}
+
+ async updateApiSettings(
+ workspaceId: string,
+ prefKey: string,
+ prefValue: string | boolean,
+ ) {
+ return this.db
+ .updateTable('workspaces')
+ .set({
+ settings: sql`COALESCE(settings, '{}'::jsonb)
+ || jsonb_build_object('api', COALESCE(settings->'api', '{}'::jsonb)
+ || jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
+ updatedAt: new Date(),
+ })
+ .where('id', '=', workspaceId)
+ .returning(this.baseFields)
+ .executeTakeFirst();
+ }
}
diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts
index 2f8baaf7..fe5b8fab 100644
--- a/apps/server/src/database/types/db.d.ts
+++ b/apps/server/src/database/types/db.d.ts
@@ -3,13 +3,18 @@
* Please do not edit it manually.
*/
-import type { ColumnType } from "kysely";
+import type { ColumnType } from 'kysely';
-export type Generated = T extends ColumnType
- ? ColumnType
- : ColumnType;
+export type Generated =
+ T extends ColumnType
+ ? ColumnType
+ : ColumnType;
-export type Int8 = ColumnType;
+export type Int8 = ColumnType<
+ string,
+ bigint | number | string,
+ bigint | number | string
+>;
export type Json = JsonValue;
@@ -25,6 +30,18 @@ export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
export type Timestamp = ColumnType;
+export interface ApiKeys {
+ createdAt: Generated;
+ deletedAt: Timestamp | null;
+ expiresAt: Timestamp | null;
+ id: Generated;
+ lastUsedAt: Timestamp | null;
+ name: string | null;
+ updatedAt: Generated;
+ creatorId: string;
+ workspaceId: string;
+}
+
export interface Attachments {
createdAt: Generated;
creatorId: string;
@@ -344,6 +361,7 @@ export interface Workspaces {
}
export interface DB {
+ apiKeys: ApiKeys;
attachments: Attachments;
authAccounts: AuthAccounts;
authProviders: AuthProviders;
diff --git a/apps/server/src/database/types/entity.types.ts b/apps/server/src/database/types/entity.types.ts
index b23fa775..b85c8c54 100644
--- a/apps/server/src/database/types/entity.types.ts
+++ b/apps/server/src/database/types/entity.types.ts
@@ -19,6 +19,7 @@ import {
Shares,
FileTasks,
UserMfa as _UserMFA,
+ ApiKeys,
} from './db';
// Workspace
@@ -119,3 +120,8 @@ export type UpdatableFileTask = Updateable>;
export type UserMFA = Selectable<_UserMFA>;
export type InsertableUserMFA = Insertable<_UserMFA>;
export type UpdatableUserMFA = Updateable>;
+
+// Api Keys
+export type ApiKey = Selectable;
+export type InsertableApiKey = Insertable;
+export type UpdatableApiKey = Updateable>;
diff --git a/apps/server/src/ee b/apps/server/src/ee
index ce112343..03c1e5c1 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit ce1123439bd7bfc4bc5626f6604e3fed5e759908
+Subproject commit 03c1e5c1f8b01376b0c7bec2449a12f3127dadc3
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index b9ac61ba..a8bf0417 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -236,6 +236,9 @@ importers:
'@mantine/core':
specifier: ^8.1.3
version: 8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@mantine/dates':
+ specifier: ^8.3.2
+ version: 8.3.2(@mantine/core@8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@8.1.3(react@18.3.1))(dayjs@1.11.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
'@mantine/form':
specifier: ^8.1.3
version: 8.1.3(react@18.3.1)
@@ -2686,6 +2689,15 @@ packages:
react: ^18.x || ^19.x
react-dom: ^18.x || ^19.x
+ '@mantine/dates@8.3.2':
+ resolution: {integrity: sha512-qO9Aft+icFGSeLFTbbHfef/UIKpmUzwujsYuRFw8o6cqOqhqjlC9ObE/3DATxvS+vK9BxODUZYGtE2sI4XUO3Q==}
+ peerDependencies:
+ '@mantine/core': 8.3.2
+ '@mantine/hooks': 8.3.2
+ dayjs: '>=1.0.0'
+ react: ^18.x || ^19.x
+ react-dom: ^18.x || ^19.x
+
'@mantine/form@8.1.3':
resolution: {integrity: sha512-OoSVv2cyjKRZ+C4Rw63VsnO3qjKGZHJkd6DSJTVRQHXfDr10hxmC5yXgxGKsxGQ+xFd4ZCdtzPUU2BoWbHfZAA==}
peerDependencies:
@@ -12773,6 +12785,15 @@ snapshots:
transitivePeerDependencies:
- '@types/react'
+ '@mantine/dates@8.3.2(@mantine/core@8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@mantine/hooks@8.1.3(react@18.3.1))(dayjs@1.11.13)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)':
+ dependencies:
+ '@mantine/core': 8.1.3(@mantine/hooks@8.1.3(react@18.3.1))(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@mantine/hooks': 8.1.3(react@18.3.1)
+ clsx: 2.1.1
+ dayjs: 1.11.13
+ react: 18.3.1
+ react-dom: 18.3.1(react@18.3.1)
+
'@mantine/form@8.1.3(react@18.3.1)':
dependencies:
fast-deep-equal: 3.1.3