diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index aa20b9144..10e6588a4 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -1087,5 +1087,11 @@
"Added {{name}} to favorites": "Added {{name}} to favorites",
"Removed {{name}} from favorites": "Removed {{name}} from favorites",
"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"
}
diff --git a/apps/client/src/components/layouts/global/top-menu.tsx b/apps/client/src/components/layouts/global/top-menu.tsx
index 849250800..9ee13e9c9 100644
--- a/apps/client/src/components/layouts/global/top-menu.tsx
+++ b/apps/client/src/components/layouts/global/top-menu.tsx
@@ -15,9 +15,16 @@ import {
IconMoon,
IconSettings,
IconSun,
+ IconUser,
IconUserCircle,
IconUsers,
} 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 { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import { Link } from "react-router-dom";
@@ -36,11 +43,20 @@ export default function TopMenu() {
const user = currentUser?.user;
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) {
return <>>;
}
return (
+ <>
+
+
+ >
);
}
diff --git a/apps/client/src/ee/features.ts b/apps/client/src/ee/features.ts
index 5a23900e3..a087b1328 100644
--- a/apps/client/src/ee/features.ts
+++ b/apps/client/src/ee/features.ts
@@ -19,5 +19,6 @@ export const Feature = {
SHARING_CONTROLS: 'sharing:controls',
TEMPLATES: 'templates',
VIEWER_COMMENTS: 'comment:viewer',
+ PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx',
} as const;
diff --git a/apps/client/src/ee/personal-space/components/create-personal-space-modal.tsx b/apps/client/src/ee/personal-space/components/create-personal-space-modal.tsx
new file mode 100644
index 000000000..72f1bc2b1
--- /dev/null
+++ b/apps/client/src/ee/personal-space/components/create-personal-space-modal.tsx
@@ -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;
+
+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({
+ 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 (
+
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/personal-space/components/personal-spaces-setting.tsx b/apps/client/src/ee/personal-space/components/personal-spaces-setting.tsx
new file mode 100644
index 000000000..77ea884cd
--- /dev/null
+++ b/apps/client/src/ee/personal-space/components/personal-spaces-setting.tsx
@@ -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 (
+
+
+ {t("Allow personal spaces")}
+
+ {t("Members can create their own personal space.")}
+
+
+
+
+
+ );
+}
+
+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) => {
+ 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 (
+
+
+
+ );
+}
diff --git a/apps/client/src/ee/personal-space/queries/personal-space-query.ts b/apps/client/src/ee/personal-space/queries/personal-space-query.ts
new file mode 100644
index 000000000..4de492b66
--- /dev/null
+++ b/apps/client/src/ee/personal-space/queries/personal-space-query.ts
@@ -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 {
+ return useQuery({
+ queryKey: ["personal-space"],
+ queryFn: () => getPersonalSpace(),
+ enabled,
+ staleTime: 5 * 60 * 1000,
+ });
+}
+
+export function useCreatePersonalSpaceMutation() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (data) => createPersonalSpace(data),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["personal-space"] });
+ queryClient.invalidateQueries({ queryKey: ["spaces"] });
+ },
+ });
+}
diff --git a/apps/client/src/ee/personal-space/services/personal-space-service.ts b/apps/client/src/ee/personal-space/services/personal-space-service.ts
new file mode 100644
index 000000000..67cb7e5ce
--- /dev/null
+++ b/apps/client/src/ee/personal-space/services/personal-space-service.ts
@@ -0,0 +1,14 @@
+import api from "@/lib/api-client";
+import { ISpace } from "@/features/space/types/space.types";
+
+export async function getPersonalSpace(): Promise {
+ const req = await api.post("/personal-space/info", {});
+ return req.data;
+}
+
+export async function createPersonalSpace(data: {
+ name?: string;
+}): Promise {
+ const req = await api.post("/personal-space/create", data);
+ return req.data;
+}
diff --git a/apps/client/src/features/space/components/create-space-form.tsx b/apps/client/src/features/space/components/create-space-form.tsx
index d5d26d57b..b156704a3 100644
--- a/apps/client/src/features/space/components/create-space-form.tsx
+++ b/apps/client/src/features/space/components/create-space-form.tsx
@@ -17,8 +17,8 @@ const formSchema = z.object({
.min(2)
.max(100)
.regex(
- /^[a-zA-Z0-9]+$/,
- "Space slug must be alphanumeric. No special characters",
+ /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
+ "Space slug must start with a letter or number and may contain hyphens and underscores",
),
description: z.string().max(500),
});
diff --git a/apps/client/src/features/space/components/edit-space-form.tsx b/apps/client/src/features/space/components/edit-space-form.tsx
index fae8de11d..5abe4144c 100644
--- a/apps/client/src/features/space/components/edit-space-form.tsx
+++ b/apps/client/src/features/space/components/edit-space-form.tsx
@@ -15,8 +15,8 @@ const formSchema = z.object({
.min(2)
.max(100)
.regex(
- /^[a-zA-Z0-9]+$/,
- "Space slug must be alphanumeric. No special characters",
+ /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
+ "Space slug must start with a letter or number and may contain hyphens and underscores",
),
});
diff --git a/apps/client/src/features/space/types/space.types.ts b/apps/client/src/features/space/types/space.types.ts
index c856d88a8..6937b233c 100644
--- a/apps/client/src/features/space/types/space.types.ts
+++ b/apps/client/src/features/space/types/space.types.ts
@@ -24,6 +24,7 @@ export interface ISpace {
description: string;
logo?: string;
slug: string;
+ isPersonal?: boolean;
hostname: string;
creatorId: string;
createdAt: Date;
diff --git a/apps/client/src/features/workspace/types/workspace.types.ts b/apps/client/src/features/workspace/types/workspace.types.ts
index eda89dd56..eb927e789 100644
--- a/apps/client/src/features/workspace/types/workspace.types.ts
+++ b/apps/client/src/features/workspace/types/workspace.types.ts
@@ -28,6 +28,7 @@ export interface IWorkspace {
trashRetentionDays?: number;
restrictApiToAdmins?: boolean;
allowMemberTemplates?: boolean;
+ allowPersonalSpaces?: boolean;
isScimEnabled?: boolean;
}
@@ -36,6 +37,7 @@ export interface IWorkspaceSettings {
sharing?: IWorkspaceSharingSettings;
api?: IWorkspaceApiSettings;
templates?: IWorkspaceTemplateSettings;
+ spaces?: IWorkspaceSpaceSettings;
}
export interface IWorkspaceApiSettings {
@@ -57,6 +59,10 @@ export interface IWorkspaceTemplateSettings {
allowMemberTemplates?: boolean;
}
+export interface IWorkspaceSpaceSettings {
+ allowPersonal?: boolean;
+}
+
export interface ICreateInvite {
role: string;
emails: string[];
diff --git a/apps/client/src/pages/settings/workspace/workspace-settings.tsx b/apps/client/src/pages/settings/workspace/workspace-settings.tsx
index 29e2841ca..e31b41b0f 100644
--- a/apps/client/src/pages/settings/workspace/workspace-settings.tsx
+++ b/apps/client/src/pages/settings/workspace/workspace-settings.tsx
@@ -7,6 +7,7 @@ import { Helmet } from "react-helmet-async";
import ManageHostname from "@/ee/components/manage-hostname.tsx";
import { Divider } from "@mantine/core";
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() {
const { t } = useTranslation();
@@ -22,6 +23,9 @@ export default function WorkspaceSettings() {
+
+
+
{isCloud() && (
<>
diff --git a/apps/client/src/styles/a11y-overrides.css b/apps/client/src/styles/a11y-overrides.css
index 69727e079..25c1eafe7 100644
--- a/apps/client/src/styles/a11y-overrides.css
+++ b/apps/client/src/styles/a11y-overrides.css
@@ -25,3 +25,11 @@
.mantine-Input-input[data-variant="default"] {
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);
+}
diff --git a/apps/server/src/common/features.ts b/apps/server/src/common/features.ts
index 4e2723943..0c0218d1e 100644
--- a/apps/server/src/common/features.ts
+++ b/apps/server/src/common/features.ts
@@ -20,6 +20,7 @@ export const Feature = {
VIEWER_COMMENTS: 'comment:viewer',
TEMPLATES: 'templates',
PDF_EXPORT: 'export:pdf',
+ PERSONAL_SPACES: 'spaces:personal',
DOCX_EXPORT: 'export:docx',
} as const;
diff --git a/apps/server/src/core/space/dto/create-space.dto.ts b/apps/server/src/core/space/dto/create-space.dto.ts
index 310bdcf25..81af5b3fa 100644
--- a/apps/server/src/core/space/dto/create-space.dto.ts
+++ b/apps/server/src/core/space/dto/create-space.dto.ts
@@ -1,7 +1,7 @@
import {
- IsAlphanumeric,
IsOptional,
IsString,
+ Matches,
MaxLength,
MinLength,
} from 'class-validator';
@@ -20,6 +20,9 @@ export class CreateSpaceDto {
@MinLength(2)
@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;
}
diff --git a/apps/server/src/core/space/services/space.service.ts b/apps/server/src/core/space/services/space.service.ts
index 2675a9e6a..af1a61079 100644
--- a/apps/server/src/core/space/services/space.service.ts
+++ b/apps/server/src/core/space/services/space.service.ts
@@ -48,6 +48,7 @@ export class SpaceService {
workspaceId: string,
createSpaceDto: CreateSpaceDto,
trx?: KyselyTransaction,
+ options?: { isPersonal?: boolean },
): Promise {
let space = null;
@@ -59,6 +60,7 @@ export class SpaceService {
workspaceId,
createSpaceDto,
trx,
+ options,
);
await this.spaceMemberService.addUserToSpace(
@@ -81,6 +83,7 @@ export class SpaceService {
after: {
name: space.name,
slug: space.slug,
+ ...(space.isPersonal ? { isPersonal: true } : {}),
},
},
});
@@ -93,6 +96,7 @@ export class SpaceService {
workspaceId: string,
createSpaceDto: CreateSpaceDto,
trx?: KyselyTransaction,
+ options?: { isPersonal?: boolean },
): Promise {
const slugExists = await this.spaceRepo.slugExists(
createSpaceDto.slug,
@@ -112,6 +116,7 @@ export class SpaceService {
creatorId: userId,
workspaceId: workspaceId,
slug: createSpaceDto.slug,
+ isPersonal: options?.isPersonal ?? false,
},
trx,
);
diff --git a/apps/server/src/core/workspace/dto/update-workspace.dto.ts b/apps/server/src/core/workspace/dto/update-workspace.dto.ts
index 25697a4b9..4d0a650b3 100644
--- a/apps/server/src/core/workspace/dto/update-workspace.dto.ts
+++ b/apps/server/src/core/workspace/dto/update-workspace.dto.ts
@@ -57,4 +57,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@IsBoolean()
allowMemberTemplates: boolean;
+
+ @IsOptional()
+ @IsBoolean()
+ allowPersonalSpaces: boolean;
}
diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts
index f3ab78e60..8f5e3c993 100644
--- a/apps/server/src/core/workspace/services/workspace.service.ts
+++ b/apps/server/src/core/workspace/services/workspace.service.ts
@@ -333,7 +333,8 @@ export class WorkspaceService {
typeof updateWorkspaceDto.mcpEnabled !== 'undefined' ||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined' ||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' ||
- typeof updateWorkspaceDto.isScimEnabled !== 'undefined'
+ typeof updateWorkspaceDto.isScimEnabled !== 'undefined' ||
+ typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined'
) {
const ws = await this.db
.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 (
typeof updateWorkspaceDto.disablePublicSharing !== '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.aiSearch;
delete updateWorkspaceDto.generativeAi;
@@ -507,6 +534,7 @@ export class WorkspaceService {
delete updateWorkspaceDto.mcpEnabled;
delete updateWorkspaceDto.allowMemberTemplates;
delete updateWorkspaceDto.aiChat;
+ delete updateWorkspaceDto.allowPersonalSpaces;
await this.workspaceRepo.updateWorkspace(
updateWorkspaceDto,
diff --git a/apps/server/src/database/migrations/20260620T010047-personal-spaces.ts b/apps/server/src/database/migrations/20260620T010047-personal-spaces.ts
new file mode 100644
index 000000000..bade5e3ae
--- /dev/null
+++ b/apps/server/src/database/migrations/20260620T010047-personal-spaces.ts
@@ -0,0 +1,24 @@
+import { Kysely, sql } from 'kysely';
+
+export async function up(db: Kysely): Promise {
+ 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): Promise {
+ await db.schema
+ .dropIndex('spaces_personal_creator_unique')
+ .ifExists()
+ .execute();
+ await db.schema.alterTable('spaces').dropColumn('is_personal').execute();
+}
diff --git a/apps/server/src/database/repos/space/space.repo.ts b/apps/server/src/database/repos/space/space.repo.ts
index 0b3896650..905d2fac6 100644
--- a/apps/server/src/database/repos/space/space.repo.ts
+++ b/apps/server/src/database/repos/space/space.repo.ts
@@ -57,6 +57,22 @@ export class SpaceRepo {
.executeTakeFirst();
}
+ async findPersonalSpace(
+ userId: string,
+ workspaceId: string,
+ trx?: KyselyTransaction,
+ ): Promise {
+ 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(
slug: string,
workspaceId: string,
diff --git a/apps/server/src/database/repos/workspace/workspace.repo.ts b/apps/server/src/database/repos/workspace/workspace.repo.ts
index 408c46ba9..6bf2d447a 100644
--- a/apps/server/src/database/repos/workspace/workspace.repo.ts
+++ b/apps/server/src/database/repos/workspace/workspace.repo.ts
@@ -251,4 +251,24 @@ export class WorkspaceRepo {
.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();
+ }
+
}
diff --git a/apps/server/src/database/types/db.d.ts b/apps/server/src/database/types/db.d.ts
index 4463aa7aa..b0f875cf7 100644
--- a/apps/server/src/database/types/db.d.ts
+++ b/apps/server/src/database/types/db.d.ts
@@ -322,6 +322,7 @@ export interface Spaces {
deletedAt: Timestamp | null;
description: string | null;
id: Generated;
+ isPersonal: Generated;
logo: string | null;
name: string | null;
settings: Json | null;
diff --git a/apps/server/src/ee b/apps/server/src/ee
index 7afa4e9f2..efd4d10df 160000
--- a/apps/server/src/ee
+++ b/apps/server/src/ee
@@ -1 +1 @@
-Subproject commit 7afa4e9f2bafa8aa99be83f5e76e75d10cf9cde4
+Subproject commit efd4d10dfd83d6930c2f069dd934d57314c32576