Compare commits

..

5 Commits

Author SHA1 Message Date
Philipinho 3b4a02e94a feat: give workspace owners global space management permission 2025-09-19 22:00:09 +01:00
Philipinho 3c4cab0d2a v0.23.2 2025-09-18 18:00:28 +01:00
Philipinho 4de25a8b94 invalidate queries on space deletion 2025-09-18 15:52:53 +01:00
Philipinho cf5bbb10df fix import html processing 2025-09-18 15:34:13 +01:00
Philipinho ac17521717 sync 2025-09-18 13:24:16 +01:00
23 changed files with 207 additions and 156 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.23.1",
"version": "0.23.2",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@@ -533,8 +533,5 @@
"Remove image": "Remove image",
"Failed to remove image": "Failed to remove image",
"Image exceeds 10MB limit.": "Image exceeds 10MB limit.",
"Image removed successfully": "Image removed successfully",
"Added successfully": "Added successfully",
"Removed successfully": "Removed successfully",
"Failed to add group members": "Failed to add group members"
"Image removed successfully": "Image removed successfully"
}
@@ -37,7 +37,6 @@ export default function AddGroupMemberModal() {
<MultiUserSelect
label={t("Add group members")}
onChange={handleMultiSelectChange}
groupId={groupId}
/>
<Group justify="flex-end" mt="md">
@@ -9,7 +9,6 @@ import { useTranslation } from "react-i18next";
interface MultiUserSelectProps {
onChange: (value: string[]) => void;
label?: string;
groupId?: string;
}
const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
@@ -22,9 +21,7 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
size={36}
/>
<div>
<Text size="sm" lineClamp={1}>
{option.label}
</Text>
<Text size="sm" lineClamp={1}>{option.label}</Text>
<Text size="xs" opacity={0.5}>
{option?.["email"]}
</Text>
@@ -32,20 +29,14 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
</Group>
);
export function MultiUserSelect({
onChange,
label,
groupId,
}: MultiUserSelectProps) {
export function MultiUserSelect({ onChange, label }: MultiUserSelectProps) {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
const { data: users, isLoading } = useWorkspaceMembersQuery({
query: debouncedQuery,
limit: 50,
...(groupId && { groupId }),
});
const [data, setData] = useState([]);
useEffect(() => {
@@ -22,7 +22,6 @@ import { IUser } from "@/features/user/types/user.types.ts";
import { useEffect } from "react";
import { validate as isValidUuid } from "uuid";
import { queryClient } from "@/main.tsx";
import { useTranslation } from "react-i18next";
export function useGetGroupsQuery(
params?: QueryParams,
@@ -120,24 +119,18 @@ export function useGroupMembersQuery(
export function useAddGroupMemberMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<void, Error, { groupId: string; userIds: string[] }>({
mutationFn: (data) => addGroupMember(data),
onSuccess: (data, variables) => {
notifications.show({ message: t("Added successfully") });
notifications.show({ message: "Added successfully" });
queryClient.invalidateQueries({
queryKey: ["groupMembers", variables.groupId],
});
queryClient.invalidateQueries({
predicate: (item) =>
["workspaceMembers"].includes(item.queryKey[0] as string),
});
},
onError: () => {
notifications.show({
message: t("Failed to add group members"),
message: "Failed to add group members",
color: "red",
});
},
@@ -146,7 +139,6 @@ export function useAddGroupMemberMutation() {
export function useRemoveGroupMemberMutation() {
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation<
void,
@@ -158,15 +150,10 @@ export function useRemoveGroupMemberMutation() {
>({
mutationFn: (data) => removeGroupMember(data),
onSuccess: (data, variables) => {
notifications.show({ message: t("Removed successfully") });
notifications.show({ message: "Removed successfully" });
queryClient.invalidateQueries({
queryKey: ["groupMembers", variables.groupId],
});
queryClient.invalidateQueries({
predicate: (item) =>
["workspaceMembers"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
@@ -55,7 +55,7 @@ export default function AddSpaceMembersModal({
<Divider size="xs" mb="xs" />
<Stack>
<MultiMemberSelect onChange={handleMultiSelectChange} spaceId={spaceId} />
<MultiMemberSelect onChange={handleMultiSelectChange} />
<SpaceMemberRole
onSelect={handleRoleSelection}
defaultRole={role}
@@ -10,7 +10,6 @@ import { useTranslation } from "react-i18next";
interface MultiMemberSelectProps {
onChange: (value: string[]) => void;
spaceId?: string;
}
const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
@@ -26,38 +25,23 @@ const renderMultiSelectOption: MultiSelectProps["renderOption"] = ({
)}
{option["type"] === "group" && <IconGroupCircle />}
<div>
<Text size="sm" lineClamp={1}>
{option.label}
</Text>
<Text size="sm" lineClamp={1}>{option.label}</Text>
{option["type"] === "user" && option["email"] && (
<Text size="xs" c="dimmed" lineClamp={1}>
{option["email"]}
</Text>
<Text size="xs" c="dimmed" lineClamp={1}>{option["email"]}</Text>
)}
</div>
</Group>
);
export function MultiMemberSelect({
onChange,
spaceId,
}: MultiMemberSelectProps) {
export function MultiMemberSelect({ onChange }: MultiMemberSelectProps) {
const { t } = useTranslation();
const [searchValue, setSearchValue] = useState("");
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
console.log("vacant", spaceId);
// Filter out empty parameters to avoid duplicate cache keys
const queryParams = {
...(debouncedQuery && { query: debouncedQuery }),
const { data: suggestion, isLoading } = useSearchSuggestionsQuery({
query: debouncedQuery,
includeUsers: true,
includeGroups: true,
...(spaceId && { spaceId }),
};
const { data: suggestion, isLoading } =
useSearchSuggestionsQuery(queryParams);
});
const [data, setData] = useState([]);
useEffect(() => {
@@ -79,14 +63,14 @@ export function MultiMemberSelect({
// Create fresh data structure based on current search results
const newData = [];
if (userItems && userItems.length > 0) {
newData.push({
group: t("Select a user"),
items: userItems,
});
}
if (groupItems && groupItems.length > 0) {
newData.push({
group: t("Select a group"),
@@ -8,11 +8,20 @@ import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts";
import { useAtom } from "jotai";
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
import { UserRole } from "@/lib/types.ts";
import { useIsEEOnly } from "@/hooks/use-is-cloud-ee.tsx";
export default function SpaceList() {
const { t } = useTranslation();
const [page, setPage] = useState(1);
const { data, isLoading } = useGetSpacesQuery({ page });
const [user] = useAtom(userAtom);
const isEEOnly = useIsEEOnly();
const { data, isLoading } = useGetSpacesQuery({
page,
...(isEEOnly && user.role === UserRole.OWNER && { includeAllSpaces: true }),
});
const [opened, { open, close }] = useDisclosure(false);
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
@@ -152,13 +152,36 @@ export function useDeleteSpaceMutation() {
});
}
const spaces = queryClient.getQueryData(["spaces"]) as any;
// Remove space-specific queries
if (variables.id) {
queryClient.removeQueries({
queryKey: ["space", variables.id],
exact: true,
});
// Invalidate recent changes
queryClient.invalidateQueries({
queryKey: ["recent-changes"],
});
queryClient.invalidateQueries({
queryKey: ["recent-changes", variables.id],
});
}
// Update spaces list cache
/* const spaces = queryClient.getQueryData(["spaces"]) as any;
if (spaces) {
spaces.items = spaces.items?.filter(
(space: ISpace) => space.id !== variables.id,
);
queryClient.setQueryData(["spaces"], spaces);
}
}*/
// Invalidate all spaces queries to refresh lists
queryClient.invalidateQueries({
predicate: (item) => ["spaces"].includes(item.queryKey[0] as string),
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
@@ -190,28 +213,6 @@ export function useAddSpaceMemberMutation() {
queryClient.invalidateQueries({
queryKey: ["spaceMembers", variables.spaceId],
});
// Optimistically update search suggestions cache by filtering out added users and groups
queryClient.setQueriesData(
{ queryKey: ["search-suggestion"], exact: false },
(oldData: any) => {
if (!oldData) return oldData;
const filteredUsers = oldData.users?.filter((user: any) =>
!variables.userIds?.includes(user.id)
) || [];
const filteredGroups = oldData.groups?.filter((group: any) =>
!variables.groupIds?.includes(group.id)
) || [];
return {
...oldData,
users: filteredUsers,
groups: filteredGroups,
};
}
);
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
@@ -231,16 +232,6 @@ export function useRemoveSpaceMemberMutation() {
queryClient.invalidateQueries({
queryKey: ["spaceMembers", variables.spaceId],
});
// For remove operations, invalidate to get fresh data
// since adding the user/group back requires fetching their current data
queryClient.invalidateQueries({
queryKey: ["search-suggestion"],
predicate: (query) => {
const queryKey = query.queryKey as any[];
return queryKey[1]?.spaceId === variables.spaceId;
},
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
+10 -1
View File
@@ -1,7 +1,16 @@
import { isCloud } from "@/lib/config";
import { useLicense } from "@/ee/hooks/use-license";
import { useAtom } from "jotai/index";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
import usePlan from "@/ee/hooks/use-plan";
export const useIsCloudEE = () => {
const { hasLicenseKey } = useLicense();
return isCloud() || !!hasLicenseKey;
};
};
export const useIsEEOnly = () => {
const { hasLicenseKey } = useLicense();
const { isBusiness } = usePlan();
return (isCloud() && isBusiness) || !!hasLicenseKey;
};
+1 -1
View File
@@ -2,7 +2,7 @@ export interface QueryParams {
query?: string;
page?: number;
limit?: number;
groupId?: string;
includeAllSpaces?: boolean;
}
export enum UserRole {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.23.1",
"version": "0.23.2",
"description": "",
"author": "",
"private": true,
@@ -10,7 +10,7 @@ import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils';
import { SpaceRole } from '../../common/helpers/types/permission';
import { SpaceRole, UserRole } from '../../common/helpers/types/permission';
import { getPageId } from '../collaboration.util';
import { JwtCollabPayload, JwtType } from '../../core/auth/dto/jwt-payload';
@@ -63,7 +63,10 @@ export class AuthenticationExtension implements Extension {
const userSpaceRole = findHighestUserSpaceRole(userSpaceRoles);
if (!userSpaceRole) {
// if role not found but user is a workspace owner, grant them readonly permission
if (!userSpaceRole && user.role === UserRole.OWNER) {
data.connection.readOnly = true;
} else if (!userSpaceRole) {
this.logger.warn(`User not authorized to access page: ${pageId}`);
throw new UnauthorizedException();
}
@@ -4,7 +4,7 @@ import {
createMongoAbility,
MongoAbility,
} from '@casl/ability';
import { SpaceRole } from '../../../common/helpers/types/permission';
import { SpaceRole, UserRole } from '../../../common/helpers/types/permission';
import { User } from '@docmost/db/types/entity.types';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import {
@@ -25,13 +25,17 @@ export default class SpaceAbilityFactory {
const userSpaceRole = findHighestUserSpaceRole(userSpaceRoles);
if (!userSpaceRole && user.role === UserRole.OWNER) {
return buildWorkspaceOwnerAbility();
}
switch (userSpaceRole) {
case SpaceRole.ADMIN:
return buildSpaceAdminAbility();
case SpaceRole.WRITER:
return buildSpaceWriterAbility();
return buildSpaceWriterAbility(user.role);
case SpaceRole.READER:
return buildSpaceReaderAbility();
return buildSpaceReaderAbility(user.role);
default:
throw new NotFoundException('Space permissions not found');
}
@@ -49,23 +53,50 @@ function buildSpaceAdminAbility() {
return build();
}
function buildSpaceWriterAbility() {
function buildSpaceWriterAbility(workspaceRole?: string) {
const { can, build } = new AbilityBuilder<MongoAbility<ISpaceAbility>>(
createMongoAbility,
);
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
if (workspaceRole === UserRole.OWNER) {
// Workspace owners get manage permissions even with writer space role
can(SpaceCaslAction.Manage, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Member);
} else {
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
}
can(SpaceCaslAction.Manage, SpaceCaslSubject.Page);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Share);
return build();
}
function buildSpaceReaderAbility() {
function buildSpaceReaderAbility(workspaceRole?: string) {
const { can, build } = new AbilityBuilder<MongoAbility<ISpaceAbility>>(
createMongoAbility,
);
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
if (workspaceRole === UserRole.OWNER) {
// Workspace owners get manage permissions even with reader space role
can(SpaceCaslAction.Manage, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Member);
} else {
can(SpaceCaslAction.Read, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Read, SpaceCaslSubject.Member);
}
can(SpaceCaslAction.Read, SpaceCaslSubject.Page);
can(SpaceCaslAction.Read, SpaceCaslSubject.Share);
return build();
}
function buildWorkspaceOwnerAbility() {
const { can, build } = new AbilityBuilder<MongoAbility<ISpaceAbility>>(
createMongoAbility,
);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Settings);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Member);
can(SpaceCaslAction.Read, SpaceCaslSubject.Page);
can(SpaceCaslAction.Read, SpaceCaslSubject.Share);
return build();
+7 -30
View File
@@ -145,7 +145,7 @@ export class SearchService {
const query = suggestion.query.toLowerCase().trim();
if (suggestion.includeUsers) {
let userQuery = this.db
const userQuery = this.db
.selectFrom('users')
.select(['id', 'name', 'email', 'avatarUrl'])
.where('workspaceId', '=', workspaceId)
@@ -159,25 +159,14 @@ export class SearchService {
),
eb(sql`users.email`, 'ilike', sql`f_unaccent(${`%${query}%`})`),
]),
);
)
.limit(limit);
// Filter out users who are already members of the space
if (suggestion.spaceId) {
userQuery = userQuery.where('users.id', 'not in', (eb) =>
eb
.selectFrom('spaceMembers')
.select('userId')
.where('spaceId', '=', suggestion.spaceId)
.where('userId', 'is not', null),
);
}
userQuery = userQuery.limit(limit);
users = await userQuery.execute();
}
if (suggestion.includeGroups) {
let groupQuery = this.db
groups = await this.db
.selectFrom('groups')
.select(['id', 'name', 'description'])
.where((eb) =>
@@ -187,21 +176,9 @@ export class SearchService {
sql`LOWER(f_unaccent(${`%${query}%`}))`,
),
)
.where('workspaceId', '=', workspaceId);
// Filter out groups that are already members of the space
if (suggestion.spaceId) {
groupQuery = groupQuery.where('groups.id', 'not in', (eb) =>
eb
.selectFrom('spaceMembers')
.select('groupId')
.where('spaceId', '=', suggestion.spaceId)
.where('groupId', 'is not', null),
);
}
groupQuery = groupQuery.limit(limit);
groups = await groupQuery.execute();
.where('workspaceId', '=', workspaceId)
.limit(limit)
.execute();
}
if (suggestion.includePages) {
@@ -279,4 +279,14 @@ export class SpaceMemberService {
): Promise<PaginationResult<Space>> {
return await this.spaceMemberRepo.getUserSpaces(userId, pagination);
}
async getAllWorkspaceSpaces(
workspaceId: string,
pagination: PaginationOptions,
): Promise<PaginationResult<Space>> {
return await this.spaceMemberRepo.getAllWorkspaceSpaces(
workspaceId,
pagination,
);
}
}
+15 -1
View File
@@ -34,6 +34,7 @@ import {
} from '../casl/interfaces/workspace-ability.type';
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
import { CreateSpaceDto } from './dto/create-space.dto';
import { SpaceRole, UserRole } from '../../common/helpers/types/permission';
@UseGuards(JwtAuthGuard)
@Controller('spaces')
@@ -52,7 +53,17 @@ export class SpaceController {
@Body()
pagination: PaginationOptions,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
if (pagination.includeAllSpaces) {
if (user.role !== UserRole.OWNER) {
throw new ForbiddenException('Only workspace owners view all spaces');
}
return this.spaceMemberService.getAllWorkspaceSpaces(
workspace.id,
pagination,
);
}
return this.spaceMemberService.getUserSpaces(user.id, pagination);
}
@@ -82,7 +93,10 @@ export class SpaceController {
space.id,
);
const userSpaceRole = findHighestUserSpaceRole(userSpaceRoles);
let userSpaceRole = findHighestUserSpaceRole(userSpaceRoles);
if (!userSpaceRole && user.role === UserRole.OWNER) {
userSpaceRole = SpaceRole.READER;
}
const membership = {
userId: user.id,
@@ -1,4 +1,5 @@
import {
IsBoolean,
IsNumber,
IsOptional,
IsPositive,
@@ -24,7 +25,8 @@ export class PaginationOptions {
@IsString()
query: string;
//for space endpoint workspace owners
@IsOptional()
@IsString()
groupId?: string;
@IsBoolean()
includeAllSpaces?: boolean;
}
@@ -263,4 +263,37 @@ export class SpaceMemberRepo {
return result;
}
async getAllWorkspaceSpaces(
workspaceId: string,
pagination: PaginationOptions,
) {
let query = this.db
.selectFrom('spaces')
.selectAll()
.select((eb) => [this.spaceRepo.withMemberCount(eb)])
.where('workspaceId', '=', workspaceId)
.orderBy('createdAt', 'asc');
if (pagination.query) {
query = query.where((eb) =>
eb(
sql`f_unaccent(name)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
).or(
sql`f_unaccent(description)`,
'ilike',
sql`f_unaccent(${'%' + pagination.query + '%'})`,
),
);
}
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
});
return result;
}
}
@@ -162,15 +162,6 @@ export class UserRepo {
);
}
if (pagination.groupId) {
query = query.where('users.id', 'not in', (eb) =>
eb
.selectFrom('groupUsers')
.select('userId')
.where('groupId', '=', pagination.groupId),
);
}
const result = executeWithPagination(query, {
page: pagination.page,
perPage: pagination.limit,
@@ -222,17 +222,40 @@ export function notionFormatter($: CheerioAPI, $root: Cheerio<any>) {
}
export function unwrapFromParagraph($: CheerioAPI, $node: Cheerio<any>) {
// find the nearest <p> or <a> ancestor
let $wrapper = $node.closest('p, a');
// Keep track of processed wrappers to avoid infinite loops
const processedWrappers = new Set<any>();
let $wrapper = $node.closest('p, a');
while ($wrapper.length) {
// if the wrapper has only our node inside, replace it entirely
if ($wrapper.contents().length === 1) {
const wrapperElement = $wrapper.get(0);
// If we've already processed this wrapper, break to avoid infinite loop
if (processedWrappers.has(wrapperElement)) {
break;
}
processedWrappers.add(wrapperElement);
// Check if the wrapper contains only whitespace and our target node
const hasOnlyTargetNode =
$wrapper.contents().filter((_, el) => {
const $el = $(el);
// Skip whitespace-only text nodes. NodeType 3 = text node
if (el.nodeType === 3 && !$el.text().trim()) {
return false;
}
// Return true if this is not our target node
return !$el.is($node) && !$node.is($el);
}).length === 0;
if (hasOnlyTargetNode) {
// Replace the wrapper entirely with our node
$wrapper.replaceWith($node);
} else {
// otherwise just move the node to before the wrapper
// Move the node to before the wrapper, preserving other content
$wrapper.before($node);
}
// look again for any new wrapper around $node
$wrapper = $node.closest('p, a');
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "docmost",
"homepage": "https://docmost.com",
"version": "0.23.1",
"version": "0.23.2",
"private": true,
"scripts": {
"build": "nx run-many -t build",