[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:
Wyatt Johnson
2020-07-13 18:19:22 +00:00
committed by GitHub
parent 00e074d49d
commit 7b2cdbad49
125 changed files with 2663 additions and 972 deletions
+23 -3
View File
@@ -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
);
};
+24 -6
View File
@@ -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
),
);
},
});
+43 -26
View File
@@ -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) =>
+75 -12
View File
@@ -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
);
},
});
+21 -5
View File
@@ -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) =>
+107
View File
@@ -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 })),
+4 -5
View File
@@ -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,
+27
View File
@@ -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 });
},
};
+20
View File
@@ -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),
+24 -5
View File
@@ -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;
},
};
+4
View File
@@ -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,
+111 -41
View File
@@ -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])
}
##################