feat(ee): personal spaces (#2298)

* feat(ee): personal spaces

* pref

* feat: on-demand only

* error notification
This commit is contained in:
Philip Okugbe
2026-06-20 14:27:41 +01:00
committed by GitHub
parent 510199cf04
commit d68e241f45
23 changed files with 366 additions and 9 deletions
@@ -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} />
</>
); );
} }
+1
View File
@@ -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);
}
+1
View File
@@ -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
View File
@@ -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;