mirror of
https://github.com/docmost/docmost.git
synced 2025-11-10 06:52:07 +10:00
feat: api keys management (EE) (#1665)
* feat: api keys (EE) * improvements * fix table * fix route * remove token suffix * api settings * Fix * fix * fix * fix
This commit is contained in:
@ -17,6 +17,7 @@
|
|||||||
"@emoji-mart/react": "^1.1.1",
|
"@emoji-mart/react": "^1.1.1",
|
||||||
"@excalidraw/excalidraw": "0.18.0-864353b",
|
"@excalidraw/excalidraw": "0.18.0-864353b",
|
||||||
"@mantine/core": "^8.1.3",
|
"@mantine/core": "^8.1.3",
|
||||||
|
"@mantine/dates": "^8.3.2",
|
||||||
"@mantine/form": "^8.1.3",
|
"@mantine/form": "^8.1.3",
|
||||||
"@mantine/hooks": "^8.1.3",
|
"@mantine/hooks": "^8.1.3",
|
||||||
"@mantine/modals": "^8.1.3",
|
"@mantine/modals": "^8.1.3",
|
||||||
|
|||||||
@ -533,5 +533,27 @@
|
|||||||
"Remove image": "Remove image",
|
"Remove image": "Remove image",
|
||||||
"Failed to remove image": "Failed to remove image",
|
"Failed to remove image": "Failed to remove image",
|
||||||
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.",
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,8 @@ import SpacesPage from "@/pages/spaces/spaces.tsx";
|
|||||||
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
|
import { MfaChallengePage } from "@/ee/mfa/pages/mfa-challenge-page";
|
||||||
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
|
import { MfaSetupRequiredPage } from "@/ee/mfa/pages/mfa-setup-required-page";
|
||||||
import SpaceTrash from "@/pages/space/space-trash.tsx";
|
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() {
|
export default function App() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -96,8 +98,10 @@ export default function App() {
|
|||||||
path={"account/preferences"}
|
path={"account/preferences"}
|
||||||
element={<AccountPreferences />}
|
element={<AccountPreferences />}
|
||||||
/>
|
/>
|
||||||
|
<Route path={"account/api-keys"} element={<UserApiKeys />} />
|
||||||
<Route path={"workspace"} element={<WorkspaceSettings />} />
|
<Route path={"workspace"} element={<WorkspaceSettings />} />
|
||||||
<Route path={"members"} element={<WorkspaceMembers />} />
|
<Route path={"members"} element={<WorkspaceMembers />} />
|
||||||
|
<Route path={"api-keys"} element={<WorkspaceApiKeys />} />
|
||||||
<Route path={"groups"} element={<Groups />} />
|
<Route path={"groups"} element={<Groups />} />
|
||||||
<Route path={"groups/:groupId"} element={<GroupInfo />} />
|
<Route path={"groups/:groupId"} element={<GroupInfo />} />
|
||||||
<Route path={"spaces"} element={<Spaces />} />
|
<Route path={"spaces"} element={<Spaces />} />
|
||||||
|
|||||||
@ -4,14 +4,15 @@ import { useTranslation } from "react-i18next";
|
|||||||
|
|
||||||
interface NoTableResultsProps {
|
interface NoTableResultsProps {
|
||||||
colSpan: number;
|
colSpan: number;
|
||||||
|
text?: string;
|
||||||
}
|
}
|
||||||
export default function NoTableResults({ colSpan }: NoTableResultsProps) {
|
export default function NoTableResults({ colSpan, text }: NoTableResultsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Td colSpan={colSpan}>
|
<Table.Td colSpan={colSpan}>
|
||||||
<Text fw={500} c="dimmed" ta="center">
|
<Text fw={500} c="dimmed" ta="center">
|
||||||
{t("No results found...")}
|
{text || t("No results found...")}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import { getWorkspaceMembers } from "@/features/workspace/services/workspace-ser
|
|||||||
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
|
import { getLicenseInfo } from "@/ee/licence/services/license-service.ts";
|
||||||
import { getSsoProviders } from "@/ee/security/services/security-service.ts";
|
import { getSsoProviders } from "@/ee/security/services/security-service.ts";
|
||||||
import { getShares } from "@/features/share/services/share-service.ts";
|
import { getShares } from "@/features/share/services/share-service.ts";
|
||||||
|
import { getApiKeys } from "@/ee/api-key";
|
||||||
|
|
||||||
export const prefetchWorkspaceMembers = () => {
|
export const prefetchWorkspaceMembers = () => {
|
||||||
const params = { limit: 100, page: 1, query: "" } as QueryParams;
|
const params = { limit: 100, page: 1, query: "" } as QueryParams;
|
||||||
@ -65,3 +66,17 @@ export const prefetchShares = () => {
|
|||||||
queryFn: () => getShares({ page: 1, limit: 100 }),
|
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 }),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@ -21,6 +21,8 @@ import useUserRole from "@/hooks/use-user-role.tsx";
|
|||||||
import { useAtom } from "jotai/index";
|
import { useAtom } from "jotai/index";
|
||||||
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import {
|
import {
|
||||||
|
prefetchApiKeyManagement,
|
||||||
|
prefetchApiKeys,
|
||||||
prefetchBilling,
|
prefetchBilling,
|
||||||
prefetchGroups,
|
prefetchGroups,
|
||||||
prefetchLicense,
|
prefetchLicense,
|
||||||
@ -60,6 +62,14 @@ const groupedData: DataGroup[] = [
|
|||||||
icon: IconBrush,
|
icon: IconBrush,
|
||||||
path: "/settings/account/preferences",
|
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: "Groups", icon: IconUsersGroup, path: "/settings/groups" },
|
||||||
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
{ label: "Spaces", icon: IconSpaces, path: "/settings/spaces" },
|
||||||
{ label: "Public sharing", icon: IconWorld, path: "/settings/sharing" },
|
{ 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":
|
case "Public sharing":
|
||||||
prefetchHandler = prefetchShares;
|
prefetchHandler = prefetchShares;
|
||||||
break;
|
break;
|
||||||
|
case "API keys":
|
||||||
|
prefetchHandler = prefetchApiKeys;
|
||||||
|
break;
|
||||||
|
case "API management":
|
||||||
|
prefetchHandler = prefetchApiKeyManagement;
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t("API key created")}
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Alert
|
||||||
|
icon={<IconAlertTriangle size={16} />}
|
||||||
|
title={t("Important")}
|
||||||
|
color="red"
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
"Make sure to copy your API key now. You won't be able to see it again!",
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Text size="sm" fw={500} mb="xs">
|
||||||
|
{t("API key")}
|
||||||
|
</Text>
|
||||||
|
<Group gap="xs" wrap="nowrap">
|
||||||
|
<TextInput
|
||||||
|
variant="filled"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
}}
|
||||||
|
value={apiKey.token}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CopyTextButton text={apiKey.token} />
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button fullWidth onClick={onClose} mt="md">
|
||||||
|
{t("I've saved my API key")}
|
||||||
|
</Button>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
143
apps/client/src/ee/api-key/components/api-key-table.tsx
Normal file
143
apps/client/src/ee/api-key/components/api-key-table.tsx
Normal file
@ -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 (
|
||||||
|
<Table.ScrollContainer minWidth={500}>
|
||||||
|
<Table highlightOnHover verticalSpacing="sm">
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>{t("Name")}</Table.Th>
|
||||||
|
{showUserColumn && <Table.Th>{t("User")}</Table.Th>}
|
||||||
|
<Table.Th>{t("Last used")}</Table.Th>
|
||||||
|
<Table.Th>{t("Expires")}</Table.Th>
|
||||||
|
<Table.Th>{t("Created")}</Table.Th>
|
||||||
|
<Table.Th></Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
|
||||||
|
<Table.Tbody>
|
||||||
|
{apiKeys && apiKeys.length > 0 ? (
|
||||||
|
apiKeys.map((apiKey: IApiKey, index: number) => (
|
||||||
|
<Table.Tr key={index}>
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz="sm" fw={500}>
|
||||||
|
{apiKey.name}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
|
||||||
|
{showUserColumn && apiKey.creator && (
|
||||||
|
<Table.Td>
|
||||||
|
<Group gap="4" wrap="nowrap">
|
||||||
|
<CustomAvatar
|
||||||
|
avatarUrl={apiKey.creator?.avatarUrl}
|
||||||
|
name={apiKey.creator.name}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<Text fz="sm" lineClamp={1}>
|
||||||
|
{apiKey.creator.name}
|
||||||
|
</Text>
|
||||||
|
</Group>
|
||||||
|
</Table.Td>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{formatDate(apiKey.lastUsedAt)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
|
||||||
|
<Table.Td>
|
||||||
|
{apiKey.expiresAt ? (
|
||||||
|
isExpired(apiKey.expiresAt) ? (
|
||||||
|
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{t("Expired")}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{formatDate(apiKey.expiresAt)}
|
||||||
|
</Text>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{t("Never")}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Table.Td>
|
||||||
|
|
||||||
|
<Table.Td>
|
||||||
|
<Text fz="sm" style={{ whiteSpace: "nowrap" }}>
|
||||||
|
{formatDate(apiKey.createdAt)}
|
||||||
|
</Text>
|
||||||
|
</Table.Td>
|
||||||
|
|
||||||
|
<Table.Td>
|
||||||
|
<Menu position="bottom-end" withinPortal>
|
||||||
|
<Menu.Target>
|
||||||
|
<ActionIcon variant="subtle" color="gray">
|
||||||
|
<IconDots size={16} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
{onUpdate && (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconEdit size={16} />}
|
||||||
|
onClick={() => onUpdate(apiKey)}
|
||||||
|
>
|
||||||
|
{t("Rename")}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
{onRevoke && (
|
||||||
|
<Menu.Item
|
||||||
|
leftSection={<IconTrash size={16} />}
|
||||||
|
color="red"
|
||||||
|
onClick={() => onRevoke(apiKey)}
|
||||||
|
>
|
||||||
|
{t("Revoke")}
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<NoTableResults colSpan={showUserColumn ? 6 : 5} />
|
||||||
|
)}
|
||||||
|
</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
</Table.ScrollContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
153
apps/client/src/ee/api-key/components/create-api-key-modal.tsx
Normal file
153
apps/client/src/ee/api-key/components/create-api-key-modal.tsx
Normal file
@ -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<typeof formSchema>;
|
||||||
|
|
||||||
|
export function CreateApiKeyModal({
|
||||||
|
opened,
|
||||||
|
onClose,
|
||||||
|
onSuccess,
|
||||||
|
}: CreateApiKeyModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [expirationOption, setExpirationOption] = useState<string>("30");
|
||||||
|
const createApiKeyMutation = useCreateApiKeyMutation();
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={handleClose}
|
||||||
|
title={t("Create API Key")}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<TextInput
|
||||||
|
label={t("Name")}
|
||||||
|
placeholder={t("Enter a descriptive name")}
|
||||||
|
data-autofocus
|
||||||
|
required
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label={t("Expiration")}
|
||||||
|
data={expirationOptions}
|
||||||
|
value={expirationOption}
|
||||||
|
onChange={(value) => setExpirationOption(value || "30")}
|
||||||
|
leftSection={<IconCalendar size={16} />}
|
||||||
|
allowDeselect={false}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{expirationOption === "custom" && (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<DateInput
|
||||||
|
label={t("Custom expiration date")}
|
||||||
|
placeholder={t("Select expiration date")}
|
||||||
|
minDate={new Date()}
|
||||||
|
{...form.getInputProps("expiresAt")}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
<Button variant="default" onClick={handleClose}>
|
||||||
|
{t("Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={createApiKeyMutation.isPending}>
|
||||||
|
{t("Create")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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 (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t("Revoke API key")}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<Stack gap="md">
|
||||||
|
<Text>
|
||||||
|
{t("Are you sure you want to revoke this API key")}{" "}
|
||||||
|
<strong>{apiKey?.name}</strong>?
|
||||||
|
</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t(
|
||||||
|
"This action cannot be undone. Any applications using this API key will stop working.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
<Button variant="default" onClick={onClose}>
|
||||||
|
{t("Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
color="red"
|
||||||
|
onClick={handleRevoke}
|
||||||
|
loading={revokeApiKeyMutation.isPending}
|
||||||
|
>
|
||||||
|
{t("Revoke")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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<typeof formSchema>;
|
||||||
|
|
||||||
|
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<FormValues>({
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t("Update API key")}
|
||||||
|
size="md"
|
||||||
|
>
|
||||||
|
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
|
||||||
|
<Stack gap="md">
|
||||||
|
<TextInput
|
||||||
|
label={t("Token name")}
|
||||||
|
placeholder={t("Enter a descriptive token name")}
|
||||||
|
required
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
<Button variant="default" onClick={onClose}>
|
||||||
|
{t("Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" loading={updateApiKeyMutation.isPending}>
|
||||||
|
{t("Update")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
apps/client/src/ee/api-key/index.ts
Normal file
11
apps/client/src/ee/api-key/index.ts
Normal file
@ -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";
|
||||||
106
apps/client/src/ee/api-key/pages/user-api-keys.tsx
Normal file
106
apps/client/src/ee/api-key/pages/user-api-keys.tsx
Normal file
@ -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<IApiKey | null>(null);
|
||||||
|
const [updateModalOpened, setUpdateModalOpened] = useState(false);
|
||||||
|
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
|
||||||
|
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(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 (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>
|
||||||
|
{t("API keys")} - {getAppName()}
|
||||||
|
</title>
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
<SettingsTitle title={t("API keys")} />
|
||||||
|
|
||||||
|
<Group justify="flex-end" mb="md">
|
||||||
|
<Button onClick={() => setCreateModalOpened(true)}>
|
||||||
|
{t("Create API Key")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<ApiKeyTable
|
||||||
|
apiKeys={data?.items || []}
|
||||||
|
isLoading={isLoading}
|
||||||
|
onUpdate={handleUpdate}
|
||||||
|
onRevoke={handleRevoke}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Space h="md" />
|
||||||
|
|
||||||
|
{data?.items.length > 0 && (
|
||||||
|
<Paginate
|
||||||
|
currentPage={page}
|
||||||
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CreateApiKeyModal
|
||||||
|
opened={createModalOpened}
|
||||||
|
onClose={() => setCreateModalOpened(false)}
|
||||||
|
onSuccess={handleCreateSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ApiKeyCreatedModal
|
||||||
|
opened={!!createdApiKey}
|
||||||
|
onClose={() => setCreatedApiKey(null)}
|
||||||
|
apiKey={createdApiKey}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UpdateApiKeyModal
|
||||||
|
opened={updateModalOpened}
|
||||||
|
onClose={() => {
|
||||||
|
setUpdateModalOpened(false);
|
||||||
|
setSelectedApiKey(null);
|
||||||
|
}}
|
||||||
|
apiKey={selectedApiKey}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RevokeApiKeyModal
|
||||||
|
opened={revokeModalOpened}
|
||||||
|
onClose={() => {
|
||||||
|
setRevokeModalOpened(false);
|
||||||
|
setSelectedApiKey(null);
|
||||||
|
}}
|
||||||
|
apiKey={selectedApiKey}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
117
apps/client/src/ee/api-key/pages/workspace-api-keys.tsx
Normal file
117
apps/client/src/ee/api-key/pages/workspace-api-keys.tsx
Normal file
@ -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<IApiKey | null>(null);
|
||||||
|
const [updateModalOpened, setUpdateModalOpened] = useState(false);
|
||||||
|
const [revokeModalOpened, setRevokeModalOpened] = useState(false);
|
||||||
|
const [selectedApiKey, setSelectedApiKey] = useState<IApiKey | null>(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 (
|
||||||
|
<>
|
||||||
|
<Helmet>
|
||||||
|
<title>
|
||||||
|
{t("API management")} - {getAppName()}
|
||||||
|
</title>
|
||||||
|
</Helmet>
|
||||||
|
|
||||||
|
<SettingsTitle title={t("API management")} />
|
||||||
|
|
||||||
|
<Text size="md" c="dimmed" mb="md">
|
||||||
|
{t("Manage API keys for all users in the workspace")}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Group justify="flex-end" mb="md">
|
||||||
|
<Button onClick={() => setCreateModalOpened(true)}>
|
||||||
|
{t("Create API Key")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
|
||||||
|
<ApiKeyTable
|
||||||
|
apiKeys={data?.items}
|
||||||
|
isLoading={isLoading}
|
||||||
|
showUserColumn
|
||||||
|
onUpdate={handleUpdate}
|
||||||
|
onRevoke={handleRevoke}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Space h="md" />
|
||||||
|
|
||||||
|
{data?.items.length > 0 && (
|
||||||
|
<Paginate
|
||||||
|
currentPage={page}
|
||||||
|
hasPrevPage={data?.meta.hasPrevPage}
|
||||||
|
hasNextPage={data?.meta.hasNextPage}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<CreateApiKeyModal
|
||||||
|
opened={createModalOpened}
|
||||||
|
onClose={() => setCreateModalOpened(false)}
|
||||||
|
onSuccess={handleCreateSuccess}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ApiKeyCreatedModal
|
||||||
|
opened={!!createdApiKey}
|
||||||
|
onClose={() => setCreatedApiKey(null)}
|
||||||
|
apiKey={createdApiKey}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<UpdateApiKeyModal
|
||||||
|
opened={updateModalOpened}
|
||||||
|
onClose={() => {
|
||||||
|
setUpdateModalOpened(false);
|
||||||
|
setSelectedApiKey(null);
|
||||||
|
}}
|
||||||
|
apiKey={selectedApiKey}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<RevokeApiKeyModal
|
||||||
|
opened={revokeModalOpened}
|
||||||
|
onClose={() => {
|
||||||
|
setRevokeModalOpened(false);
|
||||||
|
setSelectedApiKey(null);
|
||||||
|
}}
|
||||||
|
apiKey={selectedApiKey}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
97
apps/client/src/ee/api-key/queries/api-key-query.ts
Normal file
97
apps/client/src/ee/api-key/queries/api-key-query.ts
Normal file
@ -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<IPagination<IApiKey>, 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<IApiKey, Error, ICreateApiKeyRequest>({
|
||||||
|
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<IApiKey, Error, IUpdateApiKeyRequest>({
|
||||||
|
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" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
32
apps/client/src/ee/api-key/services/api-key-service.ts
Normal file
32
apps/client/src/ee/api-key/services/api-key-service.ts
Normal file
@ -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<IPagination<IApiKey>> {
|
||||||
|
const req = await api.post("/api-keys", { ...params });
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createApiKey(
|
||||||
|
data: ICreateApiKeyRequest,
|
||||||
|
): Promise<IApiKey> {
|
||||||
|
const req = await api.post<IApiKey>("/api-keys/create", data);
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateApiKey(
|
||||||
|
data: IUpdateApiKeyRequest,
|
||||||
|
): Promise<IApiKey> {
|
||||||
|
const req = await api.post<IApiKey>("/api-keys/update", data);
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokeApiKey(data: { apiKeyId: string }): Promise<void> {
|
||||||
|
await api.post("/api-keys/revoke", data);
|
||||||
|
}
|
||||||
23
apps/client/src/ee/api-key/types/api-key.types.ts
Normal file
23
apps/client/src/ee/api-key/types/api-key.types.ts
Normal file
@ -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<IUser>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICreateApiKeyRequest {
|
||||||
|
name: string;
|
||||||
|
expiresAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IUpdateApiKeyRequest {
|
||||||
|
apiKeyId: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
@ -1,11 +1,12 @@
|
|||||||
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
|
import { Group, Box, Button, TextInput, Stack, Textarea } from "@mantine/core";
|
||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { useCreateGroupMutation } from "@/features/group/queries/group-query.ts";
|
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 * as z from "zod";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
|
import { MultiUserSelect } from "@/features/group/components/multi-user-select.tsx";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { zodResolver } from 'mantine-form-zod-resolver';
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().trim().min(2).max(50),
|
name: z.string().trim().min(2).max(50),
|
||||||
|
|||||||
@ -4,10 +4,11 @@ import {
|
|||||||
useGroupQuery,
|
useGroupQuery,
|
||||||
useUpdateGroupMutation,
|
useUpdateGroupMutation,
|
||||||
} from "@/features/group/queries/group-query.ts";
|
} from "@/features/group/queries/group-query.ts";
|
||||||
import { useForm, zodResolver } from "@mantine/form";
|
import { useForm } from "@mantine/form";
|
||||||
import * as z from "zod";
|
import * as z from "zod";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { zodResolver } from "mantine-form-zod-resolver";
|
||||||
|
|
||||||
const formSchema = z.object({
|
const formSchema = z.object({
|
||||||
name: z.string().min(2).max(50),
|
name: z.string().min(2).max(50),
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { IUser } from "@/features/user/types/user.types.ts";
|
|||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { validate as isValidUuid } from "uuid";
|
import { validate as isValidUuid } from "uuid";
|
||||||
import { queryClient } from "@/main.tsx";
|
import { queryClient } from "@/main.tsx";
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
export function useGetGroupsQuery(
|
export function useGetGroupsQuery(
|
||||||
params?: QueryParams,
|
params?: QueryParams,
|
||||||
@ -73,11 +74,12 @@ export function useCreateGroupMutation() {
|
|||||||
|
|
||||||
export function useUpdateGroupMutation() {
|
export function useUpdateGroupMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation<IGroup, Error, Partial<IGroup>>({
|
return useMutation<IGroup, Error, Partial<IGroup>>({
|
||||||
mutationFn: (data) => updateGroup(data),
|
mutationFn: (data) => updateGroup(data),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
notifications.show({ message: "Group updated successfully" });
|
notifications.show({ message: t("Group updated successfully") });
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["group", variables.groupId],
|
queryKey: ["group", variables.groupId],
|
||||||
});
|
});
|
||||||
@ -91,11 +93,12 @@ export function useUpdateGroupMutation() {
|
|||||||
|
|
||||||
export function useDeleteGroupMutation() {
|
export function useDeleteGroupMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (groupId: string) => deleteGroup({ groupId }),
|
mutationFn: (groupId: string) => deleteGroup({ groupId }),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
notifications.show({ message: "Group deleted successfully" });
|
notifications.show({ message: t("Group deleted successfully") });
|
||||||
queryClient.refetchQueries({ queryKey: ["groups"] });
|
queryClient.refetchQueries({ queryKey: ["groups"] });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
@ -119,11 +122,12 @@ export function useGroupMembersQuery(
|
|||||||
|
|
||||||
export function useAddGroupMemberMutation() {
|
export function useAddGroupMemberMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation<void, Error, { groupId: string; userIds: string[] }>({
|
return useMutation<void, Error, { groupId: string; userIds: string[] }>({
|
||||||
mutationFn: (data) => addGroupMember(data),
|
mutationFn: (data) => addGroupMember(data),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
notifications.show({ message: "Added successfully" });
|
notifications.show({ message: t("Added successfully") });
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["groupMembers", variables.groupId],
|
queryKey: ["groupMembers", variables.groupId],
|
||||||
});
|
});
|
||||||
@ -139,6 +143,7 @@ export function useAddGroupMemberMutation() {
|
|||||||
|
|
||||||
export function useRemoveGroupMemberMutation() {
|
export function useRemoveGroupMemberMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
return useMutation<
|
return useMutation<
|
||||||
void,
|
void,
|
||||||
@ -150,7 +155,7 @@ export function useRemoveGroupMemberMutation() {
|
|||||||
>({
|
>({
|
||||||
mutationFn: (data) => removeGroupMember(data),
|
mutationFn: (data) => removeGroupMember(data),
|
||||||
onSuccess: (data, variables) => {
|
onSuccess: (data, variables) => {
|
||||||
notifications.show({ message: "Removed successfully" });
|
notifications.show({ message: t("Removed successfully") });
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: ["groupMembers", variables.groupId],
|
queryKey: ["groupMembers", variables.groupId],
|
||||||
});
|
});
|
||||||
|
|||||||
@ -2,6 +2,7 @@ export interface QueryParams {
|
|||||||
query?: string;
|
query?: string;
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
|
adminView?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum UserRole {
|
export enum UserRole {
|
||||||
|
|||||||
@ -1,6 +1,8 @@
|
|||||||
import "@mantine/core/styles.css";
|
import "@mantine/core/styles.css";
|
||||||
import "@mantine/spotlight/styles.css";
|
import "@mantine/spotlight/styles.css";
|
||||||
import "@mantine/notifications/styles.css";
|
import "@mantine/notifications/styles.css";
|
||||||
|
import '@mantine/dates/styles.css';
|
||||||
|
|
||||||
import ReactDOM from "react-dom/client";
|
import ReactDOM from "react-dom/client";
|
||||||
import App from "./App.tsx";
|
import App from "./App.tsx";
|
||||||
import { mantineCssResolver, theme } from "@/theme";
|
import { mantineCssResolver, theme } from "@/theme";
|
||||||
|
|||||||
@ -4,6 +4,7 @@ export enum JwtType {
|
|||||||
EXCHANGE = 'exchange',
|
EXCHANGE = 'exchange',
|
||||||
ATTACHMENT = 'attachment',
|
ATTACHMENT = 'attachment',
|
||||||
MFA_TOKEN = 'mfa_token',
|
MFA_TOKEN = 'mfa_token',
|
||||||
|
API_KEY = 'api_key',
|
||||||
}
|
}
|
||||||
export type JwtPayload = {
|
export type JwtPayload = {
|
||||||
sub: string;
|
sub: string;
|
||||||
@ -36,3 +37,10 @@ export interface JwtMfaTokenPayload {
|
|||||||
workspaceId: string;
|
workspaceId: string;
|
||||||
type: 'mfa_token';
|
type: 'mfa_token';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type JwtApiKeyPayload = {
|
||||||
|
sub: string;
|
||||||
|
workspaceId: string;
|
||||||
|
apiKeyId: string;
|
||||||
|
type: 'api_key';
|
||||||
|
};
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||||
import {
|
import {
|
||||||
|
JwtApiKeyPayload,
|
||||||
JwtAttachmentPayload,
|
JwtAttachmentPayload,
|
||||||
JwtCollabPayload,
|
JwtCollabPayload,
|
||||||
JwtExchangePayload,
|
JwtExchangePayload,
|
||||||
@ -77,10 +78,7 @@ export class TokenService {
|
|||||||
return this.jwtService.sign(payload, { expiresIn: '1h' });
|
return this.jwtService.sign(payload, { expiresIn: '1h' });
|
||||||
}
|
}
|
||||||
|
|
||||||
async generateMfaToken(
|
async generateMfaToken(user: User, workspaceId: string): Promise<string> {
|
||||||
user: User,
|
|
||||||
workspaceId: string,
|
|
||||||
): Promise<string> {
|
|
||||||
if (user.deactivatedAt || user.deletedAt) {
|
if (user.deactivatedAt || user.deletedAt) {
|
||||||
throw new ForbiddenException();
|
throw new ForbiddenException();
|
||||||
}
|
}
|
||||||
@ -93,6 +91,27 @@ export class TokenService {
|
|||||||
return this.jwtService.sign(payload, { expiresIn: '5m' });
|
return this.jwtService.sign(payload, { expiresIn: '5m' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async generateApiToken(opts: {
|
||||||
|
apiKeyId: string;
|
||||||
|
user: User;
|
||||||
|
workspaceId: string;
|
||||||
|
expiresIn?: string | number;
|
||||||
|
}): Promise<string> {
|
||||||
|
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) {
|
async verifyJwt(token: string, tokenType: string) {
|
||||||
const payload = await this.jwtService.verifyAsync(token, {
|
const payload = await this.jwtService.verifyAsync(token, {
|
||||||
secret: this.environmentService.getAppSecret(),
|
secret: this.environmentService.getAppSecret(),
|
||||||
|
|||||||
@ -2,11 +2,12 @@ import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
|||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { Strategy } from 'passport-jwt';
|
import { Strategy } from 'passport-jwt';
|
||||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
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 { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||||
import { FastifyRequest } from 'fastify';
|
import { FastifyRequest } from 'fastify';
|
||||||
import { extractBearerTokenFromHeader } from '../../../common/helpers';
|
import { extractBearerTokenFromHeader } from '../../../common/helpers';
|
||||||
|
import { ModuleRef } from '@nestjs/core';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||||
@ -16,6 +17,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
private userRepo: UserRepo,
|
private userRepo: UserRepo,
|
||||||
private workspaceRepo: WorkspaceRepo,
|
private workspaceRepo: WorkspaceRepo,
|
||||||
private readonly environmentService: EnvironmentService,
|
private readonly environmentService: EnvironmentService,
|
||||||
|
private moduleRef: ModuleRef,
|
||||||
) {
|
) {
|
||||||
super({
|
super({
|
||||||
jwtFromRequest: (req: FastifyRequest) => {
|
jwtFromRequest: (req: FastifyRequest) => {
|
||||||
@ -27,8 +29,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async validate(req: any, payload: JwtPayload) {
|
async validate(req: any, payload: JwtPayload | JwtApiKeyPayload) {
|
||||||
if (!payload.workspaceId || payload.type !== JwtType.ACCESS) {
|
if (!payload.workspaceId) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -36,6 +38,14 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
throw new UnauthorizedException('Workspace does not match');
|
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);
|
const workspace = await this.workspaceRepo.findById(payload.workspaceId);
|
||||||
|
|
||||||
if (!workspace) {
|
if (!workspace) {
|
||||||
@ -49,4 +59,30 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
|
|
||||||
return { user, workspace };
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,6 +40,7 @@ function buildWorkspaceOwnerAbility() {
|
|||||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group);
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group);
|
||||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
|
||||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
||||||
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
|
||||||
|
|
||||||
return build();
|
return build();
|
||||||
}
|
}
|
||||||
@ -55,6 +56,7 @@ function buildWorkspaceAdminAbility() {
|
|||||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group);
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Group);
|
||||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member);
|
||||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
||||||
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.API);
|
||||||
|
|
||||||
return build();
|
return build();
|
||||||
}
|
}
|
||||||
@ -68,6 +70,7 @@ function buildWorkspaceMemberAbility() {
|
|||||||
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Space);
|
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Space);
|
||||||
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Group);
|
can(WorkspaceCaslAction.Read, WorkspaceCaslSubject.Group);
|
||||||
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
can(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Attachment);
|
||||||
|
can(WorkspaceCaslAction.Create, WorkspaceCaslSubject.API);
|
||||||
|
|
||||||
return build();
|
return build();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export enum WorkspaceCaslSubject {
|
|||||||
Space = 'space',
|
Space = 'space',
|
||||||
Group = 'group',
|
Group = 'group',
|
||||||
Attachment = 'attachment',
|
Attachment = 'attachment',
|
||||||
|
API = 'api_key',
|
||||||
}
|
}
|
||||||
|
|
||||||
export type IWorkspaceAbility =
|
export type IWorkspaceAbility =
|
||||||
@ -18,4 +19,5 @@ export type IWorkspaceAbility =
|
|||||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Member]
|
| [WorkspaceCaslAction, WorkspaceCaslSubject.Member]
|
||||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Space]
|
| [WorkspaceCaslAction, WorkspaceCaslSubject.Space]
|
||||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Group]
|
| [WorkspaceCaslAction, WorkspaceCaslSubject.Group]
|
||||||
| [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment];
|
| [WorkspaceCaslAction, WorkspaceCaslSubject.Attachment]
|
||||||
|
| [WorkspaceCaslAction, WorkspaceCaslSubject.API];
|
||||||
|
|||||||
@ -18,4 +18,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
enforceMfa: boolean;
|
enforceMfa: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
restrictApiToAdmins: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId);
|
||||||
|
|
||||||
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||||
|
|||||||
@ -0,0 +1,30 @@
|
|||||||
|
import { Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
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<any>): Promise<void> {
|
||||||
|
await db.schema.dropTable('api_keys').execute();
|
||||||
|
}
|
||||||
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
IsBoolean,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsPositive,
|
IsPositive,
|
||||||
@ -23,4 +24,8 @@ export class PaginationOptions {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
query: string;
|
query: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
adminView: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -157,4 +157,22 @@ export class WorkspaceRepo {
|
|||||||
|
|
||||||
return activeUsers.length;
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
apps/server/src/database/types/db.d.ts
vendored
28
apps/server/src/database/types/db.d.ts
vendored
@ -3,13 +3,18 @@
|
|||||||
* Please do not edit it manually.
|
* Please do not edit it manually.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { ColumnType } from "kysely";
|
import type { ColumnType } from 'kysely';
|
||||||
|
|
||||||
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
|
export type Generated<T> =
|
||||||
? ColumnType<S, I | undefined, U>
|
T extends ColumnType<infer S, infer I, infer U>
|
||||||
: ColumnType<T, T | undefined, T>;
|
? ColumnType<S, I | undefined, U>
|
||||||
|
: ColumnType<T, T | undefined, T>;
|
||||||
|
|
||||||
export type Int8 = ColumnType<string, bigint | number | string, bigint | number | string>;
|
export type Int8 = ColumnType<
|
||||||
|
string,
|
||||||
|
bigint | number | string,
|
||||||
|
bigint | number | string
|
||||||
|
>;
|
||||||
|
|
||||||
export type Json = JsonValue;
|
export type Json = JsonValue;
|
||||||
|
|
||||||
@ -25,6 +30,18 @@ export type JsonValue = JsonArray | JsonObject | JsonPrimitive;
|
|||||||
|
|
||||||
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
|
export type Timestamp = ColumnType<Date, Date | string, Date | string>;
|
||||||
|
|
||||||
|
export interface ApiKeys {
|
||||||
|
createdAt: Generated<Timestamp>;
|
||||||
|
deletedAt: Timestamp | null;
|
||||||
|
expiresAt: Timestamp | null;
|
||||||
|
id: Generated<string>;
|
||||||
|
lastUsedAt: Timestamp | null;
|
||||||
|
name: string | null;
|
||||||
|
updatedAt: Generated<Timestamp>;
|
||||||
|
creatorId: string;
|
||||||
|
workspaceId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Attachments {
|
export interface Attachments {
|
||||||
createdAt: Generated<Timestamp>;
|
createdAt: Generated<Timestamp>;
|
||||||
creatorId: string;
|
creatorId: string;
|
||||||
@ -344,6 +361,7 @@ export interface Workspaces {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DB {
|
export interface DB {
|
||||||
|
apiKeys: ApiKeys;
|
||||||
attachments: Attachments;
|
attachments: Attachments;
|
||||||
authAccounts: AuthAccounts;
|
authAccounts: AuthAccounts;
|
||||||
authProviders: AuthProviders;
|
authProviders: AuthProviders;
|
||||||
|
|||||||
@ -19,6 +19,7 @@ import {
|
|||||||
Shares,
|
Shares,
|
||||||
FileTasks,
|
FileTasks,
|
||||||
UserMfa as _UserMFA,
|
UserMfa as _UserMFA,
|
||||||
|
ApiKeys,
|
||||||
} from './db';
|
} from './db';
|
||||||
|
|
||||||
// Workspace
|
// Workspace
|
||||||
@ -119,3 +120,8 @@ export type UpdatableFileTask = Updateable<Omit<FileTasks, 'id'>>;
|
|||||||
export type UserMFA = Selectable<_UserMFA>;
|
export type UserMFA = Selectable<_UserMFA>;
|
||||||
export type InsertableUserMFA = Insertable<_UserMFA>;
|
export type InsertableUserMFA = Insertable<_UserMFA>;
|
||||||
export type UpdatableUserMFA = Updateable<Omit<_UserMFA, 'id'>>;
|
export type UpdatableUserMFA = Updateable<Omit<_UserMFA, 'id'>>;
|
||||||
|
|
||||||
|
// Api Keys
|
||||||
|
export type ApiKey = Selectable<ApiKeys>;
|
||||||
|
export type InsertableApiKey = Insertable<ApiKeys>;
|
||||||
|
export type UpdatableApiKey = Updateable<Omit<ApiKeys, 'id'>>;
|
||||||
|
|||||||
Submodule apps/server/src/ee updated: ce1123439b...03c1e5c1f8
21
pnpm-lock.yaml
generated
21
pnpm-lock.yaml
generated
@ -236,6 +236,9 @@ importers:
|
|||||||
'@mantine/core':
|
'@mantine/core':
|
||||||
specifier: ^8.1.3
|
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)
|
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':
|
'@mantine/form':
|
||||||
specifier: ^8.1.3
|
specifier: ^8.1.3
|
||||||
version: 8.1.3(react@18.3.1)
|
version: 8.1.3(react@18.3.1)
|
||||||
@ -2686,6 +2689,15 @@ packages:
|
|||||||
react: ^18.x || ^19.x
|
react: ^18.x || ^19.x
|
||||||
react-dom: ^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':
|
'@mantine/form@8.1.3':
|
||||||
resolution: {integrity: sha512-OoSVv2cyjKRZ+C4Rw63VsnO3qjKGZHJkd6DSJTVRQHXfDr10hxmC5yXgxGKsxGQ+xFd4ZCdtzPUU2BoWbHfZAA==}
|
resolution: {integrity: sha512-OoSVv2cyjKRZ+C4Rw63VsnO3qjKGZHJkd6DSJTVRQHXfDr10hxmC5yXgxGKsxGQ+xFd4ZCdtzPUU2BoWbHfZAA==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -12773,6 +12785,15 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@types/react'
|
- '@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)':
|
'@mantine/form@8.1.3(react@18.3.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-deep-equal: 3.1.3
|
fast-deep-equal: 3.1.3
|
||||||
|
|||||||
Reference in New Issue
Block a user