mirror of
https://github.com/wassname/talk.git
synced 2026-08-14 12:50:17 +08:00
[CORL-1134] Site Moderators (#2995)
* feat: added server support for site moderators * feat: added unscoped property to @auth directive * feat: added frontend support for site moderator feature * feat: added site moderator support to stream * fix: linting * fix: updated snapshots
This commit is contained in:
@@ -1,14 +1,11 @@
|
||||
// import createDOMPurify from "dompurify";
|
||||
// import { JSDOM } from "jsdom";
|
||||
|
||||
import { AppOptions } from "coral-server/app";
|
||||
import { calculateTotalPublishedCommentCount } from "coral-server/models/comment";
|
||||
import { translate } from "coral-server/services/i18n";
|
||||
import { find } from "coral-server/services/stories";
|
||||
import { RequestHandler } from "coral-server/types/express";
|
||||
|
||||
const NUMBER_CLASSNAME = "coral-count-number";
|
||||
const TEXT_CLASSNAME = "coral-count-text";
|
||||
const NUMBER_CLASS_NAME = "coral-count-number";
|
||||
const TEXT_CLASS_NAME = "coral-count-text";
|
||||
|
||||
export type CountOptions = Pick<AppOptions, "mongo" | "tenantCache" | "i18n">;
|
||||
|
||||
@@ -36,18 +33,18 @@ export const countHandler = ({
|
||||
let html = "";
|
||||
if (req.query.notext === "true") {
|
||||
// We only need the count without the text.
|
||||
html = `<span class="${NUMBER_CLASSNAME}">${count}</span>`;
|
||||
html = `<span class="${NUMBER_CLASS_NAME}">${count}</span>`;
|
||||
} else {
|
||||
// Use translated string.
|
||||
const bundle = i18n.getBundle(tenant.locale);
|
||||
html = translate(
|
||||
bundle,
|
||||
`<span class="${NUMBER_CLASSNAME}">${count}</span> <span class="${TEXT_CLASSNAME}">Comments</span>`,
|
||||
`<span class="${NUMBER_CLASS_NAME}">${count}</span> <span class="${TEXT_CLASS_NAME}">Comments</span>`,
|
||||
"comment-count",
|
||||
{
|
||||
number: count,
|
||||
numberClass: NUMBER_CLASSNAME,
|
||||
textClass: TEXT_CLASSNAME,
|
||||
numberClass: NUMBER_CLASS_NAME,
|
||||
textClass: TEXT_CLASS_NAME,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -414,12 +414,21 @@ export class UserForbiddenError extends CoralError {
|
||||
operation: string,
|
||||
userID?: string,
|
||||
permit?: GQLUSER_AUTH_CONDITIONS[],
|
||||
conditions?: GQLUSER_AUTH_CONDITIONS[]
|
||||
conditions?: GQLUSER_AUTH_CONDITIONS[],
|
||||
unscoped?: boolean
|
||||
) {
|
||||
super({
|
||||
code: ERROR_CODES.USER_NOT_ENTITLED,
|
||||
context: {
|
||||
pvt: { reason, userID, resource, operation, conditions, permit },
|
||||
pvt: {
|
||||
reason,
|
||||
userID,
|
||||
resource,
|
||||
operation,
|
||||
conditions,
|
||||
permit,
|
||||
unscoped,
|
||||
},
|
||||
},
|
||||
status: 403,
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
consolidateUserSuspensionStatus,
|
||||
User,
|
||||
} from "coral-server/models/user";
|
||||
import { canModerateUnscoped } from "coral-server/models/user/helpers";
|
||||
|
||||
import {
|
||||
GQLUSER_AUTH_CONDITIONS,
|
||||
@@ -27,6 +28,7 @@ export interface AuthDirectiveArgs {
|
||||
roles?: GQLUSER_ROLE[];
|
||||
userIDField?: string;
|
||||
permit?: GQLUSER_AUTH_CONDITIONS[];
|
||||
unscoped?: boolean;
|
||||
}
|
||||
|
||||
function calculateAuthConditions(
|
||||
@@ -68,7 +70,7 @@ const auth: DirectiveResolverFn<
|
||||
> = (
|
||||
next,
|
||||
src,
|
||||
{ roles, userIDField, permit }: AuthDirectiveArgs,
|
||||
{ roles, userIDField, permit, unscoped = false }: AuthDirectiveArgs,
|
||||
{ user, now },
|
||||
info
|
||||
) => {
|
||||
@@ -115,7 +117,22 @@ const auth: DirectiveResolverFn<
|
||||
info.operation.operation,
|
||||
user.id,
|
||||
permit,
|
||||
conditions
|
||||
conditions,
|
||||
unscoped
|
||||
);
|
||||
}
|
||||
|
||||
// If the unscoped check is enabled, then ensure that the user can moderate
|
||||
// unscoped.
|
||||
if (unscoped && !canModerateUnscoped(user)) {
|
||||
throw new UserForbiddenError(
|
||||
"user cannot access unscoped resource as they are scoped",
|
||||
calculateLocationKey(info),
|
||||
info.operation.operation,
|
||||
user.id,
|
||||
permit,
|
||||
conditions,
|
||||
unscoped
|
||||
);
|
||||
}
|
||||
|
||||
@@ -141,7 +158,10 @@ const auth: DirectiveResolverFn<
|
||||
"user does not have permission to access the resource",
|
||||
calculateLocationKey(info),
|
||||
info.operation.operation,
|
||||
user ? user.id : undefined
|
||||
user ? user.id : undefined,
|
||||
permit,
|
||||
undefined,
|
||||
unscoped
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import GraphContext from "coral-server/graph/context";
|
||||
import { hasFeatureFlag } from "coral-server/models/tenant";
|
||||
import { approveComment, rejectComment } from "coral-server/stacks";
|
||||
|
||||
import {
|
||||
GQLApproveCommentInput,
|
||||
GQLFEATURE_FLAG,
|
||||
GQLRejectCommentInput,
|
||||
} from "../schema/__generated__/types";
|
||||
|
||||
import { validateUserModerationScopes } from "./helpers";
|
||||
|
||||
export const Actions = (ctx: GraphContext) => ({
|
||||
approveComment: (input: GQLApproveCommentInput) =>
|
||||
approveComment(
|
||||
approveComment: async (input: GQLApproveCommentInput) => {
|
||||
// Validate that this user is allowed to moderate this comment if the
|
||||
// feature flag is enabled.
|
||||
if (hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
|
||||
await validateUserModerationScopes(ctx, ctx.user!, input);
|
||||
}
|
||||
|
||||
return approveComment(
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.broker,
|
||||
@@ -17,9 +27,16 @@ export const Actions = (ctx: GraphContext) => ({
|
||||
input.commentRevisionID,
|
||||
ctx.user!.id,
|
||||
ctx.now
|
||||
),
|
||||
rejectComment: (input: GQLRejectCommentInput) =>
|
||||
rejectComment(
|
||||
);
|
||||
},
|
||||
rejectComment: async (input: GQLRejectCommentInput) => {
|
||||
// Validate that this user is allowed to moderate this comment if the
|
||||
// feature flag is enabled.
|
||||
if (hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
|
||||
await validateUserModerationScopes(ctx, ctx.user!, input);
|
||||
}
|
||||
|
||||
return rejectComment(
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.broker,
|
||||
@@ -28,5 +45,6 @@ export const Actions = (ctx: GraphContext) => ({
|
||||
input.commentRevisionID,
|
||||
ctx.user!.id,
|
||||
ctx.now
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ERROR_CODES } from "coral-common/errors";
|
||||
import { ADDITIONAL_DETAILS_MAX_LENGTH } from "coral-common/helpers/validate";
|
||||
import GraphContext from "coral-server/graph/context";
|
||||
import { mapFieldsetToErrorCodes } from "coral-server/graph/errors";
|
||||
import { hasFeatureFlag } from "coral-server/models/tenant";
|
||||
import { addTag, removeTag } from "coral-server/services/comments";
|
||||
import {
|
||||
createDontAgree,
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
GQLCreateCommentReactionInput,
|
||||
GQLCreateCommentReplyInput,
|
||||
GQLEditCommentInput,
|
||||
GQLFEATURE_FLAG,
|
||||
GQLFeatureCommentInput,
|
||||
GQLRemoveCommentDontAgreeInput,
|
||||
GQLRemoveCommentReactionInput,
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
GQLUnfeatureCommentInput,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { validateUserModerationScopes } from "./helpers";
|
||||
import { validateMaximumLength, WithoutMutationID } from "./util";
|
||||
|
||||
export const Comments = (ctx: GraphContext) => ({
|
||||
@@ -155,11 +158,17 @@ export const Comments = (ctx: GraphContext) => ({
|
||||
},
|
||||
ctx.now
|
||||
),
|
||||
feature: ({
|
||||
feature: async ({
|
||||
commentID,
|
||||
commentRevisionID,
|
||||
}: WithoutMutationID<GQLFeatureCommentInput>) =>
|
||||
addTag(
|
||||
}: WithoutMutationID<GQLFeatureCommentInput>) => {
|
||||
// Validate that this user is allowed to moderate this comment if the
|
||||
// feature flag is enabled.
|
||||
if (hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
|
||||
await validateUserModerationScopes(ctx, ctx.user!, { commentID });
|
||||
}
|
||||
|
||||
const comment = await addTag(
|
||||
ctx.mongo,
|
||||
ctx.tenant,
|
||||
commentID,
|
||||
@@ -167,28 +176,36 @@ export const Comments = (ctx: GraphContext) => ({
|
||||
ctx.user!,
|
||||
GQLTAG.FEATURED,
|
||||
ctx.now
|
||||
)
|
||||
.then((comment) =>
|
||||
comment.status !== GQLCOMMENT_STATUS.APPROVED
|
||||
? approveComment(
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.broker,
|
||||
ctx.tenant,
|
||||
commentID,
|
||||
commentRevisionID,
|
||||
ctx.user!.id,
|
||||
ctx.now
|
||||
)
|
||||
: comment
|
||||
)
|
||||
.then((comment) => {
|
||||
// Publish that the comment was featured.
|
||||
void publishCommentFeatured(ctx.broker, comment);
|
||||
);
|
||||
|
||||
// Return it to the next step.
|
||||
return comment;
|
||||
}),
|
||||
unfeature: ({ commentID }: WithoutMutationID<GQLUnfeatureCommentInput>) =>
|
||||
removeTag(ctx.mongo, ctx.tenant, commentID, GQLTAG.FEATURED),
|
||||
if (comment.status !== GQLCOMMENT_STATUS.APPROVED) {
|
||||
await approveComment(
|
||||
ctx.mongo,
|
||||
ctx.redis,
|
||||
ctx.broker,
|
||||
ctx.tenant,
|
||||
commentID,
|
||||
commentRevisionID,
|
||||
ctx.user!.id,
|
||||
ctx.now
|
||||
);
|
||||
}
|
||||
|
||||
// Publish that the comment was featured.
|
||||
await publishCommentFeatured(ctx.broker, comment);
|
||||
|
||||
// Return it to the next step.
|
||||
return comment;
|
||||
},
|
||||
unfeature: async ({
|
||||
commentID,
|
||||
}: WithoutMutationID<GQLUnfeatureCommentInput>) => {
|
||||
// Validate that this user is allowed to moderate this comment if the
|
||||
// feature flag is enabled.
|
||||
if (hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
|
||||
await validateUserModerationScopes(ctx, ctx.user!, { commentID });
|
||||
}
|
||||
|
||||
return removeTag(ctx.mongo, ctx.tenant, commentID, GQLTAG.FEATURED);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
enableExternalModerationPhase,
|
||||
enableFeatureFlag,
|
||||
enableWebhookEndpoint,
|
||||
regenerateSSOKey,
|
||||
rotateExternalModerationPhaseSigningSecret,
|
||||
rotateSSOSigningSecret,
|
||||
rotateWebhookEndpointSigningSecret,
|
||||
@@ -62,9 +61,6 @@ export const Settings = ({
|
||||
input: WithoutMutationID<GQLUpdateSettingsInput>
|
||||
): Promise<Tenant | null> =>
|
||||
update(mongo, redis, tenantCache, config, tenant, input.settings),
|
||||
// DEPRECATED: deprecated in favour of `rotateSSOSigningSecret`, remove in 6.2.0.
|
||||
regenerateSSOKey: (): Promise<Tenant | null> =>
|
||||
regenerateSSOKey(mongo, redis, tenantCache, tenant, now),
|
||||
rotateSSOSigningSecret: ({ inactiveIn }: GQLRotateSSOSigningSecretInput) =>
|
||||
rotateSSOSigningSecret(mongo, redis, tenantCache, tenant, inactiveIn, now),
|
||||
deleteSSOSigningSecret: ({ kid }: GQLDeleteSSOSigningSecretInput) =>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { ERROR_CODES } from "coral-common/errors";
|
||||
import GraphContext from "coral-server/graph/context";
|
||||
import { mapFieldsetToErrorCodes } from "coral-server/graph/errors";
|
||||
import { Story } from "coral-server/models/story";
|
||||
import { hasFeatureFlag } from "coral-server/models/tenant";
|
||||
import {
|
||||
addStoryExpert,
|
||||
close,
|
||||
@@ -22,6 +23,7 @@ import {
|
||||
GQLAddStoryExpertInput,
|
||||
GQLCloseStoryInput,
|
||||
GQLCreateStoryInput,
|
||||
GQLFEATURE_FLAG,
|
||||
GQLMergeStoriesInput,
|
||||
GQLOpenStoryInput,
|
||||
GQLRemoveStoryExpertInput,
|
||||
@@ -32,6 +34,8 @@ import {
|
||||
GQLUpdateStorySettingsInput,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { validateUserModerationScopes } from "./helpers";
|
||||
|
||||
export const Stories = (ctx: GraphContext) => ({
|
||||
create: async (input: GQLCreateStoryInput): Promise<Readonly<Story> | null> =>
|
||||
mapFieldsetToErrorCodes(
|
||||
@@ -64,22 +68,81 @@ export const Stories = (ctx: GraphContext) => ({
|
||||
),
|
||||
updateSettings: async (
|
||||
input: GQLUpdateStorySettingsInput
|
||||
): Promise<Readonly<Story> | null> =>
|
||||
updateSettings(ctx.mongo, ctx.tenant, input.id, input.settings, ctx.now),
|
||||
close: (input: GQLCloseStoryInput): Promise<Readonly<Story> | null> =>
|
||||
close(ctx.mongo, ctx.tenant, input.id, ctx.now),
|
||||
open: (input: GQLOpenStoryInput): Promise<Readonly<Story> | null> =>
|
||||
open(ctx.mongo, ctx.tenant, input.id, ctx.now),
|
||||
): Promise<Readonly<Story> | null> => {
|
||||
// Validate that this user is allowed to edit this story if the feature
|
||||
// flag is enabled.
|
||||
if (hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
|
||||
await validateUserModerationScopes(ctx, ctx.user!, { storyID: input.id });
|
||||
}
|
||||
|
||||
return updateSettings(
|
||||
ctx.mongo,
|
||||
ctx.tenant,
|
||||
input.id,
|
||||
input.settings,
|
||||
ctx.now
|
||||
);
|
||||
},
|
||||
close: async (input: GQLCloseStoryInput): Promise<Readonly<Story> | null> => {
|
||||
// Validate that this user is allowed to close this story if the feature
|
||||
// flag is enabled.
|
||||
if (hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
|
||||
await validateUserModerationScopes(ctx, ctx.user!, { storyID: input.id });
|
||||
}
|
||||
|
||||
return close(ctx.mongo, ctx.tenant, input.id, ctx.now);
|
||||
},
|
||||
open: async (input: GQLOpenStoryInput): Promise<Readonly<Story> | null> => {
|
||||
// Validate that this user is allowed to open this story if the feature
|
||||
// flag is enabled.
|
||||
if (hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
|
||||
await validateUserModerationScopes(ctx, ctx.user!, { storyID: input.id });
|
||||
}
|
||||
|
||||
return open(ctx.mongo, ctx.tenant, input.id, ctx.now);
|
||||
},
|
||||
merge: async (input: GQLMergeStoriesInput): Promise<Readonly<Story> | null> =>
|
||||
merge(ctx.mongo, ctx.tenant, input.destinationID, input.sourceIDs),
|
||||
remove: async (input: GQLRemoveStoryInput): Promise<Readonly<Story> | null> =>
|
||||
remove(ctx.mongo, ctx.tenant, input.id, input.includeComments),
|
||||
scrape: async (input: GQLScrapeStoryInput): Promise<Readonly<Story> | null> =>
|
||||
scrape(ctx.mongo, ctx.config, ctx.tenant.id, input.id),
|
||||
updateStoryMode: async (input: GQLUpdateStoryModeInput) =>
|
||||
updateStoryMode(ctx.mongo, ctx.tenant, input.storyID, input.mode),
|
||||
addStoryExpert: async (input: GQLAddStoryExpertInput) =>
|
||||
addStoryExpert(ctx.mongo, ctx.tenant, input.storyID, input.userID),
|
||||
removeStoryExpert: async (input: GQLRemoveStoryExpertInput) =>
|
||||
removeStoryExpert(ctx.mongo, ctx.tenant, input.storyID, input.userID),
|
||||
updateStoryMode: async (input: GQLUpdateStoryModeInput) => {
|
||||
// Validate that this user is allowed to update the story mode if the
|
||||
// feature flag is enabled.
|
||||
if (hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
|
||||
await validateUserModerationScopes(ctx, ctx.user!, {
|
||||
storyID: input.storyID,
|
||||
});
|
||||
}
|
||||
|
||||
return updateStoryMode(ctx.mongo, ctx.tenant, input.storyID, input.mode);
|
||||
},
|
||||
addStoryExpert: async (input: GQLAddStoryExpertInput) => {
|
||||
// Validate that this user is allowed to add a story expert if the
|
||||
// feature flag is enabled.
|
||||
if (hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
|
||||
await validateUserModerationScopes(ctx, ctx.user!, {
|
||||
storyID: input.storyID,
|
||||
});
|
||||
}
|
||||
|
||||
return addStoryExpert(ctx.mongo, ctx.tenant, input.storyID, input.userID);
|
||||
},
|
||||
removeStoryExpert: async (input: GQLRemoveStoryExpertInput) => {
|
||||
// Validate that this user is allowed to remove a story expert if the
|
||||
// feature flag is enabled.
|
||||
if (hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
|
||||
await validateUserModerationScopes(ctx, ctx.user!, {
|
||||
storyID: input.storyID,
|
||||
});
|
||||
}
|
||||
|
||||
return removeStoryExpert(
|
||||
ctx.mongo,
|
||||
ctx.tenant,
|
||||
input.storyID,
|
||||
input.userID
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
updateAvatar,
|
||||
updateEmail,
|
||||
updateEmailByID,
|
||||
updateModerationScopes,
|
||||
updateNotificationSettings,
|
||||
updatePassword,
|
||||
updateRole,
|
||||
@@ -61,10 +62,11 @@ import {
|
||||
GQLUpdatePasswordInput,
|
||||
GQLUpdateUserAvatarInput,
|
||||
GQLUpdateUserEmailInput,
|
||||
GQLUpdateUserModerationScopesInput,
|
||||
GQLUpdateUsernameInput,
|
||||
GQLUpdateUserRoleInput,
|
||||
GQLUpdateUserUsernameInput,
|
||||
} from "../schema/__generated__/types";
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { WithoutMutationID } from "./util";
|
||||
|
||||
@@ -208,6 +210,16 @@ export const Users = (ctx: GraphContext) => ({
|
||||
updateAvatar(ctx.mongo, ctx.tenant, input.userID, input.avatar),
|
||||
updateUserRole: async (input: GQLUpdateUserRoleInput) =>
|
||||
updateRole(ctx.mongo, ctx.tenant, ctx.user!, input.userID, input.role),
|
||||
updateUserModerationScopes: async (
|
||||
input: GQLUpdateUserModerationScopesInput
|
||||
) =>
|
||||
updateModerationScopes(
|
||||
ctx.mongo,
|
||||
ctx.tenant,
|
||||
ctx.user!,
|
||||
input.userID,
|
||||
input.moderationScopes
|
||||
),
|
||||
createModeratorNote: async (input: GQLCreateModeratorNoteInput) =>
|
||||
addModeratorNote(
|
||||
ctx.mongo,
|
||||
@@ -225,16 +237,20 @@ export const Users = (ctx: GraphContext) => ({
|
||||
input.id,
|
||||
ctx.user!
|
||||
),
|
||||
ban: async (input: GQLBanUserInput) =>
|
||||
ban: async ({
|
||||
userID,
|
||||
message,
|
||||
rejectExistingComments = false,
|
||||
}: GQLBanUserInput) =>
|
||||
ban(
|
||||
ctx.mongo,
|
||||
ctx.mailerQueue,
|
||||
ctx.rejectorQueue,
|
||||
ctx.tenant,
|
||||
ctx.user!,
|
||||
input.userID,
|
||||
input.message,
|
||||
input.rejectExistingComments || false,
|
||||
userID,
|
||||
message,
|
||||
rejectExistingComments,
|
||||
ctx.now
|
||||
),
|
||||
premodUser: async (input: GQLPremodUserInput) =>
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import {
|
||||
CommentNotFoundError,
|
||||
StoryNotFoundError,
|
||||
UserForbiddenError,
|
||||
} from "coral-server/errors";
|
||||
import { User } from "coral-server/models/user";
|
||||
import {
|
||||
canModerate,
|
||||
canModerateUnscoped,
|
||||
ModerationScopeResource,
|
||||
} from "coral-server/models/user/helpers";
|
||||
|
||||
import GraphContext from "../context";
|
||||
|
||||
interface CommentResourceModerationScope {
|
||||
commentID: string;
|
||||
}
|
||||
|
||||
function isCommentResourceModerationScope(
|
||||
scope: ResourceModerationScopes
|
||||
): scope is CommentResourceModerationScope {
|
||||
if ((scope as CommentResourceModerationScope).commentID) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
interface StoryResourceModerationScope {
|
||||
storyID: string;
|
||||
}
|
||||
|
||||
function isStoryResourceModerationScope(
|
||||
scope: ResourceModerationScopes
|
||||
): scope is StoryResourceModerationScope {
|
||||
if ((scope as StoryResourceModerationScope).storyID) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
interface SiteResourceModerationScope {
|
||||
siteID: string;
|
||||
}
|
||||
|
||||
type ResourceModerationScopes =
|
||||
| StoryResourceModerationScope
|
||||
| CommentResourceModerationScope
|
||||
| SiteResourceModerationScope;
|
||||
|
||||
/**
|
||||
* validateUserModerationScopes will validate if the user has access to
|
||||
* moderating the resource indicated by the resource scopes.
|
||||
*
|
||||
* @param ctx the graph context for this request
|
||||
* @param user the user being evaluated
|
||||
* @param scope scope keys for the documents referencing moderation scopes
|
||||
*/
|
||||
export async function validateUserModerationScopes(
|
||||
ctx: GraphContext,
|
||||
user: Pick<User, "id" | "role" | "moderationScopes">,
|
||||
scope: ResourceModerationScopes
|
||||
) {
|
||||
// If the user has no restrictions on them, exit now.
|
||||
if (canModerateUnscoped(user)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let resource: ModerationScopeResource;
|
||||
|
||||
if (isCommentResourceModerationScope(scope)) {
|
||||
// Because the user has siteID restrictions on their moderator capabilities,
|
||||
// we have to check the siteID of the comment before we make a decision.
|
||||
const comment = await ctx.loaders.Comments.comment.load(scope.commentID);
|
||||
if (!comment) {
|
||||
throw new CommentNotFoundError(scope.commentID);
|
||||
}
|
||||
|
||||
resource = comment;
|
||||
} else if (isStoryResourceModerationScope(scope)) {
|
||||
// Because the user has siteID restrictions on their moderator capabilities,
|
||||
// we have to check the siteID of the story before we make a decision.
|
||||
const story = await ctx.loaders.Stories.story.load(scope.storyID);
|
||||
if (!story) {
|
||||
throw new StoryNotFoundError(scope.storyID);
|
||||
}
|
||||
|
||||
resource = story;
|
||||
} else {
|
||||
resource = { siteID: scope.siteID };
|
||||
}
|
||||
|
||||
// Check to see if this user is allowed to moderate this comment.
|
||||
if (canModerate(user, resource)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The user had the right role, but had a siteID restriction that prevented
|
||||
// them from moderating this comment.
|
||||
throw new UserForbiddenError(
|
||||
"user does not have permission to moderate this comment",
|
||||
"comment",
|
||||
"mutation",
|
||||
user.id
|
||||
);
|
||||
}
|
||||
@@ -16,11 +16,17 @@ import {
|
||||
} from "coral-server/models/comment/helpers";
|
||||
import { createConnection } from "coral-server/models/helpers";
|
||||
import { getURLWithCommentID } from "coral-server/models/story";
|
||||
import { hasFeatureFlag } from "coral-server/models/tenant";
|
||||
import {
|
||||
canModerate,
|
||||
hasModeratorRole,
|
||||
} from "coral-server/models/user/helpers";
|
||||
import { getCommentEditableUntilDate } from "coral-server/services/comments";
|
||||
|
||||
import {
|
||||
GQLComment,
|
||||
GQLCommentTypeResolver,
|
||||
GQLFEATURE_FLAG,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import GraphContext from "../context";
|
||||
@@ -53,6 +59,19 @@ export const Comment: GQLCommentTypeResolver<comment.Comment> = {
|
||||
c.revisions.length > 0
|
||||
? { revision: getLatestRevision(c), comment: c }
|
||||
: null,
|
||||
canModerate: (c, input, ctx) => {
|
||||
if (!ctx.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the feature flag for site moderators is not turned on return based on
|
||||
// the users role.
|
||||
if (!hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
|
||||
return hasModeratorRole(ctx.user);
|
||||
}
|
||||
|
||||
return canModerate(ctx.user, { siteID: c.siteID });
|
||||
},
|
||||
deleted: ({ deletedAt }) => !!deletedAt,
|
||||
revisionHistory: (c) =>
|
||||
c.revisions.map((revision) => ({ revision, comment: c })),
|
||||
|
||||
@@ -75,11 +75,6 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
|
||||
comment: await ctx.mutators.Comments.unfeature(input),
|
||||
clientMutationId,
|
||||
}),
|
||||
// DEPRECATED: deprecated in favour of `rotateSSOSigningSecret`, remove in 6.2.0.
|
||||
regenerateSSOKey: async (source, { input }, ctx) => ({
|
||||
settings: await ctx.mutators.Settings.regenerateSSOKey(),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
rotateSSOSigningSecret: async (source, { input }, ctx) => ({
|
||||
settings: await ctx.mutators.Settings.rotateSSOSigningSecret(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
@@ -184,6 +179,10 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
|
||||
user: await ctx.mutators.Users.updateUserRole(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
updateUserModerationScopes: async (source, { input }, ctx) => ({
|
||||
user: await ctx.mutators.Users.updateUserModerationScopes(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
banUser: async (source, { input }, ctx) => ({
|
||||
user: await ctx.mutators.Users.ban(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as site from "coral-server/models/site";
|
||||
import { hasFeatureFlag } from "coral-server/models/tenant";
|
||||
import {
|
||||
canModerate,
|
||||
hasModeratorRole,
|
||||
} from "coral-server/models/user/helpers";
|
||||
|
||||
import {
|
||||
GQLFEATURE_FLAG,
|
||||
GQLSiteTypeResolver,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
export const Site: GQLSiteTypeResolver<site.Site> = {
|
||||
canModerate: ({ id }, args, ctx) => {
|
||||
if (!ctx.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the feature flag for site moderators is not turned on return based on
|
||||
// the users role.
|
||||
if (!hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
|
||||
return hasModeratorRole(ctx.user);
|
||||
}
|
||||
|
||||
return canModerate(ctx.user, { siteID: id });
|
||||
},
|
||||
};
|
||||
@@ -2,8 +2,14 @@ import { defaultsDeep } from "lodash";
|
||||
|
||||
import { decodeActionCounts } from "coral-server/models/action/comment";
|
||||
import * as story from "coral-server/models/story";
|
||||
import { hasFeatureFlag } from "coral-server/models/tenant";
|
||||
import {
|
||||
canModerate,
|
||||
hasModeratorRole,
|
||||
} from "coral-server/models/user/helpers";
|
||||
|
||||
import {
|
||||
GQLFEATURE_FLAG,
|
||||
GQLSTORY_STATUS,
|
||||
GQLStoryTypeResolver,
|
||||
GQLTAG,
|
||||
@@ -21,6 +27,20 @@ export const Story: GQLStoryTypeResolver<story.Story> = {
|
||||
story.isStoryClosed(ctx.tenant, s, ctx.now)
|
||||
? GQLSTORY_STATUS.CLOSED
|
||||
: GQLSTORY_STATUS.OPEN,
|
||||
canModerate: (s, input, ctx) => {
|
||||
if (!ctx.user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the feature flag for site moderators is not turned on return based on
|
||||
// the users role.
|
||||
if (!hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
|
||||
return hasModeratorRole(ctx.user);
|
||||
}
|
||||
|
||||
// We know the user is provided because this edge is authenticated.
|
||||
return canModerate(ctx.user, { siteID: s.siteID });
|
||||
},
|
||||
isClosed: (s, input, ctx) => story.isStoryClosed(ctx.tenant, s, ctx.now),
|
||||
closedAt: (s, input, ctx) => story.getStoryClosedAt(ctx.tenant, s) || null,
|
||||
commentActionCounts: (s) => decodeActionCounts(s.commentCounts.action),
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { GraphQLResolveInfo } from "graphql";
|
||||
|
||||
import GraphContext from "coral-server/graph/context";
|
||||
import {
|
||||
GQLUser,
|
||||
GQLUserTypeResolver,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
import { hasFeatureFlag } from "coral-server/models/tenant";
|
||||
import * as user from "coral-server/models/user";
|
||||
import { roleIsStaff } from "coral-server/models/user/helpers";
|
||||
|
||||
import {
|
||||
GQLFEATURE_FLAG,
|
||||
GQLUser,
|
||||
GQLUSER_ROLE,
|
||||
GQLUserTypeResolver,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { RecentCommentHistoryInput } from "./RecentCommentHistory";
|
||||
import { UserStatusInput } from "./UserStatus";
|
||||
import { getRequestedFields } from "./util";
|
||||
@@ -45,9 +49,24 @@ export const User: GQLUserTypeResolver<user.User> = {
|
||||
...status,
|
||||
userID: id,
|
||||
}),
|
||||
moderationScopes: ({ role, moderationScopes }, input, ctx) => {
|
||||
// If the feature flag for site moderators is not turned on return null
|
||||
// always.
|
||||
if (!hasFeatureFlag(ctx.tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Moderation scopes only apply to users that have the moderator role.
|
||||
if (role !== GQLUSER_ROLE.MODERATOR) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// For all other users return null for moderation scopes.
|
||||
return moderationScopes;
|
||||
},
|
||||
ignoredUsers: ({ ignoredUsers }, input, ctx, info) =>
|
||||
maybeLoadOnlyIgnoredUserID(ctx, info, ignoredUsers),
|
||||
ignoreable: ({ role }) => !roleIsStaff(role),
|
||||
recentCommentHistory: ({ id }): RecentCommentHistoryInput => ({ userID: id }),
|
||||
profiles: ({ profiles }) => (profiles ? profiles : []),
|
||||
profiles: ({ profiles = [] }) => profiles,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as user from "coral-server/models/user";
|
||||
|
||||
import { GQLUserModerationScopesTypeResolver } from "coral-server/graph/schema/__generated__/types";
|
||||
import { isModerationScoped } from "coral-server/models/user/helpers";
|
||||
|
||||
export const UserModerationScopes: GQLUserModerationScopesTypeResolver<user.UserModerationScopes> = {
|
||||
scoped: (moderationScopes) => isModerationScoped(moderationScopes),
|
||||
sites: ({ siteIDs }, args, ctx) => {
|
||||
if (siteIDs) {
|
||||
return ctx.loaders.Sites.site.loadMany(siteIDs);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
};
|
||||
@@ -42,6 +42,7 @@ import { RecentCommentHistory } from "./RecentCommentHistory";
|
||||
import { RejectCommentPayload } from "./RejectCommentPayload";
|
||||
import { Settings } from "./Settings";
|
||||
import { SigningSecret } from "./SigningSecret";
|
||||
import { Site } from "./Site";
|
||||
import { SlackConfiguration } from "./SlackConfiguration";
|
||||
import { SSOAuthIntegration } from "./SSOAuthIntegration";
|
||||
import { Story } from "./Story";
|
||||
@@ -51,6 +52,7 @@ import { SuspensionStatus } from "./SuspensionStatus";
|
||||
import { SuspensionStatusHistory } from "./SuspensionStatusHistory";
|
||||
import { Tag } from "./Tag";
|
||||
import { User } from "./User";
|
||||
import { UserModerationScopes } from "./UserModerationScopes";
|
||||
import { UsernameHistory } from "./UsernameHistory";
|
||||
import { UsernameStatus } from "./UsernameStatus";
|
||||
import { UserStatus } from "./UserStatus";
|
||||
@@ -95,6 +97,7 @@ const Resolvers: GQLResolver = {
|
||||
RejectCommentPayload,
|
||||
SSOAuthIntegration,
|
||||
SigningSecret,
|
||||
Site,
|
||||
Story,
|
||||
StorySettings,
|
||||
Subscription,
|
||||
@@ -103,6 +106,7 @@ const Resolvers: GQLResolver = {
|
||||
Tag,
|
||||
Time,
|
||||
User,
|
||||
UserModerationScopes,
|
||||
Queue,
|
||||
Queues,
|
||||
UsernameHistory,
|
||||
|
||||
@@ -46,10 +46,13 @@ is used without options, it simply requires a logged in user. `permit` can be
|
||||
used to allow specific `USER_AUTH_CONDITIONS` that normally (if present) would
|
||||
deny access to any edge associated with the `@auth` directive. If a User has
|
||||
only some of the conditions listed, they will pass, but if they have at least
|
||||
one more that isn't in the list, the request will be denied.
|
||||
one more that isn't in the list, the request will be denied. If `unscoped` is
|
||||
provided and is `true`, it will require that the user does not have any
|
||||
moderation scopes applied to their account (such as a site moderator).
|
||||
"""
|
||||
directive @auth(
|
||||
roles: [USER_ROLE!]
|
||||
unscoped: Boolean
|
||||
userIDField: String
|
||||
permit: [USER_AUTH_CONDITIONS!]
|
||||
) on FIELD_DEFINITION
|
||||
@@ -387,6 +390,12 @@ enum FEATURE_FLAG {
|
||||
DEFAULT_QA_STORY_MODE will set the story mode to QA by default.
|
||||
"""
|
||||
DEFAULT_QA_STORY_MODE
|
||||
|
||||
"""
|
||||
SITE_MODERATOR will enable the enhanced Site Moderator role by assigning a new
|
||||
modeartion scope to a user.
|
||||
"""
|
||||
SITE_MODERATOR
|
||||
}
|
||||
|
||||
# The moderation mode of the site.
|
||||
@@ -1510,6 +1519,12 @@ type Site {
|
||||
"""
|
||||
allowedOrigins: [String!]!
|
||||
|
||||
"""
|
||||
canModerate when true indicates that the current user can moderate comments
|
||||
left on this Site.
|
||||
"""
|
||||
canModerate: Boolean!
|
||||
|
||||
"""
|
||||
createdAt is when the site was created.
|
||||
"""
|
||||
@@ -2122,6 +2137,24 @@ type UserNotificationSettings {
|
||||
digestFrequency: DIGEST_FREQUENCY!
|
||||
}
|
||||
|
||||
"""
|
||||
UserModerationScopes describes the scopes for moderation. These only apply when
|
||||
the user has a MODERATOR role.
|
||||
"""
|
||||
type UserModerationScopes {
|
||||
"""
|
||||
scoped returns true when the moderator has a scoped moderation role, and
|
||||
cannot moderate organization wide.
|
||||
"""
|
||||
scoped: Boolean!
|
||||
|
||||
"""
|
||||
sites (when not null) list the sites that the user is only allowed to moderate
|
||||
on. When null, it means that the user is allowed to moderate on all sites.
|
||||
"""
|
||||
sites: [Site!]
|
||||
}
|
||||
|
||||
"""
|
||||
User is someone that leaves Comments, and logs in.
|
||||
"""
|
||||
@@ -2318,6 +2351,13 @@ type User {
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
)
|
||||
|
||||
"""
|
||||
moderationScopes describes the scopes for moderation. These only apply when
|
||||
the user has a MODERATOR role.
|
||||
"""
|
||||
moderationScopes: UserModerationScopes
|
||||
@auth(userIDField: "id", roles: [ADMIN, MODERATOR])
|
||||
|
||||
"""
|
||||
ssoURL is the url for managing sso account
|
||||
"""
|
||||
@@ -2748,6 +2788,11 @@ type Comment {
|
||||
"""
|
||||
deleted: Boolean
|
||||
|
||||
"""
|
||||
canModerate returns true if the current user can moderate this Comment.
|
||||
"""
|
||||
canModerate: Boolean!
|
||||
|
||||
"""
|
||||
site is the Site referenced by the Story for this Comment.
|
||||
"""
|
||||
@@ -3082,6 +3127,12 @@ type Story {
|
||||
"""
|
||||
lastCommentedAt: Time @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
canModerate returns true if the current user can moderate comments on this
|
||||
Story.
|
||||
"""
|
||||
canModerate: Boolean!
|
||||
|
||||
"""
|
||||
site is the site associated with the story
|
||||
"""
|
||||
@@ -4167,7 +4218,6 @@ input NewCommentersConfigurationInput {
|
||||
approvedCommentsThreshold: Int
|
||||
}
|
||||
|
||||
|
||||
"""
|
||||
RTEConfigurationInput specifies the configuration for the rte.
|
||||
"""
|
||||
@@ -4313,7 +4363,6 @@ input SettingsInput {
|
||||
rte: RTEConfigurationInput
|
||||
}
|
||||
|
||||
|
||||
"""
|
||||
UpdateSettingsInput provides the input for the updateSettings Mutation.
|
||||
"""
|
||||
@@ -4521,29 +4570,6 @@ type CreateCommentFlagPayload {
|
||||
clientMutationId: String!
|
||||
}
|
||||
|
||||
##################
|
||||
## regenerateSSOKey
|
||||
##################
|
||||
|
||||
input RegenerateSSOKeyInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
}
|
||||
|
||||
type RegenerateSSOKeyPayload {
|
||||
"""
|
||||
settings is the Settings that the SSO secret was regenerated on.
|
||||
"""
|
||||
settings: Settings
|
||||
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
}
|
||||
|
||||
##################
|
||||
## createStory
|
||||
##################
|
||||
@@ -6038,13 +6064,59 @@ type UpdateUserAvatarPayload {
|
||||
clientMutationId: String!
|
||||
}
|
||||
|
||||
##################
|
||||
# updateUserModerationScopes
|
||||
##################
|
||||
|
||||
"""
|
||||
ModerationScopesInput describes the different scopes that a given moderator
|
||||
could be assigned.
|
||||
"""
|
||||
input ModerationScopesInput {
|
||||
"""
|
||||
siteIDs is an array of ID's that should be the list of sites that a moderator
|
||||
is limited to. If none are passed, it will remove the scoping for this User.
|
||||
"""
|
||||
siteIDs: [String!]
|
||||
}
|
||||
|
||||
input UpdateUserModerationScopesInput {
|
||||
"""
|
||||
userID is the ID of the User that should have their role updated.
|
||||
"""
|
||||
userID: ID!
|
||||
|
||||
"""
|
||||
moderationScopes defines the scopes that a given moderator should be limited
|
||||
to when
|
||||
"""
|
||||
moderationScopes: ModerationScopesInput!
|
||||
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
}
|
||||
|
||||
type UpdateUserModerationScopesPayload {
|
||||
"""
|
||||
user is the possibly modified User.
|
||||
"""
|
||||
user: User!
|
||||
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
}
|
||||
|
||||
##################
|
||||
# updateUserRole
|
||||
##################
|
||||
|
||||
input UpdateUserRoleInput {
|
||||
"""
|
||||
userID is the ID of the User that should have their avatar updated.
|
||||
userID is the ID of the User that should have their role updated.
|
||||
"""
|
||||
userID: ID!
|
||||
|
||||
@@ -6739,16 +6811,6 @@ type Mutation {
|
||||
updateSettings(input: UpdateSettingsInput!): UpdateSettingsPayload!
|
||||
@auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
regenerateSSOKey will regenerate the SSO secret used to sign secrets. This will
|
||||
invalidate any existing user sessions.
|
||||
|
||||
DEPRECATED: deprecated in favour of `rotateSSOSigningSecret`, remove in 6.2.0.
|
||||
"""
|
||||
regenerateSSOKey(input: RegenerateSSOKeyInput!): RegenerateSSOKeyPayload!
|
||||
@auth(roles: [ADMIN])
|
||||
@deprecated(reason: "deprecated in favour of `rotateSSOSigningSecret`")
|
||||
|
||||
"""
|
||||
rotateSSOSigningSecret can be used to rotate a given active SigningSecret.
|
||||
"""
|
||||
@@ -7010,17 +7072,26 @@ type Mutation {
|
||||
updateUserRole(input: UpdateUserRoleInput!): UpdateUserRolePayload!
|
||||
@auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
updateUserModerationScopes will update the moderation scopes for a given user.
|
||||
This is used to limit the scopes for which a given moderator can moderate if
|
||||
they also have the moderator role.
|
||||
"""
|
||||
updateUserModerationScopes(
|
||||
input: UpdateUserModerationScopesInput!
|
||||
): UpdateUserModerationScopesPayload! @auth(roles: [ADMIN])
|
||||
|
||||
"""
|
||||
banUser will ban a specific User from interacting with Comments.
|
||||
"""
|
||||
banUser(input: BanUserInput!): BanUserPayload!
|
||||
@auth(roles: [ADMIN, MODERATOR])
|
||||
@auth(roles: [ADMIN, MODERATOR], unscoped: true)
|
||||
|
||||
"""
|
||||
removeUserBan will remove an active ban from a User if they have one.
|
||||
"""
|
||||
removeUserBan(input: RemoveUserBanInput!): RemoveUserBanPayload!
|
||||
@auth(roles: [ADMIN, MODERATOR])
|
||||
@auth(roles: [ADMIN, MODERATOR], unscoped: true)
|
||||
|
||||
"""
|
||||
suspendUser will suspend a specific User from interacting with Comments.
|
||||
@@ -7230,8 +7301,7 @@ type Mutation {
|
||||
"""
|
||||
testSMTP sends a test email.
|
||||
"""
|
||||
testSMTP(input: TestSMTPInput!): TestSMTPPayload!
|
||||
@auth(roles: [ADMIN, MODERATOR])
|
||||
testSMTP(input: TestSMTPInput!): TestSMTPPayload! @auth(roles: [ADMIN])
|
||||
}
|
||||
|
||||
##################
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { GQLUSER_ROLE } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
export const MODERATOR_ROLES = [GQLUSER_ROLE.ADMIN, GQLUSER_ROLE.MODERATOR];
|
||||
|
||||
export const STAFF_ROLES = [
|
||||
GQLUSER_ROLE.ADMIN,
|
||||
GQLUSER_ROLE.MODERATOR,
|
||||
|
||||
@@ -4,8 +4,14 @@ import { SSOUserProfile } from "coral-server/app/middleware/passport/strategies/
|
||||
|
||||
import { GQLUSER_ROLE } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { STAFF_ROLES } from "./constants";
|
||||
import { LocalProfile, Profile, SSOProfile, User } from "./user";
|
||||
import { MODERATOR_ROLES, STAFF_ROLES } from "./constants";
|
||||
import {
|
||||
LocalProfile,
|
||||
Profile,
|
||||
SSOProfile,
|
||||
User,
|
||||
UserModerationScopes,
|
||||
} from "./user";
|
||||
|
||||
export function roleIsStaff(role: GQLUSER_ROLE) {
|
||||
if (STAFF_ROLES.includes(role)) {
|
||||
@@ -19,6 +25,92 @@ export function hasStaffRole(user: Pick<User, "role">) {
|
||||
return roleIsStaff(user.role);
|
||||
}
|
||||
|
||||
function roleIsModerator(role: GQLUSER_ROLE) {
|
||||
if (MODERATOR_ROLES.includes(role)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
export function hasModeratorRole(user: Pick<User, "role">) {
|
||||
return roleIsModerator(user.role);
|
||||
}
|
||||
|
||||
export function isModerationScoped(moderationScopes?: UserModerationScopes) {
|
||||
return (
|
||||
!!moderationScopes &&
|
||||
!!moderationScopes.siteIDs &&
|
||||
moderationScopes.siteIDs.length > 0
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* canModerateUnscoped will check if a given user is unscoped (without any
|
||||
* restrictions) on their moderation capacity.
|
||||
*
|
||||
* @param user the user being checked for moderation scopes
|
||||
*/
|
||||
export function canModerateUnscoped(
|
||||
user: Pick<User, "role" | "moderationScopes">
|
||||
) {
|
||||
// You can't possibly be a global moderator if you don't at least have a
|
||||
// moderator compatible role.
|
||||
if (!hasModeratorRole(user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If you specifically have a moderator role, then if there is a siteID
|
||||
// restricting which sites you can moderate on, then you are not a global
|
||||
// moderator.
|
||||
if (
|
||||
user.role === GQLUSER_ROLE.MODERATOR &&
|
||||
isModerationScoped(user.moderationScopes)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export interface ModerationScopeResource {
|
||||
siteID?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* canModerate checks against the moderation scopes to determine if the current
|
||||
* user can moderate the given scope.
|
||||
*
|
||||
* @param user the user to check moderation scopes on
|
||||
* @param scopes the scopes to check against
|
||||
*/
|
||||
export function canModerate(
|
||||
user: Pick<User, "role" | "moderationScopes">,
|
||||
{ siteID }: ModerationScopeResource
|
||||
) {
|
||||
// You can't possibly moderate if you don't at least have a moderator
|
||||
// compatible role.
|
||||
if (!hasModeratorRole(user)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the user is a moderator, then if the user is scoped to only moderate
|
||||
// specific sites, then ensure they can moderate _this_ siteID.
|
||||
if (
|
||||
user.role === GQLUSER_ROLE.MODERATOR &&
|
||||
user.moderationScopes &&
|
||||
siteID &&
|
||||
user.moderationScopes.siteIDs &&
|
||||
!user.moderationScopes.siteIDs.includes(siteID)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// The moderator does not have any scopes that prevent them from moderating
|
||||
// this comment.
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getUserProfile(
|
||||
user: Pick<User, "profiles">,
|
||||
type: Profile["type"]
|
||||
|
||||
@@ -363,6 +363,14 @@ export interface UserCommentCounts {
|
||||
status: CommentStatusCounts;
|
||||
}
|
||||
|
||||
export interface UserModerationScopes {
|
||||
/**
|
||||
* siteIDs is the array of site ID's that this User can moderate. If not
|
||||
* provided the user can moderate all sites.
|
||||
*/
|
||||
siteIDs?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* User is someone that leaves Comments, and logs in.
|
||||
*/
|
||||
@@ -432,6 +440,12 @@ export interface User extends TenantResource {
|
||||
*/
|
||||
role: GQLUSER_ROLE;
|
||||
|
||||
/**
|
||||
* moderationScopes describes the scopes for moderation. These only apply when
|
||||
* the user has a MODERATOR role.
|
||||
*/
|
||||
moderationScopes?: UserModerationScopes;
|
||||
|
||||
/**
|
||||
* notifications stores the notification settings for the given User.
|
||||
*/
|
||||
@@ -743,6 +757,28 @@ export async function updateUserRole(
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export async function updateUserModerationScopes(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
moderationScopes: UserModerationScopes
|
||||
) {
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{ id, tenantID },
|
||||
{ $set: { moderationScopes } },
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export async function verifyUserPassword(
|
||||
user: Pick<User, "profiles">,
|
||||
password: string,
|
||||
|
||||
@@ -12,30 +12,6 @@ import {
|
||||
|
||||
import { TenantCache } from "./cache";
|
||||
|
||||
/**
|
||||
* regenerateSSOKey will regenerate the Single Sign-On key for the specified
|
||||
* Tenant and notify all other Tenant's connected that the Tenant was updated.
|
||||
*
|
||||
* DEPRECATED: deprecated in favour of `rotateSSOSigningSecret`, remove in 6.2.0.
|
||||
*/
|
||||
export async function regenerateSSOKey(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
cache: TenantCache,
|
||||
tenant: Tenant,
|
||||
now: Date
|
||||
) {
|
||||
// Regeneration is the same as rotating but with a specific 30 day window.
|
||||
return rotateSSOSigningSecret(
|
||||
mongo,
|
||||
redis,
|
||||
cache,
|
||||
tenant,
|
||||
30 * 24 * 60 * 60,
|
||||
now
|
||||
);
|
||||
}
|
||||
|
||||
export async function rotateSSOSigningSecret(
|
||||
mongo: Db,
|
||||
redis: Redis,
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
DuplicateUserError,
|
||||
EmailAlreadySetError,
|
||||
EmailNotSetError,
|
||||
InternalError,
|
||||
InvalidCredentialsError,
|
||||
LocalProfileAlreadySetError,
|
||||
LocalProfileNotSetError,
|
||||
@@ -29,7 +30,12 @@ import {
|
||||
} from "coral-server/errors";
|
||||
import logger from "coral-server/logger";
|
||||
import { Comment, retrieveComment } from "coral-server/models/comment";
|
||||
import { linkUsersAvailable, Tenant } from "coral-server/models/tenant";
|
||||
import { retrieveManySites } from "coral-server/models/site";
|
||||
import {
|
||||
hasFeatureFlag,
|
||||
linkUsersAvailable,
|
||||
Tenant,
|
||||
} from "coral-server/models/tenant";
|
||||
import {
|
||||
banUser,
|
||||
clearDeletionDate,
|
||||
@@ -61,11 +67,13 @@ import {
|
||||
suspendUser,
|
||||
updateUserAvatar,
|
||||
updateUserEmail,
|
||||
updateUserModerationScopes,
|
||||
updateUserNotificationSettings,
|
||||
updateUserPassword,
|
||||
updateUserRole,
|
||||
updateUserUsername,
|
||||
User,
|
||||
UserModerationScopes,
|
||||
verifyUserPassword,
|
||||
} from "coral-server/models/user";
|
||||
import {
|
||||
@@ -80,6 +88,7 @@ import { sendConfirmationEmail } from "coral-server/services/users/auth";
|
||||
|
||||
import {
|
||||
GQLAuthIntegrations,
|
||||
GQLFEATURE_FLAG,
|
||||
GQLUSER_ROLE,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
@@ -656,6 +665,39 @@ export async function updateRole(
|
||||
return updateUserRole(mongo, tenant.id, userID, role);
|
||||
}
|
||||
|
||||
export async function updateModerationScopes(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
user: Pick<User, "id">,
|
||||
userID: string,
|
||||
moderationScopes: UserModerationScopes
|
||||
) {
|
||||
if (!hasFeatureFlag(tenant, GQLFEATURE_FLAG.SITE_MODERATOR)) {
|
||||
throw new InternalError("feature flag not enabled", {
|
||||
flag: GQLFEATURE_FLAG.SITE_MODERATOR,
|
||||
});
|
||||
}
|
||||
|
||||
if (user.id === userID) {
|
||||
throw new Error("cannot update your own moderation scopes");
|
||||
}
|
||||
|
||||
// Verify that the scopes referenced exist.
|
||||
if (moderationScopes.siteIDs) {
|
||||
const sites = await retrieveManySites(
|
||||
mongo,
|
||||
tenant.id,
|
||||
moderationScopes.siteIDs
|
||||
);
|
||||
|
||||
if (sites.some((site) => site === null)) {
|
||||
throw new Error("site specified does not exist");
|
||||
}
|
||||
}
|
||||
|
||||
return updateUserModerationScopes(mongo, tenant.id, userID, moderationScopes);
|
||||
}
|
||||
|
||||
/**
|
||||
* enabledAuthenticationIntegrations returns enabled auth integrations for a tenant
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user