[CORL-155] User Suspending and Banning (#2247)

* feat: suspending, banning, now propogation

* feat: adapting to `now`

* feat: support auth for suspension/banned

* feat: added trace-id to requests

* feat: new mutation api with hooks support

* feat: added user status filtering, current field

* feat: Implement filter by status, adapt to new USER_STATUS type, add lookup helper <3

* fix: typo

* fix: tests

* chore: rename banned status to ban status

* test: feature test + lots of test helper improvements e.g. types

* fix: add translation to ban user modal

* fix: translation

* fix: test
This commit is contained in:
Wyatt Johnson
2019-04-22 22:57:32 +00:00
committed by GitHub
parent b63c00f26f
commit dbbc1af42e
147 changed files with 4609 additions and 1468 deletions
+14 -5
View File
@@ -1,3 +1,4 @@
import bunyan from "bunyan";
import uuid from "uuid";
import { LanguageCode } from "talk-common/helpers/i18n/locales";
@@ -8,6 +9,8 @@ import { I18n } from "talk-server/services/i18n";
import { Request } from "talk-server/types/express";
export interface CommonContextOptions {
id?: string;
now?: Date;
user?: User;
req?: Request;
lang?: LanguageCode;
@@ -18,22 +21,28 @@ export interface CommonContextOptions {
export default class CommonContext {
public readonly user?: User;
public readonly req?: Request;
public readonly id: string;
public readonly config: Config;
public readonly i18n: I18n;
public readonly lang: LanguageCode;
public readonly logger = logger.child({
context: "graph",
contextID: uuid.v1(),
});
public readonly now: Date;
public readonly logger: ReturnType<typeof bunyan.createLogger>;
constructor({
id = uuid.v1(),
now = new Date(),
user,
req,
config,
i18n,
lang = i18n.getDefaultLang(),
}: CommonContextOptions) {
this.id = id;
this.logger = logger.child({
context: "graph",
contextID: this.id,
});
this.now = now;
this.user = user;
this.req = req;
this.config = config;
+65 -22
View File
@@ -2,13 +2,21 @@ import { DirectiveResolverFn } from "graphql-tools";
import { memoize } from "lodash";
import { GraphQLResolveInfo, ResponsePath } from "graphql";
import { UserForbiddenError } from "talk-server/errors";
import {
UserBanned,
UserForbiddenError,
UserSuspended,
} from "talk-server/errors";
import CommonContext from "talk-server/graph/common/context";
import {
GQLUSER_AUTH_CONDITIONS,
GQLUSER_ROLE,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { User } from "talk-server/models/user";
import {
consolidateUserStatus,
consolidateUserSuspensionStatus,
User,
} from "talk-server/models/user";
// Replace `memoize.Cache`.
memoize.Cache = WeakMap;
@@ -19,7 +27,10 @@ export interface AuthDirectiveArgs {
permit?: GQLUSER_AUTH_CONDITIONS[];
}
function calculateAuthConditions(user: User): GQLUSER_AUTH_CONDITIONS[] {
function calculateAuthConditions(
user: User,
now: Date
): GQLUSER_AUTH_CONDITIONS[] {
const conditions: GQLUSER_AUTH_CONDITIONS[] = [];
if (!user.username) {
@@ -30,6 +41,16 @@ function calculateAuthConditions(user: User): GQLUSER_AUTH_CONDITIONS[] {
conditions.push(GQLUSER_AUTH_CONDITIONS.MISSING_EMAIL);
}
// Compute the user status.
const status = consolidateUserStatus(user.status, now);
if (status.ban.active) {
conditions.push(GQLUSER_AUTH_CONDITIONS.BANNED);
}
if (status.suspension.active) {
conditions.push(GQLUSER_AUTH_CONDITIONS.SUSPENDED);
}
return conditions.sort();
}
@@ -74,32 +95,53 @@ const auth: DirectiveResolverFn<
next,
src,
{ roles, userIDField, permit }: AuthDirectiveArgs,
{ user },
{ user, now },
info
) => {
// If there is a user on the request.
if (user) {
// If the permit was not specified, then no conditions can exist on the
// User, if they do error.
const conditions = calculateAuthConditionsMemoized(user);
if (!permit && conditions.length > 0) {
throw new UserForbiddenError(
"authentication conditions not met",
calculateLocationKey(info),
user.id
);
}
// If the permit was specified, and some of the conditions for the user
// aren't in the list of permitted conditions, then error.
const conditions = calculateAuthConditionsMemoized(user, now);
if (
permit &&
conditions.some(condition => permit.indexOf(condition) === -1)
// If the permit was not specified, then no conditions can exist on the
// User, if they do error.
(!permit && conditions.length > 0) ||
// If the permit was specified, and some of the conditions for the user
// aren't in the list of permitted conditions, then error.
(permit && conditions.some(condition => !permit.includes(condition)))
) {
// Compute the resource that the user was attempting to access.
const resource = calculateLocationKey(info);
if (conditions.includes(GQLUSER_AUTH_CONDITIONS.BANNED)) {
throw new UserBanned(user.id, resource, info.operation.operation);
}
if (conditions.includes(GQLUSER_AUTH_CONDITIONS.SUSPENDED)) {
const status = consolidateUserSuspensionStatus(
user.status.suspension,
now
);
if (!status.until) {
throw new Error(
"we expected to get an `until` for a suspended user, but did not"
);
}
throw new UserSuspended(
user.id,
status.until,
resource,
info.operation.operation
);
}
throw new UserForbiddenError(
"authentication conditions not met",
calculateLocationKey(info),
user.id
resource,
info.operation.operation,
user.id,
permit,
conditions
);
}
@@ -124,7 +166,8 @@ const auth: DirectiveResolverFn<
throw new UserForbiddenError(
"user does not have permission to access the resource",
calculateLocationKey(info),
user ? user.id : null
info.operation.operation,
user ? user.id : undefined
);
};
@@ -135,7 +135,8 @@ export default (ctx: Context) => ({
retrieveSharedModerationQueueQueuesCounts(
ctx.mongo,
ctx.redis,
ctx.tenant.id
ctx.tenant.id,
ctx.now
)
),
});
@@ -64,7 +64,7 @@ export default (ctx: TenantContext) => ({
(inputs: FindOrCreateStoryInput[]) =>
Promise.all(
inputs.map(input =>
findOrCreate(ctx.mongo, ctx.tenant, input, ctx.scraperQueue)
findOrCreate(ctx.mongo, ctx.tenant, input, ctx.scraperQueue, ctx.now)
)
),
{
+57 -4
View File
@@ -3,6 +3,7 @@ import DataLoader from "dataloader";
import Context from "talk-server/graph/tenant/context";
import {
GQLUSER_ROLE,
GQLUSER_STATUS,
QueryToUsersArgs,
} from "talk-server/graph/tenant/schema/__generated__/types";
import { Connection } from "talk-server/models/helpers/connection";
@@ -13,7 +14,9 @@ import {
UserConnectionInput,
} from "talk-server/models/user";
const roleFilter = (role?: GQLUSER_ROLE): UserConnectionInput["filter"] => {
type UserConnectionFilterInput = UserConnectionInput["filter"];
const roleFilter = (role?: GQLUSER_ROLE): UserConnectionFilterInput => {
if (role) {
return { role };
}
@@ -21,7 +24,7 @@ const roleFilter = (role?: GQLUSER_ROLE): UserConnectionInput["filter"] => {
return {};
};
const queryFilter = (query?: string): UserConnectionInput["filter"] => {
const queryFilter = (query?: string): UserConnectionFilterInput => {
if (query) {
return { $text: { $search: query } };
}
@@ -29,6 +32,47 @@ const queryFilter = (query?: string): UserConnectionInput["filter"] => {
return {};
};
const statusFilter = (
now: Date,
status?: GQLUSER_STATUS
): UserConnectionFilterInput => {
switch (status) {
case GQLUSER_STATUS.ACTIVE:
return {
"status.ban.active": false,
"status.suspension.history": {
$not: {
$elemMatch: {
"from.start": {
$lte: now,
},
"from.finish": {
$gt: now,
},
},
},
},
};
case GQLUSER_STATUS.BANNED:
return { "status.ban.active": true };
case GQLUSER_STATUS.SUSPENDED:
return {
"status.suspension.history": {
$elemMatch: {
"from.start": {
$lte: now,
},
"from.finish": {
$gt: now,
},
},
},
};
default:
return {};
}
};
/**
* primeUsersFromConnection will prime a given context with the users retrieved
* via a connection.
@@ -58,16 +102,25 @@ export default (ctx: Context) => {
return {
user,
connection: ({ first = 10, after, role, query }: QueryToUsersArgs) =>
connection: ({
first = 10,
after,
role,
query,
status,
}: QueryToUsersArgs) =>
retrieveUserConnection(ctx.mongo, ctx.tenant.id, {
first,
after,
filter: {
// Merge role filters into the query.
// Merge the role filters into the query.
...roleFilter(role),
// Merge the query filters into the query.
...queryFilter(query),
// Merge the status filters into the query.
...statusFilter(ctx.now, status),
},
}).then(primeUsersFromConnection(ctx)),
};
@@ -37,6 +37,7 @@ export const Comments = (ctx: TenantContext) => ({
ctx.user!,
{ authorID: ctx.user!.id, ...comment },
nudge,
ctx.now,
ctx.req
),
{
@@ -44,6 +45,8 @@ export const Comments = (ctx: TenantContext) => ({
ERROR_CODES.COMMENT_BODY_EXCEEDS_MAX_LENGTH,
ERROR_CODES.COMMENT_BODY_TOO_SHORT,
],
"input.parentID": [ERROR_CODES.COMMENT_NOT_FOUND],
"input.storyID": [ERROR_CODES.STORY_NOT_FOUND],
}
),
edit: ({ commentID, body }: GQLEditCommentInput) =>
@@ -57,6 +60,7 @@ export const Comments = (ctx: TenantContext) => ({
id: commentID,
body,
},
ctx.now,
ctx.req
),
{
@@ -70,10 +74,17 @@ export const Comments = (ctx: TenantContext) => ({
commentID,
commentRevisionID,
}: GQLCreateCommentReactionInput) =>
createReaction(ctx.mongo, ctx.redis, ctx.tenant, ctx.user!, {
commentID,
commentRevisionID,
}),
createReaction(
ctx.mongo,
ctx.redis,
ctx.tenant,
ctx.user!,
{
commentID,
commentRevisionID,
},
ctx.now
),
removeReaction: ({ commentID }: GQLRemoveCommentReactionInput) =>
removeReaction(ctx.mongo, ctx.redis, ctx.tenant, ctx.user!, {
commentID,
@@ -83,15 +94,22 @@ export const Comments = (ctx: TenantContext) => ({
commentRevisionID,
additionalDetails,
}: GQLCreateCommentDontAgreeInput) =>
createDontAgree(ctx.mongo, ctx.redis, ctx.tenant, ctx.user!, {
commentID,
commentRevisionID,
// TODO: (wyattjoh) move this validation to the schema when bug is fixed: https://github.com/apollographql/graphql-tools/issues/842
additionalDetails: validateMaximumLength(
ADDITIONAL_DETAILS_MAX_LENGTH,
additionalDetails
),
}),
createDontAgree(
ctx.mongo,
ctx.redis,
ctx.tenant,
ctx.user!,
{
commentID,
commentRevisionID,
// TODO: (wyattjoh) move this validation to the schema when bug is fixed: https://github.com/apollographql/graphql-tools/issues/842
additionalDetails: validateMaximumLength(
ADDITIONAL_DETAILS_MAX_LENGTH,
additionalDetails
),
},
ctx.now
),
removeDontAgree: ({ commentID }: GQLRemoveCommentDontAgreeInput) =>
removeDontAgree(ctx.mongo, ctx.redis, ctx.tenant, ctx.user!, {
commentID,
@@ -102,14 +120,21 @@ export const Comments = (ctx: TenantContext) => ({
reason,
additionalDetails,
}: GQLCreateCommentFlagInput) =>
createFlag(ctx.mongo, ctx.redis, ctx.tenant, ctx.user!, {
commentID,
commentRevisionID,
reason,
// TODO: (wyattjoh) move this validation to the schema when bug is fixed: https://github.com/apollographql/graphql-tools/issues/842
additionalDetails: validateMaximumLength(
ADDITIONAL_DETAILS_MAX_LENGTH,
additionalDetails
),
}),
createFlag(
ctx.mongo,
ctx.redis,
ctx.tenant,
ctx.user!,
{
commentID,
commentRevisionID,
reason,
// TODO: (wyattjoh) move this validation to the schema when bug is fixed: https://github.com/apollographql/graphql-tools/issues/842
additionalDetails: validateMaximumLength(
ADDITIONAL_DETAILS_MAX_LENGTH,
additionalDetails
),
},
ctx.now
),
});
@@ -33,7 +33,8 @@ export const Stories = (ctx: TenantContext) => ({
ctx.tenant,
input.story.id,
input.story.url,
omitBy(input.story, isNull)
omitBy(input.story, isNull),
ctx.now
),
{
"input.story.url": [
@@ -44,7 +45,7 @@ export const Stories = (ctx: TenantContext) => ({
),
update: async (input: GQLUpdateStoryInput): Promise<Readonly<Story> | null> =>
mapFieldsetToErrorCodes(
update(ctx.mongo, ctx.tenant, input.id, input.story),
update(ctx.mongo, ctx.tenant, input.id, input.story, ctx.now),
{
"input.story.url": [
ERROR_CODES.STORY_URL_NOT_PERMITTED,
@@ -55,11 +56,11 @@ export const Stories = (ctx: TenantContext) => ({
updateSettings: async (
input: GQLUpdateStorySettingsInput
): Promise<Readonly<Story> | null> =>
updateSettings(ctx.mongo, ctx.tenant, input.id, input.settings),
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),
close(ctx.mongo, ctx.tenant, input.id, ctx.now),
open: (input: GQLOpenStoryInput): Promise<Readonly<Story> | null> =>
open(ctx.mongo, ctx.tenant, input.id),
open(ctx.mongo, ctx.tenant, input.id, ctx.now),
merge: async (input: GQLMergeStoriesInput): Promise<Readonly<Story> | null> =>
merge(
ctx.mongo,
+25 -1
View File
@@ -3,11 +3,15 @@ import { mapFieldsetToErrorCodes } from "talk-server/graph/common/errors";
import TenantContext from "talk-server/graph/tenant/context";
import { User } from "talk-server/models/user";
import {
ban,
createToken,
deactivateToken,
removeBan,
removeSuspension,
setEmail,
setPassword,
setUsername,
suspend,
updateAvatar,
updateEmail,
updatePassword,
@@ -15,11 +19,15 @@ import {
updateUsername,
} from "talk-server/services/users";
import {
GQLBanUserInput,
GQLCreateTokenInput,
GQLDeactivateTokenInput,
GQLRemoveUserBanInput,
GQLRemoveUserSuspensionInput,
GQLSetEmailInput,
GQLSetPasswordInput,
GQLSetUsernameInput,
GQLSuspendUserInput,
GQLUpdatePasswordInput,
GQLUpdateUserAvatarInput,
GQLUpdateUserEmailInput,
@@ -69,7 +77,8 @@ export const Users = (ctx: TenantContext) => ({
// NOTE: (wyattjoh) this will error if not provided.
ctx.signingConfig!,
ctx.user!,
input.name
input.name,
ctx.now
),
deactivateToken: async (input: GQLDeactivateTokenInput) =>
deactivateToken(ctx.mongo, ctx.tenant, ctx.user!, input.id),
@@ -81,4 +90,19 @@ export const Users = (ctx: TenantContext) => ({
updateAvatar(ctx.mongo, ctx.tenant, input.userID, input.avatar),
updateUserRole: async (input: GQLUpdateUserRoleInput) =>
updateRole(ctx.mongo, ctx.tenant, ctx.user!, input.userID, input.role),
ban: async (input: GQLBanUserInput) =>
ban(ctx.mongo, ctx.tenant, ctx.user!, input.userID, ctx.now),
suspend: async (input: GQLSuspendUserInput) =>
suspend(
ctx.mongo,
ctx.tenant,
ctx.user!,
input.userID,
input.timeout,
ctx.now
),
removeBan: async (input: GQLRemoveUserBanInput) =>
removeBan(ctx.mongo, ctx.tenant, ctx.user!, input.userID, ctx.now),
removeSuspension: async (input: GQLRemoveUserSuspensionInput) =>
removeSuspension(ctx.mongo, ctx.tenant, ctx.user!, input.userID, ctx.now),
});
@@ -0,0 +1,12 @@
import { GQLBanStatusTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import * as user from "talk-server/models/user";
export type BanStatusInput = user.ConsolidatedBanStatus & {
userID: string;
};
export const BanStatus: Required<GQLBanStatusTypeResolver<BanStatusInput>> = {
active: ({ active }) => active,
history: ({ history, userID }) =>
history.map(status => ({ ...status, userID })),
};
@@ -0,0 +1,16 @@
import { GQLBanStatusHistoryTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import * as user from "talk-server/models/user";
export const BanStatusHistory: Required<
GQLBanStatusHistoryTypeResolver<user.BanStatusHistory>
> = {
active: ({ active }) => active,
createdBy: ({ createdBy }, input, ctx) => {
if (createdBy) {
return ctx.loaders.Users.user.load(createdBy);
}
return null;
},
createdAt: ({ createdAt }) => createdAt,
};
@@ -131,4 +131,20 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
user: await ctx.mutators.Users.updateUserRole(input),
clientMutationId: input.clientMutationId,
}),
banUser: async (source, { input }, ctx) => ({
user: await ctx.mutators.Users.ban(input),
clientMutationId: input.clientMutationId,
}),
removeUserBan: async (source, { input }, ctx) => ({
user: await ctx.mutators.Users.removeBan(input),
clientMutationId: input.clientMutationId,
}),
suspendUser: async (source, { input }, ctx) => ({
user: await ctx.mutators.Users.suspend(input),
clientMutationId: input.clientMutationId,
}),
removeUserSuspension: async (source, { input }, ctx) => ({
user: await ctx.mutators.Users.removeSuspension(input),
clientMutationId: input.clientMutationId,
}),
};
@@ -0,0 +1,15 @@
import { GQLSuspensionStatusTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import * as user from "talk-server/models/user";
export type SuspensionStatusInput = user.ConsolidatedSuspensionStatus & {
userID: string;
};
export const SuspensionStatus: Required<
GQLSuspensionStatusTypeResolver<SuspensionStatusInput>
> = {
active: ({ active }) => active,
until: ({ until }) => until,
history: ({ history, userID }) =>
history.map(status => ({ ...status, userID })),
};
@@ -0,0 +1,26 @@
import { GQLSuspensionStatusHistoryTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import * as user from "talk-server/models/user";
export const SuspensionStatusHistory: Required<
GQLSuspensionStatusHistoryTypeResolver<user.SuspensionStatusHistory>
> = {
active: ({ from }, input, ctx) =>
from.start <= ctx.now && from.finish > ctx.now,
from: ({ from }) => from,
createdBy: ({ createdBy }, input, ctx) => {
if (createdBy) {
return ctx.loaders.Users.user.load(createdBy);
}
return null;
},
createdAt: ({ createdAt }) => createdAt,
modifiedBy: ({ modifiedBy }, input, ctx) => {
if (modifiedBy) {
return ctx.loaders.Users.user.load(modifiedBy);
}
return null;
},
modifiedAt: ({ modifiedAt }) => modifiedAt,
};
@@ -1,8 +1,14 @@
import { GQLUserTypeResolver } from "talk-server/graph/tenant/schema/__generated__/types";
import * as user from "talk-server/models/user";
import { UserStatusInput } from "./UserStatus";
export const User: GQLUserTypeResolver<user.User> = {
comments: ({ id }, input, ctx) => ctx.loaders.Comments.forUser(id, input),
commentModerationActionHistory: ({ id }, input, ctx) =>
ctx.loaders.CommentModerationActions.forModerator(input, id),
status: ({ id, status }): UserStatusInput => ({
...status,
userID: id,
}),
};
@@ -0,0 +1,46 @@
import {
GQLUSER_STATUS,
GQLUserStatusTypeResolver,
} from "talk-server/graph/tenant/schema/__generated__/types";
import * as user from "talk-server/models/user";
import { BanStatusInput } from "./BanStatus";
import { SuspensionStatusInput } from "./SuspensionStatus";
export type UserStatusInput = user.UserStatus & {
userID: string;
};
export const UserStatus: Required<
GQLUserStatusTypeResolver<UserStatusInput>
> = {
current: (status, input, ctx) => {
const consolidatedStatus = user.consolidateUserStatus(status, ctx.now);
const statuses: GQLUSER_STATUS[] = [];
// If they are currently banned, then mark it.
if (consolidatedStatus.ban.active) {
statuses.push(GQLUSER_STATUS.BANNED);
}
// If they are currently suspended, then mark it.
if (consolidatedStatus.suspension.active) {
statuses.push(GQLUSER_STATUS.SUSPENDED);
}
// If no other statuses were applied, then apply the active status.
if (statuses.length === 0) {
statuses.push(GQLUSER_STATUS.ACTIVE);
}
return statuses;
},
ban: ({ ban, userID }): BanStatusInput => ({
...user.consolidateUserBanStatus(ban),
userID,
}),
suspension: ({ suspension, userID }): SuspensionStatusInput => ({
...user.consolidateUserSuspensionStatus(suspension),
userID,
}),
};
@@ -4,6 +4,8 @@ import { GQLResolver } from "talk-server/graph/tenant/schema/__generated__/types
import { AcceptCommentPayload } from "./AcceptCommentPayload";
import { AuthIntegrations } from "./AuthIntegrations";
import { BanStatus } from "./BanStatus";
import { BanStatusHistory } from "./BanStatusHistory";
import { CloseCommenting } from "./CloseCommenting";
import { Comment } from "./Comment";
import { CommentCounts } from "./CommentCounts";
@@ -21,12 +23,17 @@ import { Query } from "./Query";
import { RejectCommentPayload } from "./RejectCommentPayload";
import { Story } from "./Story";
import { StorySettings } from "./StorySettings";
import { SuspensionStatus } from "./SuspensionStatus";
import { SuspensionStatusHistory } from "./SuspensionStatusHistory";
import { Tag } from "./Tag";
import { User } from "./User";
import { UserStatus } from "./UserStatus";
const Resolvers: GQLResolver = {
AcceptCommentPayload,
AuthIntegrations,
BanStatus,
BanStatusHistory,
CloseCommenting,
Comment,
CommentCounts,
@@ -45,9 +52,12 @@ const Resolvers: GQLResolver = {
RejectCommentPayload,
Story,
StorySettings,
SuspensionStatus,
SuspensionStatusHistory,
Tag,
Time,
User,
UserStatus,
};
export default Resolvers;
@@ -18,6 +18,16 @@ enum USER_AUTH_CONDITIONS {
address.
"""
MISSING_EMAIL
"""
BANNED is provided when the User is currently banned.
"""
BANNED
"""
SUSPENDED is provided when the User is currently under an active suspension.
"""
SUSPENDED
}
"""
@@ -1038,6 +1048,11 @@ type Settings {
reaction specifies the configuration for reactions.
"""
reaction: ReactionConfiguration!
"""
createdAt is the time that the Settings was created at.
"""
createdAt: Time! @auth(roles: [ADMIN])
}
################################################################################
@@ -1072,6 +1087,10 @@ type GoogleProfile {
id: String!
}
"""
Profile is all the different profiles that a given User may have associated
with their account.
"""
union Profile =
LocalProfile
| OIDCProfile
@@ -1079,12 +1098,188 @@ union Profile =
| FacebookProfile
| GoogleProfile
"""
Token facilitates accessing Talk externally with a token.
"""
type Token {
id: ID!
name: String!
createdAt: Time!
}
"""
BanStatusHistory is the list of all ban events against a specific User.
"""
type BanStatusHistory {
"""
active when true, indicates that the given user is banned.
"""
active: Boolean!
"""
createdBy is the User that suspended the User. If `null`, the then the given
User was banned by the system.
"""
createdBy: User
"""
createdAt is the time that the given User was banned.
"""
createdAt: Time!
}
"""
BanStatus contains information about a ban for a given User.
"""
type BanStatus {
"""
active when true, indicates that the given user is banned.
"""
active: Boolean!
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "userID"
permit: [SUSPENDED, BANNED]
)
"""
history is the list of all ban events against a specific User.
"""
history: [BanStatusHistory!]! @auth(roles: [ADMIN, MODERATOR])
}
"""
TimeRange represents a range of times.
"""
type TimeRange {
"""
start is the time that the time range started on.
"""
start: Time!
"""
finish is the time that the time range finished at.
"""
finish: Time!
}
"""
SuspensionStatusHistory is the list of all suspension events against a specific User.
"""
type SuspensionStatusHistory {
"""
active is true when the given suspension status time range applies now.
"""
active: Boolean!
"""
from is the time range that the suspension is active for.
"""
from: TimeRange!
"""
createdBy is the User that suspended the User. If `null`, the then the given
User was suspended by the system.
"""
createdBy: User
"""
createdAt is the time that the suspension was created at.
"""
createdAt: Time!
"""
modifiedBy is the User that cancelled/edited the suspension. If `null`, then
the suspension has not been cancelled/edited, or has been edited by the
system.
"""
modifiedBy: User
"""
modifiedAt is the time that the suspension was cancelled/edited. If `null`,
then the suspension has not been cancelled/edited.
"""
modifiedAt: Time
}
"""
SuspensionStatus stores the user suspension status as well as the history of
changes.
"""
type SuspensionStatus {
"""
active when true, indicates that the given user is suspended.
"""
active: Boolean!
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "userID"
permit: [SUSPENDED, BANNED]
)
"""
until is the time that the current user suspension is over.
"""
until: Time
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "userID"
permit: [SUSPENDED, BANNED]
)
"""
history is the list of all suspension events against a specific User.
"""
history: [SuspensionStatusHistory!]! @auth(roles: [ADMIN, MODERATOR])
}
"""
UserStatus stores the user status information regarding moderation state.
"""
type UserStatus {
"""
current represents the current statuses applied to the User.
"""
current: [USER_STATUS!]!
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "userID"
permit: [SUSPENDED, BANNED]
)
"""
banned stores the user banned status as well as the history of changes.
"""
ban: BanStatus!
"""
suspension stores the user suspension status as well as the history of
changes.
"""
suspension: SuspensionStatus!
}
"""
USER_STATUS is used to describe the current state of a User. A User may exist in
multiple states.
"""
enum USER_STATUS {
"""
ACTIVE is used when a User is not suspended or banned.
"""
ACTIVE
"""
BANNED is used when a User is banned.
"""
BANNED
"""
SUSPENDED is used when a User is currently suspended.
"""
SUSPENDED
}
"""
User is someone that leaves Comments, and logs in.
"""
@@ -1099,6 +1294,11 @@ type User {
"""
username: String
"""
avatar is the url to the avatar for a specific User.
"""
avatar: String
"""
email is the current email address for the User.
"""
@@ -1106,9 +1306,15 @@ type User {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "id"
permit: [MISSING_NAME, MISSING_EMAIL]
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED]
)
"""
emailVerified when true indicates that the given email address has been
verified.
"""
emailVerified: Boolean @auth(roles: [ADMIN, MODERATOR], userIDField: "id")
"""
profiles is the array of profiles assigned to the user.
"""
@@ -1116,13 +1322,18 @@ type User {
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "id"
permit: [MISSING_NAME, MISSING_EMAIL]
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED]
)
"""
role is the current role of the User.
"""
role: USER_ROLE! @auth(roles: [ADMIN, MODERATOR], userIDField: "id")
role: USER_ROLE!
@auth(
roles: [ADMIN, MODERATOR]
userIDField: "id"
permit: [SUSPENDED, BANNED]
)
"""
comments are the comments of the User.
@@ -1142,6 +1353,11 @@ type User {
after: Cursor
): CommentModerationActionConnection! @auth(roles: [MODERATOR, ADMIN])
"""
status stores the user status information regarding moderation state.
"""
status: UserStatus!
"""
tokens lists the access tokens associated with the account.
"""
@@ -1807,17 +2023,22 @@ type Query {
"""
user will return the user referenced by their ID.
TODO: evaluate adding a profile based lookup.
"""
user(id: ID!): User @auth(roles: [ADMIN, MODERATOR])
"""
users returns filtered users that can be paginated.
TODO: evaluate adding status based filtering.
"""
users(
first: Int = 10
after: Cursor
role: USER_ROLE
query: String
status: USER_STATUS
): UsersConnection! @auth(roles: [ADMIN, MODERATOR])
"""
@@ -3487,6 +3708,125 @@ type UpdateUserRolePayload {
clientMutationId: String!
}
##################
# banUser
##################
input BanUserInput {
"""
userID is the ID of the User that should have their account banned.
"""
userID: ID!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type BanUserPayload {
"""
user is the possibly modified User.
"""
user: User!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
# suspendUser
##################
input SuspendUserInput {
"""
userID is the ID of the User that should be suspended.
"""
userID: ID!
"""
timeout is the length of time (in seconds) that a User should be suspended
for.
"""
timeout: Int!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type SuspendUserPayload {
"""
user is the possibly modified User.
"""
user: User!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
# removeUserBan
##################
input RemoveUserBanInput {
"""
userID is the ID of the User that should have their account un-banned.
"""
userID: ID!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type RemoveUserBanPayload {
"""
user is the possibly modified User.
"""
user: User!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
# removeUserSuspension
##################
input RemoveUserSuspensionInput {
"""
userID is the ID of the User that should have their active suspensions
removed.
"""
userID: ID!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
type RemoveUserSuspensionPayload {
"""
user is the possibly modified User.
"""
user: User!
"""
clientMutationId is required for Relay support.
"""
clientMutationId: String!
}
##################
## Mutation
##################
@@ -3630,14 +3970,14 @@ type Mutation {
before. This mutation will fail if the username is already set.
"""
setUsername(input: SetUsernameInput!): SetUsernamePayload!
@auth(permit: [MISSING_NAME, MISSING_EMAIL])
@auth(permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED])
"""
setEmail will set the email address on the current User if they have not set
one already. This mutation will fail if the email address is already set.
"""
setEmail(input: SetEmailInput!): SetEmailPayload!
@auth(permit: [MISSING_EMAIL])
@auth(permit: [MISSING_EMAIL, SUSPENDED, BANNED])
"""
setPassword will set the password on the current User if they have not set
@@ -3692,4 +4032,30 @@ type Mutation {
"""
updateUserRole(input: UpdateUserRoleInput!): UpdateUserRolePayload!
@auth(roles: [ADMIN])
"""
banUser will ban a specific User from interacting with Comments.
"""
banUser(input: BanUserInput!): BanUserPayload!
@auth(roles: [ADMIN, MODERATOR])
"""
removeUserBan will remove an active ban from a User if they have one.
"""
removeUserBan(input: RemoveUserBanInput!): RemoveUserBanPayload!
@auth(roles: [ADMIN, MODERATOR])
"""
suspendUser will suspend a specific User from interacting with Comments.
"""
suspendUser(input: SuspendUserInput!): SuspendUserPayload!
@auth(roles: [ADMIN, MODERATOR])
"""
removeUserSuspension will remove an active suspension from a User if they have
one.
"""
removeUserSuspension(
input: RemoveUserSuspensionInput!
): RemoveUserSuspensionPayload! @auth(roles: [ADMIN, MODERATOR])
}