mirror of
https://github.com/docmost/docmost.git
synced 2026-06-22 10:51:49 +10:00
feat(ee): personal spaces (#2298)
* feat(ee): personal spaces * pref * feat: on-demand only * error notification
This commit is contained in:
@@ -1087,5 +1087,11 @@
|
|||||||
"Added {{name}} to favorites": "Added {{name}} to favorites",
|
"Added {{name}} to favorites": "Added {{name}} to favorites",
|
||||||
"Removed {{name}} from favorites": "Removed {{name}} from favorites",
|
"Removed {{name}} from favorites": "Removed {{name}} from favorites",
|
||||||
"Page menu for {{name}}": "Page menu for {{name}}",
|
"Page menu for {{name}}": "Page menu for {{name}}",
|
||||||
"Create subpage of {{name}}": "Create subpage of {{name}}"
|
"Create subpage of {{name}}": "Create subpage of {{name}}",
|
||||||
|
"Allow personal spaces": "Allow personal spaces",
|
||||||
|
"Members can create their own personal space.": "Members can create their own personal space.",
|
||||||
|
"Toggle allow personal spaces": "Toggle allow personal spaces",
|
||||||
|
"Create personal space": "Create personal space",
|
||||||
|
"Personal space": "Personal space",
|
||||||
|
"{{name}}'s space": "{{name}}'s space"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,16 @@ import {
|
|||||||
IconMoon,
|
IconMoon,
|
||||||
IconSettings,
|
IconSettings,
|
||||||
IconSun,
|
IconSun,
|
||||||
|
IconUser,
|
||||||
IconUserCircle,
|
IconUserCircle,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
} from "@tabler/icons-react";
|
} from "@tabler/icons-react";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { getSpaceUrl } from "@/lib/config.ts";
|
||||||
|
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||||
|
import { Feature } from "@/ee/features";
|
||||||
|
import { usePersonalSpaceQuery } from "@/ee/personal-space/queries/personal-space-query";
|
||||||
|
import CreatePersonalSpaceModal from "@/ee/personal-space/components/create-personal-space-modal";
|
||||||
import { useAtom } from "jotai";
|
import { useAtom } from "jotai";
|
||||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
@@ -36,11 +43,20 @@ export default function TopMenu() {
|
|||||||
const user = currentUser?.user;
|
const user = currentUser?.user;
|
||||||
const workspace = currentUser?.workspace;
|
const workspace = currentUser?.workspace;
|
||||||
|
|
||||||
|
const hasPersonalSpaces = useHasFeature(Feature.PERSONAL_SPACES);
|
||||||
|
const settingEnabled = workspace?.settings?.spaces?.allowPersonal === true;
|
||||||
|
const { data: personalSpace } = usePersonalSpaceQuery(hasPersonalSpaces);
|
||||||
|
const [
|
||||||
|
createOpened,
|
||||||
|
{ open: openCreate, close: closeCreate },
|
||||||
|
] = useDisclosure(false);
|
||||||
|
|
||||||
if (!user || !workspace) {
|
if (!user || !workspace) {
|
||||||
return <></>;
|
return <></>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
|
<Menu width={250} position="bottom-end" withArrow shadow={"lg"}>
|
||||||
<Menu.Target>
|
<Menu.Target>
|
||||||
<UnstyledButton>
|
<UnstyledButton>
|
||||||
@@ -115,6 +131,26 @@ export default function TopMenu() {
|
|||||||
{t("My preferences")}
|
{t("My preferences")}
|
||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
|
|
||||||
|
{personalSpace ? (
|
||||||
|
<Menu.Item
|
||||||
|
component={Link}
|
||||||
|
to={getSpaceUrl(personalSpace.slug)}
|
||||||
|
leftSection={<IconUser size={16} />}
|
||||||
|
>
|
||||||
|
{t("Personal space")}
|
||||||
|
</Menu.Item>
|
||||||
|
) : (
|
||||||
|
hasPersonalSpaces &&
|
||||||
|
settingEnabled && (
|
||||||
|
<Menu.Item
|
||||||
|
onClick={openCreate}
|
||||||
|
leftSection={<IconUser size={16} />}
|
||||||
|
>
|
||||||
|
{t("Create personal space")}
|
||||||
|
</Menu.Item>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
<Menu.Sub>
|
<Menu.Sub>
|
||||||
<Menu.Sub.Target>
|
<Menu.Sub.Target>
|
||||||
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
|
<Menu.Sub.Item leftSection={<IconBrightnessFilled size={16} />}>
|
||||||
@@ -160,5 +196,8 @@ export default function TopMenu() {
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
</Menu.Dropdown>
|
</Menu.Dropdown>
|
||||||
</Menu>
|
</Menu>
|
||||||
|
|
||||||
|
<CreatePersonalSpaceModal opened={createOpened} onClose={closeCreate} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,5 +19,6 @@ export const Feature = {
|
|||||||
SHARING_CONTROLS: 'sharing:controls',
|
SHARING_CONTROLS: 'sharing:controls',
|
||||||
TEMPLATES: 'templates',
|
TEMPLATES: 'templates',
|
||||||
VIEWER_COMMENTS: 'comment:viewer',
|
VIEWER_COMMENTS: 'comment:viewer',
|
||||||
|
PERSONAL_SPACES: 'spaces:personal',
|
||||||
DOCX_EXPORT: 'export:docx',
|
DOCX_EXPORT: 'export:docx',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Modal, TextInput, Button, Group, Divider } from "@mantine/core";
|
||||||
|
import { useForm } from "@mantine/form";
|
||||||
|
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAtomValue } from "jotai";
|
||||||
|
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
import { useCreatePersonalSpaceMutation } from "@/ee/personal-space/queries/personal-space-query";
|
||||||
|
import { getSpaceUrl } from "@/lib/config.ts";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
|
||||||
|
const formSchema = z.object({
|
||||||
|
name: z.string().trim().min(2).max(100),
|
||||||
|
});
|
||||||
|
type FormValues = z.infer<typeof formSchema>;
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
opened: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CreatePersonalSpaceModal({ opened, onClose }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const currentUser = useAtomValue(currentUserAtom);
|
||||||
|
const createMutation = useCreatePersonalSpaceMutation();
|
||||||
|
|
||||||
|
const firstName = (currentUser?.user?.name ?? "").trim().split(/\s+/)[0] || "";
|
||||||
|
|
||||||
|
const form = useForm<FormValues>({
|
||||||
|
validate: zod4Resolver(formSchema),
|
||||||
|
initialValues: {
|
||||||
|
name: firstName ? t("{{name}}'s space", { name: firstName }) : "",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = async (values: FormValues) => {
|
||||||
|
try {
|
||||||
|
const createdSpace = await createMutation.mutateAsync({
|
||||||
|
name: values.name,
|
||||||
|
});
|
||||||
|
onClose();
|
||||||
|
navigate(getSpaceUrl(createdSpace.slug));
|
||||||
|
} catch (err) {
|
||||||
|
notifications.show({
|
||||||
|
message: err?.response?.data?.message,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
opened={opened}
|
||||||
|
onClose={onClose}
|
||||||
|
title={t("Create personal space")}
|
||||||
|
closeButtonProps={{ "aria-label": t("Close") }}
|
||||||
|
>
|
||||||
|
<Divider size="xs" mb="md" />
|
||||||
|
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||||
|
<TextInput
|
||||||
|
withAsterisk
|
||||||
|
data-autofocus
|
||||||
|
label={t("Space name")}
|
||||||
|
variant="filled"
|
||||||
|
errorProps={{ role: "alert" }}
|
||||||
|
{...form.getInputProps("name")}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
<Button type="submit" loading={createMutation.isPending}>
|
||||||
|
{t("Create")}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
import { Group, Text, Switch, Tooltip } from "@mantine/core";
|
||||||
|
import { useAtom } from "jotai";
|
||||||
|
import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||||
|
import { notifications } from "@mantine/notifications";
|
||||||
|
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||||
|
import { Feature } from "@/ee/features";
|
||||||
|
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
|
||||||
|
|
||||||
|
export default function PersonalSpacesSetting() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||||
|
<div>
|
||||||
|
<Text size="md">{t("Allow personal spaces")}</Text>
|
||||||
|
<Text size="sm" c="dimmed">
|
||||||
|
{t("Members can create their own personal space.")}
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PersonalSpacesToggle />
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PersonalSpacesToggle() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||||
|
const [checked, setChecked] = useState(
|
||||||
|
workspace?.settings?.spaces?.allowPersonal === true,
|
||||||
|
);
|
||||||
|
const hasPersonalSpaces = useHasFeature(Feature.PERSONAL_SPACES);
|
||||||
|
const upgradeLabel = useUpgradeLabel();
|
||||||
|
|
||||||
|
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = event.currentTarget.checked;
|
||||||
|
try {
|
||||||
|
const updatedWorkspace = await updateWorkspace({
|
||||||
|
allowPersonalSpaces: value,
|
||||||
|
});
|
||||||
|
setChecked(value);
|
||||||
|
setWorkspace(updatedWorkspace);
|
||||||
|
} catch (err) {
|
||||||
|
notifications.show({
|
||||||
|
message: err?.response?.data?.message,
|
||||||
|
color: "red",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip label={upgradeLabel} disabled={hasPersonalSpaces} refProp="rootRef">
|
||||||
|
<Switch
|
||||||
|
checked={checked}
|
||||||
|
onChange={handleChange}
|
||||||
|
disabled={!hasPersonalSpaces}
|
||||||
|
aria-label={t("Toggle allow personal spaces")}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import {
|
||||||
|
useMutation,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
UseQueryResult,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import { ISpace } from "@/features/space/types/space.types";
|
||||||
|
import {
|
||||||
|
createPersonalSpace,
|
||||||
|
getPersonalSpace,
|
||||||
|
} from "@/ee/personal-space/services/personal-space-service";
|
||||||
|
|
||||||
|
export function usePersonalSpaceQuery(
|
||||||
|
enabled: boolean,
|
||||||
|
): UseQueryResult<ISpace | null, Error> {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["personal-space"],
|
||||||
|
queryFn: () => getPersonalSpace(),
|
||||||
|
enabled,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreatePersonalSpaceMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<ISpace, Error, { name?: string }>({
|
||||||
|
mutationFn: (data) => createPersonalSpace(data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["personal-space"] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["spaces"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import api from "@/lib/api-client";
|
||||||
|
import { ISpace } from "@/features/space/types/space.types";
|
||||||
|
|
||||||
|
export async function getPersonalSpace(): Promise<ISpace | null> {
|
||||||
|
const req = await api.post<ISpace | null>("/personal-space/info", {});
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPersonalSpace(data: {
|
||||||
|
name?: string;
|
||||||
|
}): Promise<ISpace> {
|
||||||
|
const req = await api.post<ISpace>("/personal-space/create", data);
|
||||||
|
return req.data;
|
||||||
|
}
|
||||||
@@ -17,8 +17,8 @@ const formSchema = z.object({
|
|||||||
.min(2)
|
.min(2)
|
||||||
.max(100)
|
.max(100)
|
||||||
.regex(
|
.regex(
|
||||||
/^[a-zA-Z0-9]+$/,
|
/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
|
||||||
"Space slug must be alphanumeric. No special characters",
|
"Space slug must start with a letter or number and may contain hyphens and underscores",
|
||||||
),
|
),
|
||||||
description: z.string().max(500),
|
description: z.string().max(500),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,8 +15,8 @@ const formSchema = z.object({
|
|||||||
.min(2)
|
.min(2)
|
||||||
.max(100)
|
.max(100)
|
||||||
.regex(
|
.regex(
|
||||||
/^[a-zA-Z0-9]+$/,
|
/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
|
||||||
"Space slug must be alphanumeric. No special characters",
|
"Space slug must start with a letter or number and may contain hyphens and underscores",
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface ISpace {
|
|||||||
description: string;
|
description: string;
|
||||||
logo?: string;
|
logo?: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
isPersonal?: boolean;
|
||||||
hostname: string;
|
hostname: string;
|
||||||
creatorId: string;
|
creatorId: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export interface IWorkspace {
|
|||||||
trashRetentionDays?: number;
|
trashRetentionDays?: number;
|
||||||
restrictApiToAdmins?: boolean;
|
restrictApiToAdmins?: boolean;
|
||||||
allowMemberTemplates?: boolean;
|
allowMemberTemplates?: boolean;
|
||||||
|
allowPersonalSpaces?: boolean;
|
||||||
isScimEnabled?: boolean;
|
isScimEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ export interface IWorkspaceSettings {
|
|||||||
sharing?: IWorkspaceSharingSettings;
|
sharing?: IWorkspaceSharingSettings;
|
||||||
api?: IWorkspaceApiSettings;
|
api?: IWorkspaceApiSettings;
|
||||||
templates?: IWorkspaceTemplateSettings;
|
templates?: IWorkspaceTemplateSettings;
|
||||||
|
spaces?: IWorkspaceSpaceSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWorkspaceApiSettings {
|
export interface IWorkspaceApiSettings {
|
||||||
@@ -57,6 +59,10 @@ export interface IWorkspaceTemplateSettings {
|
|||||||
allowMemberTemplates?: boolean;
|
allowMemberTemplates?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface IWorkspaceSpaceSettings {
|
||||||
|
allowPersonal?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ICreateInvite {
|
export interface ICreateInvite {
|
||||||
role: string;
|
role: string;
|
||||||
emails: string[];
|
emails: string[];
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { Helmet } from "react-helmet-async";
|
|||||||
import ManageHostname from "@/ee/components/manage-hostname.tsx";
|
import ManageHostname from "@/ee/components/manage-hostname.tsx";
|
||||||
import { Divider } from "@mantine/core";
|
import { Divider } from "@mantine/core";
|
||||||
import AllowMemberTemplates from "@/ee/security/components/allow-member-templates.tsx";
|
import AllowMemberTemplates from "@/ee/security/components/allow-member-templates.tsx";
|
||||||
|
import PersonalSpacesSetting from "@/ee/personal-space/components/personal-spaces-setting.tsx";
|
||||||
|
|
||||||
export default function WorkspaceSettings() {
|
export default function WorkspaceSettings() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -22,6 +23,9 @@ export default function WorkspaceSettings() {
|
|||||||
<Divider my="md" />
|
<Divider my="md" />
|
||||||
<AllowMemberTemplates />
|
<AllowMemberTemplates />
|
||||||
|
|
||||||
|
<Divider my="md" />
|
||||||
|
<PersonalSpacesSetting />
|
||||||
|
|
||||||
{isCloud() && (
|
{isCloud() && (
|
||||||
<>
|
<>
|
||||||
<Divider my="md" />
|
<Divider my="md" />
|
||||||
|
|||||||
@@ -25,3 +25,11 @@
|
|||||||
.mantine-Input-input[data-variant="default"] {
|
.mantine-Input-input[data-variant="default"] {
|
||||||
border-color: var(--mantine-color-gray-6);
|
border-color: var(--mantine-color-gray-6);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* better contrast for disabled inputs */
|
||||||
|
.mantine-Input-input:disabled,
|
||||||
|
.mantine-Input-input[data-disabled] {
|
||||||
|
opacity: 0.7;
|
||||||
|
color: var(--mantine-color-dimmed);
|
||||||
|
-webkit-text-fill-color: var(--mantine-color-dimmed);
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const Feature = {
|
|||||||
VIEWER_COMMENTS: 'comment:viewer',
|
VIEWER_COMMENTS: 'comment:viewer',
|
||||||
TEMPLATES: 'templates',
|
TEMPLATES: 'templates',
|
||||||
PDF_EXPORT: 'export:pdf',
|
PDF_EXPORT: 'export:pdf',
|
||||||
|
PERSONAL_SPACES: 'spaces:personal',
|
||||||
DOCX_EXPORT: 'export:docx',
|
DOCX_EXPORT: 'export:docx',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
IsAlphanumeric,
|
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
Matches,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
MinLength,
|
MinLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
@@ -20,6 +20,9 @@ export class CreateSpaceDto {
|
|||||||
|
|
||||||
@MinLength(2)
|
@MinLength(2)
|
||||||
@MaxLength(100)
|
@MaxLength(100)
|
||||||
@IsAlphanumeric()
|
@Matches(/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/, {
|
||||||
|
message:
|
||||||
|
'Space slug must start with a letter or number and may contain hyphens and underscores',
|
||||||
|
})
|
||||||
slug: string;
|
slug: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export class SpaceService {
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
createSpaceDto: CreateSpaceDto,
|
createSpaceDto: CreateSpaceDto,
|
||||||
trx?: KyselyTransaction,
|
trx?: KyselyTransaction,
|
||||||
|
options?: { isPersonal?: boolean },
|
||||||
): Promise<Space> {
|
): Promise<Space> {
|
||||||
let space = null;
|
let space = null;
|
||||||
|
|
||||||
@@ -59,6 +60,7 @@ export class SpaceService {
|
|||||||
workspaceId,
|
workspaceId,
|
||||||
createSpaceDto,
|
createSpaceDto,
|
||||||
trx,
|
trx,
|
||||||
|
options,
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.spaceMemberService.addUserToSpace(
|
await this.spaceMemberService.addUserToSpace(
|
||||||
@@ -81,6 +83,7 @@ export class SpaceService {
|
|||||||
after: {
|
after: {
|
||||||
name: space.name,
|
name: space.name,
|
||||||
slug: space.slug,
|
slug: space.slug,
|
||||||
|
...(space.isPersonal ? { isPersonal: true } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -93,6 +96,7 @@ export class SpaceService {
|
|||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
createSpaceDto: CreateSpaceDto,
|
createSpaceDto: CreateSpaceDto,
|
||||||
trx?: KyselyTransaction,
|
trx?: KyselyTransaction,
|
||||||
|
options?: { isPersonal?: boolean },
|
||||||
): Promise<Space> {
|
): Promise<Space> {
|
||||||
const slugExists = await this.spaceRepo.slugExists(
|
const slugExists = await this.spaceRepo.slugExists(
|
||||||
createSpaceDto.slug,
|
createSpaceDto.slug,
|
||||||
@@ -112,6 +116,7 @@ export class SpaceService {
|
|||||||
creatorId: userId,
|
creatorId: userId,
|
||||||
workspaceId: workspaceId,
|
workspaceId: workspaceId,
|
||||||
slug: createSpaceDto.slug,
|
slug: createSpaceDto.slug,
|
||||||
|
isPersonal: options?.isPersonal ?? false,
|
||||||
},
|
},
|
||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -57,4 +57,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
allowMemberTemplates: boolean;
|
allowMemberTemplates: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
allowPersonalSpaces: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -333,7 +333,8 @@ export class WorkspaceService {
|
|||||||
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
|
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
|
||||||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
|
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
|
||||||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' ||
|
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' ||
|
||||||
typeof updateWorkspaceDto.isScimEnabled !== 'undefined'
|
typeof updateWorkspaceDto.isScimEnabled !== 'undefined' ||
|
||||||
|
typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined'
|
||||||
) {
|
) {
|
||||||
const ws = await this.db
|
const ws = await this.db
|
||||||
.selectFrom('workspaces')
|
.selectFrom('workspaces')
|
||||||
@@ -361,6 +362,18 @@ export class WorkspaceService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined') {
|
||||||
|
if (
|
||||||
|
!this.licenseCheckService.hasFeature(
|
||||||
|
ws.licenseKey,
|
||||||
|
Feature.PERSONAL_SPACES,
|
||||||
|
ws.plan,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException('This feature requires a valid license');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
||||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
||||||
@@ -500,6 +513,20 @@ export class WorkspaceService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined') {
|
||||||
|
const prev = settingsBefore?.spaces?.allowPersonal ?? false;
|
||||||
|
if (prev !== updateWorkspaceDto.allowPersonalSpaces) {
|
||||||
|
before.allowPersonalSpaces = prev;
|
||||||
|
after.allowPersonalSpaces = updateWorkspaceDto.allowPersonalSpaces;
|
||||||
|
}
|
||||||
|
await this.workspaceRepo.updateSpaceSettings(
|
||||||
|
workspaceId,
|
||||||
|
'allowPersonal',
|
||||||
|
updateWorkspaceDto.allowPersonalSpaces,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
delete updateWorkspaceDto.restrictApiToAdmins;
|
delete updateWorkspaceDto.restrictApiToAdmins;
|
||||||
delete updateWorkspaceDto.aiSearch;
|
delete updateWorkspaceDto.aiSearch;
|
||||||
delete updateWorkspaceDto.generativeAi;
|
delete updateWorkspaceDto.generativeAi;
|
||||||
@@ -507,6 +534,7 @@ export class WorkspaceService {
|
|||||||
delete updateWorkspaceDto.mcpEnabled;
|
delete updateWorkspaceDto.mcpEnabled;
|
||||||
delete updateWorkspaceDto.allowMemberTemplates;
|
delete updateWorkspaceDto.allowMemberTemplates;
|
||||||
delete updateWorkspaceDto.aiChat;
|
delete updateWorkspaceDto.aiChat;
|
||||||
|
delete updateWorkspaceDto.allowPersonalSpaces;
|
||||||
|
|
||||||
await this.workspaceRepo.updateWorkspace(
|
await this.workspaceRepo.updateWorkspace(
|
||||||
updateWorkspaceDto,
|
updateWorkspaceDto,
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.alterTable('spaces')
|
||||||
|
.addColumn('is_personal', 'boolean', (col) =>
|
||||||
|
col.notNull().defaultTo(false),
|
||||||
|
)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
await sql`
|
||||||
|
CREATE UNIQUE INDEX spaces_personal_creator_unique
|
||||||
|
ON spaces (creator_id)
|
||||||
|
WHERE is_personal = true AND deleted_at IS NULL
|
||||||
|
`.execute(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await db.schema
|
||||||
|
.dropIndex('spaces_personal_creator_unique')
|
||||||
|
.ifExists()
|
||||||
|
.execute();
|
||||||
|
await db.schema.alterTable('spaces').dropColumn('is_personal').execute();
|
||||||
|
}
|
||||||
@@ -57,6 +57,22 @@ export class SpaceRepo {
|
|||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async findPersonalSpace(
|
||||||
|
userId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
): Promise<Space | undefined> {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
return db
|
||||||
|
.selectFrom('spaces')
|
||||||
|
.selectAll('spaces')
|
||||||
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
.where('creatorId', '=', userId)
|
||||||
|
.where('isPersonal', '=', true)
|
||||||
|
.where('deletedAt', 'is', null)
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
async slugExists(
|
async slugExists(
|
||||||
slug: string,
|
slug: string,
|
||||||
workspaceId: string,
|
workspaceId: string,
|
||||||
|
|||||||
@@ -251,4 +251,24 @@ export class WorkspaceRepo {
|
|||||||
.executeTakeFirst();
|
.executeTakeFirst();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateSpaceSettings(
|
||||||
|
workspaceId: string,
|
||||||
|
prefKey: string,
|
||||||
|
prefValue: string | boolean,
|
||||||
|
trx?: KyselyTransaction,
|
||||||
|
) {
|
||||||
|
const db = dbOrTx(this.db, trx);
|
||||||
|
return db
|
||||||
|
.updateTable('workspaces')
|
||||||
|
.set({
|
||||||
|
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||||
|
|| jsonb_build_object('spaces', COALESCE(settings->'spaces', '{}'::jsonb)
|
||||||
|
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
})
|
||||||
|
.where('id', '=', workspaceId)
|
||||||
|
.returning(this.baseFields)
|
||||||
|
.executeTakeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -322,6 +322,7 @@ export interface Spaces {
|
|||||||
deletedAt: Timestamp | null;
|
deletedAt: Timestamp | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
id: Generated<string>;
|
id: Generated<string>;
|
||||||
|
isPersonal: Generated<boolean>;
|
||||||
logo: string | null;
|
logo: string | null;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
settings: Json | null;
|
settings: Json | null;
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: 7afa4e9f2b...efd4d10dfd
Reference in New Issue
Block a user