Compare commits

...

1 Commits

Author SHA1 Message Date
Philipinho 3b4a02e94a feat: give workspace owners global space management permission 2025-09-19 22:00:09 +01:00
9 changed files with 130 additions and 14 deletions
@@ -8,11 +8,20 @@ import { useTranslation } from "react-i18next";
import Paginate from "@/components/common/paginate.tsx"; import Paginate from "@/components/common/paginate.tsx";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx"; import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
import { AvatarIconType } from "@/features/attachments/types/attachment.types.ts"; 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() { export default function SpaceList() {
const { t } = useTranslation(); const { t } = useTranslation();
const [page, setPage] = useState(1); 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 [opened, { open, close }] = useDisclosure(false);
const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null); const [selectedSpaceId, setSelectedSpaceId] = useState<string>(null);
@@ -1,7 +1,16 @@
import { isCloud } from "@/lib/config"; import { isCloud } from "@/lib/config";
import { useLicense } from "@/ee/hooks/use-license"; 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 = () => { export const useIsCloudEE = () => {
const { hasLicenseKey } = useLicense(); const { hasLicenseKey } = useLicense();
return isCloud() || !!hasLicenseKey; return isCloud() || !!hasLicenseKey;
}; };
export const useIsEEOnly = () => {
const { hasLicenseKey } = useLicense();
const { isBusiness } = usePlan();
return (isCloud() && isBusiness) || !!hasLicenseKey;
};
+1
View File
@@ -2,6 +2,7 @@ export interface QueryParams {
query?: string; query?: string;
page?: number; page?: number;
limit?: number; limit?: number;
includeAllSpaces?: boolean;
} }
export enum UserRole { export enum UserRole {
@@ -10,7 +10,7 @@ import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { PageRepo } from '@docmost/db/repos/page/page.repo'; import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { findHighestUserSpaceRole } from '@docmost/db/repos/space/utils'; 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 { getPageId } from '../collaboration.util';
import { JwtCollabPayload, JwtType } from '../../core/auth/dto/jwt-payload'; import { JwtCollabPayload, JwtType } from '../../core/auth/dto/jwt-payload';
@@ -63,7 +63,10 @@ export class AuthenticationExtension implements Extension {
const userSpaceRole = findHighestUserSpaceRole(userSpaceRoles); 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}`); this.logger.warn(`User not authorized to access page: ${pageId}`);
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
@@ -4,7 +4,7 @@ import {
createMongoAbility, createMongoAbility,
MongoAbility, MongoAbility,
} from '@casl/ability'; } 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 { User } from '@docmost/db/types/entity.types';
import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo'; import { SpaceMemberRepo } from '@docmost/db/repos/space/space-member.repo';
import { import {
@@ -25,13 +25,17 @@ export default class SpaceAbilityFactory {
const userSpaceRole = findHighestUserSpaceRole(userSpaceRoles); const userSpaceRole = findHighestUserSpaceRole(userSpaceRoles);
if (!userSpaceRole && user.role === UserRole.OWNER) {
return buildWorkspaceOwnerAbility();
}
switch (userSpaceRole) { switch (userSpaceRole) {
case SpaceRole.ADMIN: case SpaceRole.ADMIN:
return buildSpaceAdminAbility(); return buildSpaceAdminAbility();
case SpaceRole.WRITER: case SpaceRole.WRITER:
return buildSpaceWriterAbility(); return buildSpaceWriterAbility(user.role);
case SpaceRole.READER: case SpaceRole.READER:
return buildSpaceReaderAbility(); return buildSpaceReaderAbility(user.role);
default: default:
throw new NotFoundException('Space permissions not found'); throw new NotFoundException('Space permissions not found');
} }
@@ -49,23 +53,50 @@ function buildSpaceAdminAbility() {
return build(); return build();
} }
function buildSpaceWriterAbility() { function buildSpaceWriterAbility(workspaceRole?: string) {
const { can, build } = new AbilityBuilder<MongoAbility<ISpaceAbility>>( const { can, build } = new AbilityBuilder<MongoAbility<ISpaceAbility>>(
createMongoAbility, 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.Page);
can(SpaceCaslAction.Manage, SpaceCaslSubject.Share); can(SpaceCaslAction.Manage, SpaceCaslSubject.Share);
return build(); return build();
} }
function buildSpaceReaderAbility() { function buildSpaceReaderAbility(workspaceRole?: string) {
const { can, build } = new AbilityBuilder<MongoAbility<ISpaceAbility>>( const { can, build } = new AbilityBuilder<MongoAbility<ISpaceAbility>>(
createMongoAbility, 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.Page);
can(SpaceCaslAction.Read, SpaceCaslSubject.Share); can(SpaceCaslAction.Read, SpaceCaslSubject.Share);
return build(); return build();
@@ -279,4 +279,14 @@ export class SpaceMemberService {
): Promise<PaginationResult<Space>> { ): Promise<PaginationResult<Space>> {
return await this.spaceMemberRepo.getUserSpaces(userId, pagination); 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'; } from '../casl/interfaces/workspace-ability.type';
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory'; import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
import { CreateSpaceDto } from './dto/create-space.dto'; import { CreateSpaceDto } from './dto/create-space.dto';
import { SpaceRole, UserRole } from '../../common/helpers/types/permission';
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@Controller('spaces') @Controller('spaces')
@@ -52,7 +53,17 @@ export class SpaceController {
@Body() @Body()
pagination: PaginationOptions, pagination: PaginationOptions,
@AuthUser() user: User, @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); return this.spaceMemberService.getUserSpaces(user.id, pagination);
} }
@@ -82,7 +93,10 @@ export class SpaceController {
space.id, space.id,
); );
const userSpaceRole = findHighestUserSpaceRole(userSpaceRoles); let userSpaceRole = findHighestUserSpaceRole(userSpaceRoles);
if (!userSpaceRole && user.role === UserRole.OWNER) {
userSpaceRole = SpaceRole.READER;
}
const membership = { const membership = {
userId: user.id, userId: user.id,
@@ -1,4 +1,5 @@
import { import {
IsBoolean,
IsNumber, IsNumber,
IsOptional, IsOptional,
IsPositive, IsPositive,
@@ -23,4 +24,9 @@ export class PaginationOptions {
@IsOptional() @IsOptional()
@IsString() @IsString()
query: string; query: string;
//for space endpoint workspace owners
@IsOptional()
@IsBoolean()
includeAllSpaces?: boolean;
} }
@@ -263,4 +263,37 @@ export class SpaceMemberRepo {
return result; 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;
}
} }