mirror of
https://github.com/wassname/talk.git
synced 2026-08-05 13:30:26 +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:
@@ -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])
|
||||
}
|
||||
|
||||
##################
|
||||
|
||||
Reference in New Issue
Block a user