mirror of
https://github.com/docmost/docmost.git
synced 2025-11-13 05:02:36 +10:00
feat: delete workspace member (#987)
* add delete user endpoint (server) * delete user (UI) * prevent token generation * more checks
This commit is contained in:
@ -354,6 +354,9 @@
|
|||||||
"Character count: {{characterCount}}": "Character count: {{characterCount}}",
|
"Character count: {{characterCount}}": "Character count: {{characterCount}}",
|
||||||
"New update": "New update",
|
"New update": "New update",
|
||||||
"{{latestVersion}} is available": "{{latestVersion}} is available",
|
"{{latestVersion}} is available": "{{latestVersion}} is available",
|
||||||
|
"Delete member": "Delete member",
|
||||||
|
"Member deleted successfully": "Member deleted successfully",
|
||||||
|
"Are you sure you want to delete this workspace member? This action is irreversible.": "Are you sure you want to delete this workspace member? This action is irreversible."
|
||||||
"Move": "Move",
|
"Move": "Move",
|
||||||
"Move page": "Move page",
|
"Move page": "Move page",
|
||||||
"Move page to a different space.": "Move page to a different space.",
|
"Move page to a different space.": "Move page to a different space.",
|
||||||
|
|||||||
@ -0,0 +1,66 @@
|
|||||||
|
import { Menu, ActionIcon, Text } from "@mantine/core";
|
||||||
|
import React from "react";
|
||||||
|
import { IconDots, IconTrash } from "@tabler/icons-react";
|
||||||
|
import { modals } from "@mantine/modals";
|
||||||
|
import { useDeleteWorkspaceMemberMutation } from "@/features/workspace/queries/workspace-query.ts";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import useUserRole from "@/hooks/use-user-role.tsx";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
export default function MemberActionMenu({ userId }: Props) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const deleteWorkspaceMemberMutation = useDeleteWorkspaceMemberMutation();
|
||||||
|
const { isAdmin } = useUserRole();
|
||||||
|
|
||||||
|
const onRevoke = async () => {
|
||||||
|
await deleteWorkspaceMemberMutation.mutateAsync({ userId });
|
||||||
|
};
|
||||||
|
|
||||||
|
const openRevokeModal = () =>
|
||||||
|
modals.openConfirmModal({
|
||||||
|
title: t("Delete member"),
|
||||||
|
children: (
|
||||||
|
<Text size="sm">
|
||||||
|
{t(
|
||||||
|
"Are you sure you want to delete this workspace member? This action is irreversible.",
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
),
|
||||||
|
centered: true,
|
||||||
|
labels: { confirm: t("Delete"), cancel: t("Don't") },
|
||||||
|
confirmProps: { color: "red" },
|
||||||
|
onConfirm: onRevoke,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Menu
|
||||||
|
shadow="xl"
|
||||||
|
position="bottom-end"
|
||||||
|
offset={20}
|
||||||
|
width={200}
|
||||||
|
withArrow
|
||||||
|
arrowPosition="center"
|
||||||
|
>
|
||||||
|
<Menu.Target>
|
||||||
|
<ActionIcon variant="subtle" c="gray">
|
||||||
|
<IconDots size={20} stroke={2} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Menu.Target>
|
||||||
|
|
||||||
|
<Menu.Dropdown>
|
||||||
|
<Menu.Item
|
||||||
|
c="red"
|
||||||
|
onClick={openRevokeModal}
|
||||||
|
leftSection={<IconTrash size={16} />}
|
||||||
|
disabled={!isAdmin}
|
||||||
|
>
|
||||||
|
{t("Delete member")}
|
||||||
|
</Menu.Item>
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -17,6 +17,8 @@ import Paginate from "@/components/common/paginate.tsx";
|
|||||||
import { SearchInput } from "@/components/common/search-input.tsx";
|
import { SearchInput } from "@/components/common/search-input.tsx";
|
||||||
import NoTableResults from "@/components/common/no-table-results.tsx";
|
import NoTableResults from "@/components/common/no-table-results.tsx";
|
||||||
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
|
import { usePaginateAndSearch } from "@/hooks/use-paginate-and-search.tsx";
|
||||||
|
import InviteActionMenu from "@/features/workspace/components/members/components/invite-action-menu.tsx";
|
||||||
|
import MemberActionMenu from "@/features/workspace/components/members/components/members-action-menu.tsx";
|
||||||
|
|
||||||
export default function WorkspaceMembersTable() {
|
export default function WorkspaceMembersTable() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -96,6 +98,9 @@ export default function WorkspaceMembersTable() {
|
|||||||
disabled={!isAdmin}
|
disabled={!isAdmin}
|
||||||
/>
|
/>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
|
<Table.Td>
|
||||||
|
{isAdmin && <MemberActionMenu userId={user.id} />}
|
||||||
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import {
|
|||||||
getWorkspace,
|
getWorkspace,
|
||||||
getWorkspacePublicData,
|
getWorkspacePublicData,
|
||||||
getAppVersion,
|
getAppVersion,
|
||||||
|
deleteWorkspaceMember,
|
||||||
} from "@/features/workspace/services/workspace-service";
|
} from "@/features/workspace/services/workspace-service";
|
||||||
import { IPagination, QueryParams } from "@/lib/types.ts";
|
import { IPagination, QueryParams } from "@/lib/types.ts";
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
@ -56,6 +57,30 @@ export function useWorkspaceMembersQuery(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDeleteWorkspaceMemberMutation() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation<
|
||||||
|
void,
|
||||||
|
Error,
|
||||||
|
{
|
||||||
|
userId: string;
|
||||||
|
}
|
||||||
|
>({
|
||||||
|
mutationFn: (data) => deleteWorkspaceMember(data),
|
||||||
|
onSuccess: (data, variables) => {
|
||||||
|
notifications.show({ message: "Member deleted successfully" });
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["workspaceMembers"],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const errorMessage = error["response"]?.data?.message;
|
||||||
|
notifications.show({ message: errorMessage, color: "red" });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function useChangeMemberRoleMutation() {
|
export function useChangeMemberRoleMutation() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
|||||||
@ -36,6 +36,12 @@ export async function getWorkspaceMembers(
|
|||||||
return req.data;
|
return req.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deleteWorkspaceMember(data: {
|
||||||
|
userId: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
await api.post("/workspace/members/delete", data);
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateWorkspace(data: Partial<IWorkspace>) {
|
export async function updateWorkspace(data: Partial<IWorkspace>) {
|
||||||
const req = await api.post<IWorkspace>("/workspace/update", data);
|
const req = await api.post<IWorkspace>("/workspace/update", data);
|
||||||
return req.data;
|
return req.data;
|
||||||
|
|||||||
@ -17,6 +17,9 @@ export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
|
|||||||
if (job.name === QueueJob.DELETE_SPACE_ATTACHMENTS) {
|
if (job.name === QueueJob.DELETE_SPACE_ATTACHMENTS) {
|
||||||
await this.attachmentService.handleDeleteSpaceAttachments(job.data.id);
|
await this.attachmentService.handleDeleteSpaceAttachments(job.data.id);
|
||||||
}
|
}
|
||||||
|
if (job.name === QueueJob.DELETE_USER_AVATARS) {
|
||||||
|
await this.attachmentService.handleDeleteUserAvatars(job.data.id);
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -282,9 +282,41 @@ export class AttachmentService {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (failedDeletions.length === attachments.length) {
|
if (failedDeletions.length === attachments.length) {
|
||||||
throw new Error(`Failed to delete any attachments for spaceId: ${spaceId}`);
|
throw new Error(
|
||||||
|
`Failed to delete any attachments for spaceId: ${spaceId}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async handleDeleteUserAvatars(userId: string) {
|
||||||
|
try {
|
||||||
|
const userAvatars = await this.db
|
||||||
|
.selectFrom('attachments')
|
||||||
|
.select(['id', 'filePath'])
|
||||||
|
.where('creatorId', '=', userId)
|
||||||
|
.where('type', '=', AttachmentType.Avatar)
|
||||||
|
.execute();
|
||||||
|
|
||||||
|
if (!userAvatars || userAvatars.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
userAvatars.map(async (attachment) => {
|
||||||
|
try {
|
||||||
|
await this.storageService.delete(attachment.filePath);
|
||||||
|
await this.attachmentRepo.deleteAttachmentById(attachment.id);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.log(
|
||||||
|
`DeleteUserAvatar: failed to delete user avatar ${attachment.id}:`,
|
||||||
|
err,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -43,18 +43,16 @@ export class AuthService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async login(loginDto: LoginDto, workspaceId: string) {
|
async login(loginDto: LoginDto, workspaceId: string) {
|
||||||
const user = await this.userRepo.findByEmail(
|
const user = await this.userRepo.findByEmail(loginDto.email, workspaceId, {
|
||||||
loginDto.email,
|
includePassword: true,
|
||||||
workspaceId,
|
});
|
||||||
{
|
|
||||||
includePassword: true
|
const isPasswordMatch = await comparePasswordHash(
|
||||||
}
|
loginDto.password,
|
||||||
|
user.password,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
if (!user || !isPasswordMatch || user.deletedAt) {
|
||||||
!user ||
|
|
||||||
!(await comparePasswordHash(loginDto.password, user.password))
|
|
||||||
) {
|
|
||||||
throw new UnauthorizedException('email or password does not match');
|
throw new UnauthorizedException('email or password does not match');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -86,7 +84,7 @@ export class AuthService {
|
|||||||
includePassword: true,
|
includePassword: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!user) {
|
if (!user || user.deletedAt) {
|
||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException('User not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -125,7 +123,7 @@ export class AuthService {
|
|||||||
workspace.id,
|
workspace.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!user) {
|
if (!user || user.deletedAt) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -168,7 +166,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const user = await this.userRepo.findById(userToken.userId, workspaceId);
|
const user = await this.userRepo.findById(userToken.userId, workspaceId);
|
||||||
if (!user) {
|
if (!user || user.deletedAt) {
|
||||||
throw new NotFoundException('User not found');
|
throw new NotFoundException('User not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,8 @@
|
|||||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
import {
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
UnauthorizedException,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||||
import {
|
import {
|
||||||
@ -17,6 +21,10 @@ export class TokenService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
async generateAccessToken(user: User): Promise<string> {
|
async generateAccessToken(user: User): Promise<string> {
|
||||||
|
if (user.deletedAt) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
|
||||||
const payload: JwtPayload = {
|
const payload: JwtPayload = {
|
||||||
sub: user.id,
|
sub: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
|||||||
@ -1,9 +1,4 @@
|
|||||||
import {
|
import { Injectable, Logger, UnauthorizedException } from '@nestjs/common';
|
||||||
BadRequestException,
|
|
||||||
Injectable,
|
|
||||||
Logger,
|
|
||||||
UnauthorizedException,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { Strategy } from 'passport-jwt';
|
import { Strategy } from 'passport-jwt';
|
||||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||||
@ -47,7 +42,7 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
|||||||
}
|
}
|
||||||
const user = await this.userRepo.findById(payload.sub, payload.workspaceId);
|
const user = await this.userRepo.findById(payload.sub, payload.workspaceId);
|
||||||
|
|
||||||
if (!user) {
|
if (!user || user.deletedAt) {
|
||||||
throw new UnauthorizedException();
|
throw new UnauthorizedException();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -84,6 +84,7 @@ export class SearchService {
|
|||||||
.select(['id', 'name', 'avatarUrl'])
|
.select(['id', 'name', 'avatarUrl'])
|
||||||
.where((eb) => eb(sql`LOWER(users.name)`, 'like', `%${query}%`))
|
.where((eb) => eb(sql`LOWER(users.name)`, 'like', `%${query}%`))
|
||||||
.where('workspaceId', '=', workspaceId)
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
.where('deletedAt', 'is', null)
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,6 +34,7 @@ import { addDays } from 'date-fns';
|
|||||||
import { FastifyReply } from 'fastify';
|
import { FastifyReply } from 'fastify';
|
||||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||||
import { CheckHostnameDto } from '../dto/check-hostname.dto';
|
import { CheckHostnameDto } from '../dto/check-hostname.dto';
|
||||||
|
import { RemoveWorkspaceUserDto } from '../dto/remove-workspace-user.dto';
|
||||||
|
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@Controller('workspace')
|
@Controller('workspace')
|
||||||
@ -120,6 +121,22 @@ export class WorkspaceController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post('members/delete')
|
||||||
|
async deleteWorkspaceMember(
|
||||||
|
@Body() dto: RemoveWorkspaceUserDto,
|
||||||
|
@AuthUser() user: User,
|
||||||
|
@AuthWorkspace() workspace: Workspace,
|
||||||
|
) {
|
||||||
|
const ability = this.workspaceAbility.createForUser(user, workspace);
|
||||||
|
if (
|
||||||
|
ability.cannot(WorkspaceCaslAction.Manage, WorkspaceCaslSubject.Member)
|
||||||
|
) {
|
||||||
|
throw new ForbiddenException();
|
||||||
|
}
|
||||||
|
await this.workspaceService.deleteUser(user, dto.userId, workspace.id);
|
||||||
|
}
|
||||||
|
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post('members/change-role')
|
@Post('members/change-role')
|
||||||
async updateWorkspaceMemberRole(
|
async updateWorkspaceMemberRole(
|
||||||
|
|||||||
@ -27,6 +27,8 @@ import { DomainService } from '../../../integrations/environment/domain.service'
|
|||||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||||
import { addDays } from 'date-fns';
|
import { addDays } from 'date-fns';
|
||||||
import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants';
|
import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants';
|
||||||
|
import { v4 } from 'uuid';
|
||||||
|
import { AttachmentType } from 'src/core/attachment/attachment.constants';
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||||
import { Queue } from 'bullmq';
|
import { Queue } from 'bullmq';
|
||||||
@ -45,6 +47,7 @@ export class WorkspaceService {
|
|||||||
private environmentService: EnvironmentService,
|
private environmentService: EnvironmentService,
|
||||||
private domainService: DomainService,
|
private domainService: DomainService,
|
||||||
@InjectKysely() private readonly db: KyselyDB,
|
@InjectKysely() private readonly db: KyselyDB,
|
||||||
|
@InjectQueue(QueueName.ATTACHMENT_QUEUE) private attachmentQueue: Queue,
|
||||||
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
|
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -419,4 +422,66 @@ export class WorkspaceService {
|
|||||||
}
|
}
|
||||||
return { hostname: this.domainService.getUrl(hostname) };
|
return { hostname: this.domainService.getUrl(hostname) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async deleteUser(
|
||||||
|
authUser: User,
|
||||||
|
userId: string,
|
||||||
|
workspaceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const user = await this.userRepo.findById(userId, workspaceId);
|
||||||
|
|
||||||
|
if (!user || user.deletedAt) {
|
||||||
|
throw new BadRequestException('Workspace member not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspaceOwnerCount = await this.userRepo.roleCountByWorkspaceId(
|
||||||
|
UserRole.OWNER,
|
||||||
|
workspaceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (user.role === UserRole.OWNER && workspaceOwnerCount === 1) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'There must be at least one workspace owner',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authUser.id === userId) {
|
||||||
|
throw new BadRequestException('You cannot delete yourself');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authUser.role === UserRole.ADMIN && user.role === UserRole.OWNER) {
|
||||||
|
throw new BadRequestException('You cannot delete a user with owner role');
|
||||||
|
}
|
||||||
|
|
||||||
|
await executeTx(this.db, async (trx) => {
|
||||||
|
await this.userRepo.updateUser(
|
||||||
|
{
|
||||||
|
name: 'Deleted user',
|
||||||
|
email: v4() + '@deleted.docmost.com',
|
||||||
|
avatarUrl: null,
|
||||||
|
settings: null,
|
||||||
|
deletedAt: new Date(),
|
||||||
|
},
|
||||||
|
userId,
|
||||||
|
workspaceId,
|
||||||
|
trx,
|
||||||
|
);
|
||||||
|
|
||||||
|
await trx.deleteFrom('groupUsers').where('userId', '=', userId).execute();
|
||||||
|
await trx
|
||||||
|
.deleteFrom('spaceMembers')
|
||||||
|
.where('userId', '=', userId)
|
||||||
|
.execute();
|
||||||
|
await trx
|
||||||
|
.deleteFrom('authAccounts')
|
||||||
|
.where('userId', '=', userId)
|
||||||
|
.execute();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.attachmentQueue.add(QueueJob.DELETE_USER_AVATARS, user);
|
||||||
|
} catch (err) {
|
||||||
|
// empty
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -139,6 +139,7 @@ export class UserRepo {
|
|||||||
.selectFrom('users')
|
.selectFrom('users')
|
||||||
.select(this.baseFields)
|
.select(this.baseFields)
|
||||||
.where('workspaceId', '=', workspaceId)
|
.where('workspaceId', '=', workspaceId)
|
||||||
|
.where('deletedAt', 'is', null)
|
||||||
.orderBy('createdAt', 'asc');
|
.orderBy('createdAt', 'asc');
|
||||||
|
|
||||||
if (pagination.query) {
|
if (pagination.query) {
|
||||||
|
|||||||
@ -11,6 +11,8 @@ export enum QueueJob {
|
|||||||
DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments',
|
DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments',
|
||||||
PAGE_CONTENT_UPDATE = 'page-content-update',
|
PAGE_CONTENT_UPDATE = 'page-content-update',
|
||||||
|
|
||||||
|
DELETE_USER_AVATARS = 'delete-user-avatars',
|
||||||
|
|
||||||
PAGE_BACKLINKS = 'page-backlinks',
|
PAGE_BACKLINKS = 'page-backlinks',
|
||||||
|
|
||||||
STRIPE_SEATS_SYNC = 'sync-stripe-seats',
|
STRIPE_SEATS_SYNC = 'sync-stripe-seats',
|
||||||
|
|||||||
Reference in New Issue
Block a user