mirror of
https://github.com/wassname/talk.git
synced 2026-08-10 12:41:02 +08:00
[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:
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
),
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user