diff --git a/apps/client/src/features/space/components/space-list.tsx b/apps/client/src/features/space/components/space-list.tsx index 065bdde7..3e155700 100644 --- a/apps/client/src/features/space/components/space-list.tsx +++ b/apps/client/src/features/space/components/space-list.tsx @@ -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(null); diff --git a/apps/client/src/hooks/use-is-cloud-ee.tsx b/apps/client/src/hooks/use-is-cloud-ee.tsx index 148fe395..ff6fbbd0 100644 --- a/apps/client/src/hooks/use-is-cloud-ee.tsx +++ b/apps/client/src/hooks/use-is-cloud-ee.tsx @@ -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; -}; \ No newline at end of file +}; + +export const useIsEEOnly = () => { + const { hasLicenseKey } = useLicense(); + const { isBusiness } = usePlan(); + return (isCloud() && isBusiness) || !!hasLicenseKey; +}; diff --git a/apps/client/src/lib/types.ts b/apps/client/src/lib/types.ts index fd0e3212..653b145d 100644 --- a/apps/client/src/lib/types.ts +++ b/apps/client/src/lib/types.ts @@ -2,6 +2,7 @@ export interface QueryParams { query?: string; page?: number; limit?: number; + includeAllSpaces?: boolean; } export enum UserRole { diff --git a/apps/server/src/collaboration/extensions/authentication.extension.ts b/apps/server/src/collaboration/extensions/authentication.extension.ts index 1a42bd97..c9817b82 100644 --- a/apps/server/src/collaboration/extensions/authentication.extension.ts +++ b/apps/server/src/collaboration/extensions/authentication.extension.ts @@ -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(); } diff --git a/apps/server/src/core/casl/abilities/space-ability.factory.ts b/apps/server/src/core/casl/abilities/space-ability.factory.ts index 53a57a0c..57dd36e0 100644 --- a/apps/server/src/core/casl/abilities/space-ability.factory.ts +++ b/apps/server/src/core/casl/abilities/space-ability.factory.ts @@ -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>( 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>( 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>( + createMongoAbility, + ); + can(SpaceCaslAction.Manage, SpaceCaslSubject.Settings); + can(SpaceCaslAction.Manage, SpaceCaslSubject.Member); can(SpaceCaslAction.Read, SpaceCaslSubject.Page); can(SpaceCaslAction.Read, SpaceCaslSubject.Share); return build(); diff --git a/apps/server/src/core/space/services/space-member.service.ts b/apps/server/src/core/space/services/space-member.service.ts index 16ab7c65..342827b0 100644 --- a/apps/server/src/core/space/services/space-member.service.ts +++ b/apps/server/src/core/space/services/space-member.service.ts @@ -279,4 +279,14 @@ export class SpaceMemberService { ): Promise> { return await this.spaceMemberRepo.getUserSpaces(userId, pagination); } + + async getAllWorkspaceSpaces( + workspaceId: string, + pagination: PaginationOptions, + ): Promise> { + return await this.spaceMemberRepo.getAllWorkspaceSpaces( + workspaceId, + pagination, + ); + } } diff --git a/apps/server/src/core/space/space.controller.ts b/apps/server/src/core/space/space.controller.ts index 67cc2e03..b9d86805 100644 --- a/apps/server/src/core/space/space.controller.ts +++ b/apps/server/src/core/space/space.controller.ts @@ -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, diff --git a/apps/server/src/database/pagination/pagination-options.ts b/apps/server/src/database/pagination/pagination-options.ts index e0481910..790809ef 100644 --- a/apps/server/src/database/pagination/pagination-options.ts +++ b/apps/server/src/database/pagination/pagination-options.ts @@ -1,4 +1,5 @@ import { + IsBoolean, IsNumber, IsOptional, IsPositive, @@ -23,4 +24,9 @@ export class PaginationOptions { @IsOptional() @IsString() query: string; + + //for space endpoint workspace owners + @IsOptional() + @IsBoolean() + includeAllSpaces?: boolean; } diff --git a/apps/server/src/database/repos/space/space-member.repo.ts b/apps/server/src/database/repos/space/space-member.repo.ts index 0850c5e1..360ab80b 100644 --- a/apps/server/src/database/repos/space/space-member.repo.ts +++ b/apps/server/src/database/repos/space/space-member.repo.ts @@ -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; + } }