feat(page-permission): OSS-ify page-level permission module
Adds a native page-permission controller + service under apps/server/src/core/page/page-permission, wired into PageModule. LicenseCheckService now declares PAGE_PERMISSIONS and SHARING_CONTROLS as Acadenice OSS features so hasFeature() / resolveFeatures() always expose them regardless of EE plan, keeping useHasFeature() and the server-side guards consistent. tsconfig.build.json excludes vitest.config.ts from the Nest build. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9e686af2e3
commit
41ce6308fa
7 changed files with 526 additions and 7 deletions
|
|
@ -0,0 +1,67 @@
|
||||||
|
import {
|
||||||
|
ArrayNotEmpty,
|
||||||
|
IsArray,
|
||||||
|
IsIn,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
ValidateIf,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export const PAGE_PERMISSION_ROLES = ['reader', 'writer'] as const;
|
||||||
|
export type PagePermissionRoleValue = (typeof PAGE_PERMISSION_ROLES)[number];
|
||||||
|
|
||||||
|
export class PageIdBodyDto {
|
||||||
|
@IsString()
|
||||||
|
pageId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AddPagePermissionDto {
|
||||||
|
@IsString()
|
||||||
|
pageId: string;
|
||||||
|
|
||||||
|
@IsIn(PAGE_PERMISSION_ROLES as unknown as string[])
|
||||||
|
role: PagePermissionRoleValue;
|
||||||
|
|
||||||
|
@ValidateIf((o) => !o.groupIds || o.groupIds.length === 0)
|
||||||
|
@IsArray()
|
||||||
|
@ArrayNotEmpty()
|
||||||
|
@IsUUID('all', { each: true })
|
||||||
|
userIds?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('all', { each: true })
|
||||||
|
groupIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RemovePagePermissionDto {
|
||||||
|
@IsString()
|
||||||
|
pageId: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('all', { each: true })
|
||||||
|
userIds?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsUUID('all', { each: true })
|
||||||
|
groupIds?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdatePagePermissionDto {
|
||||||
|
@IsString()
|
||||||
|
pageId: string;
|
||||||
|
|
||||||
|
@IsIn(PAGE_PERMISSION_ROLES as unknown as string[])
|
||||||
|
role: PagePermissionRoleValue;
|
||||||
|
|
||||||
|
@ValidateIf((o) => !o.groupId)
|
||||||
|
@IsUUID()
|
||||||
|
userId?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
groupId?: string;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../../../common/guards/jwt-auth.guard';
|
||||||
|
import { AuthUser } from '../../../common/decorators/auth-user.decorator';
|
||||||
|
import { User } from '@docmost/db/types/entity.types';
|
||||||
|
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||||
|
import { PagePermissionService } from './page-permission.service';
|
||||||
|
import {
|
||||||
|
AddPagePermissionDto,
|
||||||
|
PageIdBodyDto,
|
||||||
|
RemovePagePermissionDto,
|
||||||
|
UpdatePagePermissionDto,
|
||||||
|
} from './dto/page-permission.dto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page-sharing endpoints — Outline-style granular access.
|
||||||
|
* Mounted under the same /pages prefix as PageController, so the existing
|
||||||
|
* client (`apps/client/src/ee/page-permission/services/page-permission-service.ts`)
|
||||||
|
* works without any URL changes.
|
||||||
|
*/
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('pages')
|
||||||
|
export class PagePermissionController {
|
||||||
|
constructor(private readonly pagePermissionService: PagePermissionService) {}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@Post('restrict')
|
||||||
|
async restrict(@Body() dto: PageIdBodyDto, @AuthUser() user: User) {
|
||||||
|
await this.pagePermissionService.restrictPage(dto.pageId, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@Post('remove-restriction')
|
||||||
|
async unrestrict(@Body() dto: PageIdBodyDto, @AuthUser() user: User) {
|
||||||
|
await this.pagePermissionService.unrestrictPage(dto.pageId, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@Post('add-permission')
|
||||||
|
async addPermission(
|
||||||
|
@Body() dto: AddPagePermissionDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
) {
|
||||||
|
await this.pagePermissionService.addPermissions(dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@Post('remove-permission')
|
||||||
|
async removePermission(
|
||||||
|
@Body() dto: RemovePagePermissionDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
) {
|
||||||
|
await this.pagePermissionService.removePermissions(dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.NO_CONTENT)
|
||||||
|
@Post('update-permission')
|
||||||
|
async updatePermission(
|
||||||
|
@Body() dto: UpdatePagePermissionDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
) {
|
||||||
|
await this.pagePermissionService.updatePermissionRole(dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('permissions')
|
||||||
|
async listPermissions(
|
||||||
|
@Body() body: PageIdBodyDto & { cursor?: string; query?: string },
|
||||||
|
@AuthUser() user: User,
|
||||||
|
) {
|
||||||
|
const pagination: PaginationOptions = {
|
||||||
|
limit: 50,
|
||||||
|
cursor: body.cursor,
|
||||||
|
query: body.query,
|
||||||
|
} as PaginationOptions;
|
||||||
|
return this.pagePermissionService.listPermissions(
|
||||||
|
body.pageId,
|
||||||
|
user,
|
||||||
|
pagination as any,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('permission-info')
|
||||||
|
async getRestrictionInfo(
|
||||||
|
@Body() dto: PageIdBodyDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
) {
|
||||||
|
return this.pagePermissionService.getRestrictionInfo(dto.pageId, user);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PagePermissionController } from './page-permission.controller';
|
||||||
|
import { PagePermissionService } from './page-permission.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [PagePermissionController],
|
||||||
|
providers: [PagePermissionService],
|
||||||
|
})
|
||||||
|
export class PagePermissionModule {}
|
||||||
|
|
@ -0,0 +1,316 @@
|
||||||
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
|
import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||||
|
import { PagePermissionRepo } from '@docmost/db/repos/page/page-permission.repo';
|
||||||
|
import { PageRepo } from '@docmost/db/repos/page/page.repo';
|
||||||
|
import { Page, User } from '@docmost/db/types/entity.types';
|
||||||
|
import { PageAccessService } from '../page-access/page-access.service';
|
||||||
|
import {
|
||||||
|
PageAccessLevel,
|
||||||
|
PagePermissionRole,
|
||||||
|
} from '../../../common/helpers/types/permission';
|
||||||
|
import {
|
||||||
|
AddPagePermissionDto,
|
||||||
|
RemovePagePermissionDto,
|
||||||
|
UpdatePagePermissionDto,
|
||||||
|
} from './dto/page-permission.dto';
|
||||||
|
|
||||||
|
interface RestrictionInfo {
|
||||||
|
restrictionId?: string;
|
||||||
|
hasDirectRestriction: boolean;
|
||||||
|
hasInheritedRestriction: boolean;
|
||||||
|
inheritedFrom?: { id: string; slugId: string; title: string };
|
||||||
|
userAccess: { canView: boolean; canEdit: boolean; canManage: boolean };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page-sharing service — Outline-style granular access on top of the native
|
||||||
|
* page_access / page_permissions tables. Reuses `PagePermissionRepo` for
|
||||||
|
* persistence and `PageAccessService` to authorize the caller.
|
||||||
|
*
|
||||||
|
* Authorization rule for managing permissions: caller must have edit access
|
||||||
|
* to the page (page-level if restricted, space-level otherwise). This matches
|
||||||
|
* Outline's "Editors can share" behavior.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PagePermissionService {
|
||||||
|
constructor(
|
||||||
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
|
private readonly pageRepo: PageRepo,
|
||||||
|
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||||
|
private readonly pageAccessService: PageAccessService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async loadPageOrThrow(pageId: string): Promise<Page> {
|
||||||
|
const page = await this.pageRepo.findById(pageId);
|
||||||
|
if (!page) throw new NotFoundException('Page not found');
|
||||||
|
return page;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restrict the page — promote its access level to RESTRICTED and seed the
|
||||||
|
* caller as a writer so they don't lock themselves out. Idempotent.
|
||||||
|
*/
|
||||||
|
async restrictPage(pageId: string, user: User): Promise<void> {
|
||||||
|
const page = await this.loadPageOrThrow(pageId);
|
||||||
|
await this.pageAccessService.validateCanEdit(page, user);
|
||||||
|
|
||||||
|
await this.db.transaction().execute(async (trx) => {
|
||||||
|
const existing = await this.pagePermissionRepo.findPageAccessByPageId(
|
||||||
|
page.id,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
|
||||||
|
let pageAccess = existing;
|
||||||
|
if (!pageAccess) {
|
||||||
|
pageAccess = await this.pagePermissionRepo.insertPageAccess(
|
||||||
|
{
|
||||||
|
pageId: page.id,
|
||||||
|
workspaceId: page.workspaceId,
|
||||||
|
spaceId: page.spaceId,
|
||||||
|
accessLevel: PageAccessLevel.RESTRICTED,
|
||||||
|
creatorId: user.id,
|
||||||
|
},
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed the caller as a writer so they retain access after restriction.
|
||||||
|
const existingPerm = await this.pagePermissionRepo.findPagePermissionByUserId(
|
||||||
|
pageAccess.id,
|
||||||
|
user.id,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
if (!existingPerm) {
|
||||||
|
await this.pagePermissionRepo.insertPagePermissions(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
pageAccessId: pageAccess.id,
|
||||||
|
userId: user.id,
|
||||||
|
role: PagePermissionRole.WRITER,
|
||||||
|
addedById: user.id,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async unrestrictPage(pageId: string, user: User): Promise<void> {
|
||||||
|
const page = await this.loadPageOrThrow(pageId);
|
||||||
|
await this.pageAccessService.validateCanEdit(page, user);
|
||||||
|
// ON DELETE CASCADE on page_permissions handles cleanup.
|
||||||
|
await this.pagePermissionRepo.deletePageAccess(page.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async addPermissions(dto: AddPagePermissionDto, user: User): Promise<void> {
|
||||||
|
const page = await this.loadPageOrThrow(dto.pageId);
|
||||||
|
await this.pageAccessService.validateCanEdit(page, user);
|
||||||
|
|
||||||
|
const userIds = dto.userIds ?? [];
|
||||||
|
const groupIds = dto.groupIds ?? [];
|
||||||
|
if (userIds.length === 0 && groupIds.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.db.transaction().execute(async (trx) => {
|
||||||
|
let pageAccess = await this.pagePermissionRepo.findPageAccessByPageId(
|
||||||
|
page.id,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
// Auto-promote to restricted on first add — matches the Notion-like
|
||||||
|
// affordance "share with X = make it restricted".
|
||||||
|
if (!pageAccess) {
|
||||||
|
pageAccess = await this.pagePermissionRepo.insertPageAccess(
|
||||||
|
{
|
||||||
|
pageId: page.id,
|
||||||
|
workspaceId: page.workspaceId,
|
||||||
|
spaceId: page.spaceId,
|
||||||
|
accessLevel: PageAccessLevel.RESTRICTED,
|
||||||
|
creatorId: user.id,
|
||||||
|
},
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newRows = [
|
||||||
|
...userIds.map((uid) => ({
|
||||||
|
pageAccessId: pageAccess!.id,
|
||||||
|
userId: uid,
|
||||||
|
role: dto.role,
|
||||||
|
addedById: user.id,
|
||||||
|
})),
|
||||||
|
...groupIds.map((gid) => ({
|
||||||
|
pageAccessId: pageAccess!.id,
|
||||||
|
groupId: gid,
|
||||||
|
role: dto.role,
|
||||||
|
addedById: user.id,
|
||||||
|
})),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Drop any existing rows for the same (pageAccess, user/group) so the
|
||||||
|
// insert is idempotent and the role is updated on re-add.
|
||||||
|
if (userIds.length > 0) {
|
||||||
|
await this.pagePermissionRepo.deletePagePermissionsByUserIds(
|
||||||
|
pageAccess!.id,
|
||||||
|
userIds,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (groupIds.length > 0) {
|
||||||
|
await this.pagePermissionRepo.deletePagePermissionsByGroupIds(
|
||||||
|
pageAccess!.id,
|
||||||
|
groupIds,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.pagePermissionRepo.insertPagePermissions(newRows, trx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async removePermissions(
|
||||||
|
dto: RemovePagePermissionDto,
|
||||||
|
user: User,
|
||||||
|
): Promise<void> {
|
||||||
|
const page = await this.loadPageOrThrow(dto.pageId);
|
||||||
|
await this.pageAccessService.validateCanEdit(page, user);
|
||||||
|
|
||||||
|
const pageAccess = await this.pagePermissionRepo.findPageAccessByPageId(
|
||||||
|
page.id,
|
||||||
|
);
|
||||||
|
if (!pageAccess) return;
|
||||||
|
|
||||||
|
if (dto.userIds && dto.userIds.length > 0) {
|
||||||
|
await this.pagePermissionRepo.deletePagePermissionsByUserIds(
|
||||||
|
pageAccess.id,
|
||||||
|
dto.userIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (dto.groupIds && dto.groupIds.length > 0) {
|
||||||
|
await this.pagePermissionRepo.deletePagePermissionsByGroupIds(
|
||||||
|
pageAccess.id,
|
||||||
|
dto.groupIds,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePermissionRole(
|
||||||
|
dto: UpdatePagePermissionDto,
|
||||||
|
user: User,
|
||||||
|
): Promise<void> {
|
||||||
|
const page = await this.loadPageOrThrow(dto.pageId);
|
||||||
|
await this.pageAccessService.validateCanEdit(page, user);
|
||||||
|
|
||||||
|
const pageAccess = await this.pagePermissionRepo.findPageAccessByPageId(
|
||||||
|
page.id,
|
||||||
|
);
|
||||||
|
if (!pageAccess) {
|
||||||
|
throw new NotFoundException('Page is not restricted');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!dto.userId && !dto.groupId) {
|
||||||
|
throw new ForbiddenException('userId or groupId required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Guard against demoting the last writer to reader (locks out everyone).
|
||||||
|
if (dto.role === PagePermissionRole.READER) {
|
||||||
|
const writers = await this.pagePermissionRepo.countWritersByPageAccessId(
|
||||||
|
pageAccess.id,
|
||||||
|
);
|
||||||
|
const target = dto.userId
|
||||||
|
? await this.pagePermissionRepo.findPagePermissionByUserId(
|
||||||
|
pageAccess.id,
|
||||||
|
dto.userId,
|
||||||
|
)
|
||||||
|
: await this.pagePermissionRepo.findPagePermissionByGroupId(
|
||||||
|
pageAccess.id,
|
||||||
|
dto.groupId!,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
writers <= 1 &&
|
||||||
|
target?.role === PagePermissionRole.WRITER
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException(
|
||||||
|
'Cannot demote the last writer of a restricted page',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.pagePermissionRepo.updatePagePermissionRole(
|
||||||
|
pageAccess.id,
|
||||||
|
dto.role,
|
||||||
|
{ userId: dto.userId, groupId: dto.groupId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async listPermissions(
|
||||||
|
pageId: string,
|
||||||
|
user: User,
|
||||||
|
pagination: { limit: number; cursor?: string; query?: string },
|
||||||
|
) {
|
||||||
|
const page = await this.loadPageOrThrow(pageId);
|
||||||
|
// Only people who can view the page can see who has access.
|
||||||
|
await this.pageAccessService.validateCanView(page, user);
|
||||||
|
|
||||||
|
const pageAccess = await this.pagePermissionRepo.findPageAccessByPageId(
|
||||||
|
page.id,
|
||||||
|
);
|
||||||
|
if (!pageAccess) {
|
||||||
|
return { items: [], meta: { hasNextPage: false, hasPrevPage: false } };
|
||||||
|
}
|
||||||
|
return this.pagePermissionRepo.getPagePermissionsPaginated(
|
||||||
|
pageAccess.id,
|
||||||
|
pagination as any,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getRestrictionInfo(
|
||||||
|
pageId: string,
|
||||||
|
user: User,
|
||||||
|
): Promise<RestrictionInfo> {
|
||||||
|
const page = await this.loadPageOrThrow(pageId);
|
||||||
|
await this.pageAccessService.validateCanView(page, user);
|
||||||
|
|
||||||
|
const direct = await this.pagePermissionRepo.findPageAccessByPageId(
|
||||||
|
page.id,
|
||||||
|
);
|
||||||
|
const ancestor = await this.pagePermissionRepo.findRestrictedAncestor(
|
||||||
|
page.id,
|
||||||
|
);
|
||||||
|
// findRestrictedAncestor returns the SAME page when the page itself is
|
||||||
|
// restricted (depth=0). We only consider it "inherited" when depth > 0.
|
||||||
|
const isInherited =
|
||||||
|
!!ancestor && ancestor.depth > 0 && ancestor.pageId !== page.id;
|
||||||
|
const inheritedPage = isInherited
|
||||||
|
? await this.pageRepo.findById(ancestor!.pageId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const { hasAnyRestriction, canAccess, canEdit } =
|
||||||
|
await this.pagePermissionRepo.canUserEditPage(user.id, page.id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
restrictionId: direct?.id,
|
||||||
|
hasDirectRestriction: !!direct,
|
||||||
|
hasInheritedRestriction: isInherited,
|
||||||
|
inheritedFrom: inheritedPage
|
||||||
|
? {
|
||||||
|
id: inheritedPage.id,
|
||||||
|
slugId: inheritedPage.slugId,
|
||||||
|
title: inheritedPage.title ?? '',
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
userAccess: {
|
||||||
|
canView: hasAnyRestriction ? canAccess : true,
|
||||||
|
canEdit: hasAnyRestriction ? canEdit : true,
|
||||||
|
// For now: anyone who can edit can manage permissions. Mirrors the
|
||||||
|
// service-level rule used by addPermissions/restrict.
|
||||||
|
canManage: hasAnyRestriction ? canEdit : true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,10 +6,17 @@ import { TrashCleanupService } from './services/trash-cleanup.service';
|
||||||
import { StorageModule } from '../../integrations/storage/storage.module';
|
import { StorageModule } from '../../integrations/storage/storage.module';
|
||||||
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
||||||
import { WatcherModule } from '../watcher/watcher.module';
|
import { WatcherModule } from '../watcher/watcher.module';
|
||||||
|
import { PagePermissionController } from './page-permission/page-permission.controller';
|
||||||
|
import { PagePermissionService } from './page-permission/page-permission.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
controllers: [PageController],
|
controllers: [PageController, PagePermissionController],
|
||||||
providers: [PageService, PageHistoryService, TrashCleanupService],
|
providers: [
|
||||||
|
PageService,
|
||||||
|
PageHistoryService,
|
||||||
|
TrashCleanupService,
|
||||||
|
PagePermissionService,
|
||||||
|
],
|
||||||
exports: [PageService, PageHistoryService],
|
exports: [PageService, PageHistoryService],
|
||||||
imports: [StorageModule, CollaborationModule, WatcherModule],
|
imports: [StorageModule, CollaborationModule, WatcherModule],
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,21 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ModuleRef } from '@nestjs/core';
|
import { ModuleRef } from '@nestjs/core';
|
||||||
import { EnvironmentService } from './environment.service';
|
import { EnvironmentService } from './environment.service';
|
||||||
|
import { Feature } from '../../common/features';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Acadenice — features OSS-ifiees inconditionnellement (open-source).
|
||||||
|
* Toujours injectees dans la reponse `resolveFeatures` quel que soit le plan
|
||||||
|
* ou la license. Permet de garder le pipeline `useHasFeature(...)` cote front
|
||||||
|
* et les guards cote serveur sans toucher au flux EE.
|
||||||
|
*
|
||||||
|
* Why: reproduit la logique du Patch 020 (branding) et R4.5 (audit, api-keys)
|
||||||
|
* appliques par overrides UI directs. Centralise ici pour rester DRY.
|
||||||
|
*/
|
||||||
|
const ACADENICE_OSS_FEATURES: ReadonlyArray<string> = [
|
||||||
|
Feature.PAGE_PERMISSIONS,
|
||||||
|
Feature.SHARING_CONTROLS,
|
||||||
|
];
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LicenseCheckService {
|
export class LicenseCheckService {
|
||||||
|
|
@ -27,6 +42,9 @@ export class LicenseCheckService {
|
||||||
}
|
}
|
||||||
|
|
||||||
hasFeature(licenseKey: string, feature: string, plan?: string): boolean {
|
hasFeature(licenseKey: string, feature: string, plan?: string): boolean {
|
||||||
|
if (ACADENICE_OSS_FEATURES.includes(feature)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
if (this.environmentService.isCloud()) {
|
if (this.environmentService.isCloud()) {
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
|
|
@ -63,17 +81,22 @@ export class LicenseCheckService {
|
||||||
}
|
}
|
||||||
|
|
||||||
resolveFeatures(licenseKey: string, plan: string): string[] {
|
resolveFeatures(licenseKey: string, plan: string): string[] {
|
||||||
|
let base: string[];
|
||||||
if (this.environmentService.isCloud()) {
|
if (this.environmentService.isCloud()) {
|
||||||
try {
|
try {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
const { getFeaturesForCloudPlan } = require('../../ee/licence/feature-registry');
|
const { getFeaturesForCloudPlan } = require('../../ee/licence/feature-registry');
|
||||||
return [...getFeaturesForCloudPlan(plan)];
|
base = [...getFeaturesForCloudPlan(plan)];
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
base = [];
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
base = this.getFeatures(licenseKey);
|
||||||
}
|
}
|
||||||
|
// Inject Acadenice OSS-ified features unconditionally.
|
||||||
return this.getFeatures(licenseKey);
|
const set = new Set(base);
|
||||||
|
for (const f of ACADENICE_OSS_FEATURES) set.add(f);
|
||||||
|
return [...set];
|
||||||
}
|
}
|
||||||
|
|
||||||
resolveTier(licenseKey: string, plan: string): string {
|
resolveTier(licenseKey: string, plan: string): string {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"exclude": ["node_modules",
|
"exclude": ["node_modules",
|
||||||
"test", "dist", "**/*spec.ts"]
|
"test", "dist", "**/*spec.ts", "vitest.config.ts"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue