mirror of
https://github.com/wassname/talk.git
synced 2026-07-24 13:20:47 +08:00
[CORL-1249] Add "warn" status (#3100)
* schema and backend for warning users * allow warning and warning acknowledgement * include warning actions in user history drawer * fix fixture * add copy to warn modal * style warning on stream side * localize strings * update warning form * fix fixtures * copy updates * clean up error messaging * userefetch for users * ensure user status is refreshed on warn error * add details about warning to user status details * update copy for unacknowledged warning * fix merge conflict * update copy * remove unused code * clean up warning css * sort imports * fix copy in comments Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
parent
d001fcd456
commit
abfcdcc933
@@ -664,6 +664,20 @@ export class UserSuspended extends CoralError {
|
||||
}
|
||||
}
|
||||
|
||||
export class UserWarned extends CoralError {
|
||||
constructor(
|
||||
userID: string,
|
||||
message?: string,
|
||||
resource?: string,
|
||||
operation?: string
|
||||
) {
|
||||
super({
|
||||
code: ERROR_CODES.USER_WARNED,
|
||||
context: { pvt: { resource, operation, userID }, pub: { message } },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class UserCannotBeIgnoredError extends CoralError {
|
||||
constructor(userID: string) {
|
||||
super({
|
||||
|
||||
@@ -41,6 +41,7 @@ export const ERROR_TRANSLATIONS: Record<ERROR_CODES, string> = {
|
||||
USER_ALREADY_BANNED: "error-userAlreadyBanned",
|
||||
USER_BANNED: "error-userBanned",
|
||||
USER_SUSPENDED: "error-userSuspended",
|
||||
USER_WARNED: "error-userWarned",
|
||||
INTEGRATION_DISABLED: "error-integrationDisabled",
|
||||
PASSWORD_RESET_TOKEN_EXPIRED: "error-passwordResetTokenExpired",
|
||||
EMAIL_CONFIRM_TOKEN_EXPIRED: "error-emailConfirmTokenExpired",
|
||||
|
||||
@@ -5,11 +5,13 @@ import {
|
||||
UserBanned,
|
||||
UserForbiddenError,
|
||||
UserSuspended,
|
||||
UserWarned,
|
||||
} from "coral-server/errors";
|
||||
import GraphContext from "coral-server/graph/context";
|
||||
import {
|
||||
consolidateUserStatus,
|
||||
consolidateUserSuspensionStatus,
|
||||
consolidateUserWarningStatus,
|
||||
User,
|
||||
} from "coral-server/models/user";
|
||||
import { canModerateUnscoped } from "coral-server/models/user/helpers";
|
||||
@@ -59,6 +61,10 @@ function calculateAuthConditions(
|
||||
conditions.push(GQLUSER_AUTH_CONDITIONS.PENDING_DELETION);
|
||||
}
|
||||
|
||||
if (status.warning && status.warning.active) {
|
||||
conditions.push(GQLUSER_AUTH_CONDITIONS.WARNED);
|
||||
}
|
||||
|
||||
return conditions.sort();
|
||||
}
|
||||
|
||||
@@ -92,6 +98,16 @@ const auth: DirectiveResolverFn<
|
||||
throw new UserBanned(user.id, resource, info.operation.operation);
|
||||
}
|
||||
|
||||
if (conditions.includes(GQLUSER_AUTH_CONDITIONS.WARNED)) {
|
||||
const warning = consolidateUserWarningStatus(user.status.warning);
|
||||
throw new UserWarned(
|
||||
user.id,
|
||||
warning.message,
|
||||
resource,
|
||||
info.operation.operation
|
||||
);
|
||||
}
|
||||
|
||||
if (conditions.includes(GQLUSER_AUTH_CONDITIONS.SUSPENDED)) {
|
||||
const status = consolidateUserSuspensionStatus(
|
||||
user.status.suspension,
|
||||
|
||||
@@ -3,6 +3,7 @@ import GraphContext from "coral-server/graph/context";
|
||||
import { mapFieldsetToErrorCodes } from "coral-server/graph/errors";
|
||||
import { User } from "coral-server/models/user";
|
||||
import {
|
||||
acknowledgeWarning,
|
||||
addModeratorNote,
|
||||
ban,
|
||||
cancelAccountDeletion,
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
removeIgnore,
|
||||
removePremod,
|
||||
removeSuspension,
|
||||
removeWarning,
|
||||
requestAccountDeletion,
|
||||
requestCommentsDownload,
|
||||
requestUserCommentsDownload,
|
||||
@@ -32,6 +34,7 @@ import {
|
||||
updateRole,
|
||||
updateUsername,
|
||||
updateUsernameByID,
|
||||
warn,
|
||||
} from "coral-server/services/users";
|
||||
import { invite } from "coral-server/services/users/auth/invite";
|
||||
import { deleteUser } from "coral-server/services/users/delete";
|
||||
@@ -51,6 +54,7 @@ import {
|
||||
GQLRemoveUserBanInput,
|
||||
GQLRemoveUserIgnoreInput,
|
||||
GQLRemoveUserSuspensionInput,
|
||||
GQLRemoveUserWarningInput,
|
||||
GQLRequestAccountDeletionInput,
|
||||
GQLRequestCommentsDownloadInput,
|
||||
GQLRequestUserCommentsDownloadInput,
|
||||
@@ -68,6 +72,7 @@ import {
|
||||
GQLUpdateUsernameInput,
|
||||
GQLUpdateUserRoleInput,
|
||||
GQLUpdateUserUsernameInput,
|
||||
GQLWarnUserInput,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import { WithoutMutationID } from "./util";
|
||||
@@ -258,6 +263,19 @@ export const Users = (ctx: GraphContext) => ({
|
||||
rejectExistingComments,
|
||||
ctx.now
|
||||
),
|
||||
warn: async (input: GQLWarnUserInput) =>
|
||||
warn(
|
||||
ctx.mongo,
|
||||
ctx.tenant,
|
||||
ctx.user!,
|
||||
input.userID,
|
||||
input.message,
|
||||
ctx.now
|
||||
),
|
||||
removeWarning: async (input: GQLRemoveUserWarningInput) =>
|
||||
removeWarning(ctx.mongo, ctx.tenant, ctx.user!, input.userID, ctx.now),
|
||||
acknowledgeWarning: async () =>
|
||||
acknowledgeWarning(ctx.mongo, ctx.tenant, ctx.user!.id, ctx.now),
|
||||
premodUser: async (input: GQLPremodUserInput) =>
|
||||
premod(ctx.mongo, ctx.tenant, ctx.user!, input.userID, ctx.now),
|
||||
suspend: async (input: GQLSuspendUserInput) =>
|
||||
|
||||
@@ -199,6 +199,18 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
|
||||
user: await ctx.mutators.Users.removeBan(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
warnUser: async (source, { input }, ctx) => ({
|
||||
user: await ctx.mutators.Users.warn(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
removeUserWarning: async (source, { input }, ctx) => ({
|
||||
user: await ctx.mutators.Users.removeWarning(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
acknowledgeWarning: async (source, { input }, ctx) => ({
|
||||
user: await ctx.mutators.Users.acknowledgeWarning(),
|
||||
clientMutationId: input.clientMutationId,
|
||||
}),
|
||||
suspendUser: async (source, { input }, ctx) => ({
|
||||
user: await ctx.mutators.Users.suspend(input),
|
||||
clientMutationId: input.clientMutationId,
|
||||
|
||||
@@ -9,6 +9,7 @@ import { BanStatusInput } from "./BanStatus";
|
||||
import { PremodStatusInput } from "./PremodStatus";
|
||||
import { SuspensionStatusInput } from "./SuspensionStatus";
|
||||
import { UsernameStatusInput } from "./UsernameStatus";
|
||||
import { WarningStatusInput } from "./WarningStatus";
|
||||
|
||||
export type UserStatusInput = user.UserStatus & {
|
||||
userID: string;
|
||||
@@ -36,6 +37,11 @@ export const UserStatus: Required<GQLUserStatusTypeResolver<
|
||||
statuses.push(GQLUSER_STATUS.PREMOD);
|
||||
}
|
||||
|
||||
// If they have a warning, then mark it.
|
||||
if (consolidatedStatus.warning.active) {
|
||||
statuses.push(GQLUSER_STATUS.WARNED);
|
||||
}
|
||||
|
||||
// If no other statuses were applied, then apply the active status.
|
||||
if (statuses.length === 0) {
|
||||
statuses.push(GQLUSER_STATUS.ACTIVE);
|
||||
@@ -59,4 +65,8 @@ export const UserStatus: Required<GQLUserStatusTypeResolver<
|
||||
...user.consolidateUserPremodStatus(premod),
|
||||
userID,
|
||||
}),
|
||||
warning: ({ warning, userID }): WarningStatusInput => ({
|
||||
...user.consolidateUserWarningStatus(warning),
|
||||
userID,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import * as user from "coral-server/models/user";
|
||||
|
||||
import { GQLWarningStatusTypeResolver } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
export type WarningStatusInput = user.ConsolidatedWarningStatus & {
|
||||
userID: string;
|
||||
};
|
||||
|
||||
export const WarningStatus: Required<GQLWarningStatusTypeResolver<
|
||||
WarningStatusInput
|
||||
>> = {
|
||||
active: ({ active }) => active,
|
||||
message: ({ message }) => message,
|
||||
history: ({ history, userID }) =>
|
||||
history.map((status) => ({ ...status, userID })),
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as user from "coral-server/models/user";
|
||||
|
||||
import { GQLWarningStatusHistoryTypeResolver } from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
export const WarningStatusHistory: Required<GQLWarningStatusHistoryTypeResolver<
|
||||
user.WarningStatusHistory
|
||||
>> = {
|
||||
active: ({ active }) => active,
|
||||
acknowledgedAt: ({ acknowledgedAt }) => acknowledgedAt,
|
||||
createdBy: ({ createdBy }, input, ctx) => {
|
||||
return ctx.loaders.Users.user.load(createdBy);
|
||||
},
|
||||
createdAt: ({ createdAt }) => createdAt,
|
||||
message: ({ message }) => message,
|
||||
};
|
||||
@@ -60,6 +60,8 @@ import { UserModerationScopes } from "./UserModerationScopes";
|
||||
import { UsernameHistory } from "./UsernameHistory";
|
||||
import { UsernameStatus } from "./UsernameStatus";
|
||||
import { UserStatus } from "./UserStatus";
|
||||
import { WarningStatus } from "./WarningStatus";
|
||||
import { WarningStatusHistory } from "./WarningStatusHistory";
|
||||
import { WebhookEndpoint } from "./WebhookEndpoint";
|
||||
import { YouTubeMediaConfiguration } from "./YouTubeMediaConfiguration";
|
||||
|
||||
@@ -123,6 +125,8 @@ const Resolvers: GQLResolver = {
|
||||
UsernameHistory,
|
||||
UsernameStatus,
|
||||
UserStatus,
|
||||
WarningStatus,
|
||||
WarningStatusHistory,
|
||||
WebhookEndpoint,
|
||||
YouTubeMediaConfiguration,
|
||||
};
|
||||
|
||||
@@ -34,6 +34,11 @@ enum USER_AUTH_CONDITIONS {
|
||||
remain after being deleted.
|
||||
"""
|
||||
PENDING_DELETION
|
||||
|
||||
"""
|
||||
WARNED
|
||||
"""
|
||||
WARNED
|
||||
}
|
||||
|
||||
"""
|
||||
@@ -1921,7 +1926,7 @@ type BanStatus {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "userID"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2001,7 +2006,7 @@ type SuspensionStatus {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "userID"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2011,7 +2016,7 @@ type SuspensionStatus {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "userID"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2020,6 +2025,50 @@ type SuspensionStatus {
|
||||
history: [SuspensionStatusHistory!]! @auth(roles: [ADMIN, MODERATOR])
|
||||
}
|
||||
|
||||
type WarningStatusHistory {
|
||||
"""
|
||||
active when true, indicates that the given user has been warned but has not acknowledged the warning.
|
||||
"""
|
||||
active: Boolean!
|
||||
|
||||
"""
|
||||
createdBy is the user that warned the commenter
|
||||
"""
|
||||
createdBy: User!
|
||||
|
||||
"""
|
||||
createdAt is the time the user was warned
|
||||
"""
|
||||
createdAt: Time!
|
||||
|
||||
"""
|
||||
acknowledgedAt is the time the commenter acknowledged the warning. if `null` then the warning
|
||||
has not been acknowledged
|
||||
"""
|
||||
acknowledgedAt: Time
|
||||
|
||||
"""
|
||||
message is the custom message sent to the commenter
|
||||
"""
|
||||
message: String!
|
||||
}
|
||||
|
||||
type WarningStatus {
|
||||
"""
|
||||
active when true, indicates that the given user has been warned but has not acknowledged it.
|
||||
"""
|
||||
active: Boolean!
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "userID"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
message: String
|
||||
|
||||
history: [WarningStatusHistory!]! @auth(roles: [ADMIN, MODERATOR])
|
||||
}
|
||||
|
||||
type PremodStatusHistory {
|
||||
"""
|
||||
active when true, indicates that the given user is premodded.
|
||||
@@ -2057,7 +2106,7 @@ type UsernameHistory {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "userID"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2072,7 +2121,7 @@ type UsernameHistory {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "userID"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -2094,7 +2143,7 @@ type UserStatus {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "userID"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2117,6 +2166,11 @@ type UserStatus {
|
||||
premod stores the user premod status as well as the history of changes.
|
||||
"""
|
||||
premod: PremodStatus! @auth(roles: [ADMIN, MODERATOR])
|
||||
|
||||
"""
|
||||
warning stores the user warning status as well as the history of warnings
|
||||
"""
|
||||
warning: WarningStatus!
|
||||
}
|
||||
|
||||
"""
|
||||
@@ -2143,6 +2197,11 @@ enum USER_STATUS {
|
||||
PREMOD is used when a User is currently set to require pre-moderation.
|
||||
"""
|
||||
PREMOD
|
||||
|
||||
"""
|
||||
WARNED is used when a user has been warned about behaviour and has not acknowledged
|
||||
"""
|
||||
WARNED
|
||||
}
|
||||
|
||||
type ModeratorNote {
|
||||
@@ -2281,7 +2340,14 @@ type User {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "id"
|
||||
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [
|
||||
MISSING_NAME
|
||||
MISSING_EMAIL
|
||||
SUSPENDED
|
||||
BANNED
|
||||
PENDING_DELETION
|
||||
WARNED
|
||||
]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2299,7 +2365,7 @@ type User {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "id"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2309,7 +2375,14 @@ type User {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "id"
|
||||
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [
|
||||
MISSING_NAME
|
||||
MISSING_EMAIL
|
||||
SUSPENDED
|
||||
BANNED
|
||||
PENDING_DELETION
|
||||
WARNED
|
||||
]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2319,7 +2392,14 @@ type User {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "id"
|
||||
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [
|
||||
MISSING_NAME
|
||||
MISSING_EMAIL
|
||||
SUSPENDED
|
||||
BANNED
|
||||
PENDING_DELETION
|
||||
WARNED
|
||||
]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2345,7 +2425,7 @@ type User {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "id"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2369,7 +2449,10 @@ type User {
|
||||
sorted by their last comment date.
|
||||
"""
|
||||
ongoingDiscussions(limit: Int = 5 @constraint(max: 5)): [Story!]!
|
||||
@auth(userIDField: "id", permit: [SUSPENDED, BANNED, PENDING_DELETION])
|
||||
@auth(
|
||||
userIDField: "id"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
recentCommentHistory returns recent commenting history by the User.
|
||||
@@ -2397,7 +2480,7 @@ type User {
|
||||
@auth(
|
||||
roles: [ADMIN]
|
||||
userIDField: "id"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2407,14 +2490,17 @@ type User {
|
||||
@auth(
|
||||
roles: [ADMIN, MODERATOR]
|
||||
userIDField: "id"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
notifications stores the notification settings for the given User.
|
||||
"""
|
||||
notifications: UserNotificationSettings!
|
||||
@auth(userIDField: "id", permit: [SUSPENDED, BANNED, PENDING_DELETION])
|
||||
@auth(
|
||||
userIDField: "id"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
createdAt is the time that the User was created at.
|
||||
@@ -2429,7 +2515,7 @@ type User {
|
||||
@auth(
|
||||
userIDField: "id"
|
||||
roles: [ADMIN, MODERATOR]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2440,7 +2526,7 @@ type User {
|
||||
@auth(
|
||||
userIDField: "id"
|
||||
roles: [ADMIN, MODERATOR]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2450,7 +2536,7 @@ type User {
|
||||
@auth(
|
||||
userIDField: "id"
|
||||
roles: [ADMIN, MODERATOR]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2461,7 +2547,7 @@ type User {
|
||||
@auth(
|
||||
userIDField: "id"
|
||||
roles: [ADMIN, MODERATOR]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -2474,6 +2560,10 @@ type User {
|
||||
mediaSettings are the user's preferences around media stream behaviour.
|
||||
"""
|
||||
mediaSettings: UserMediaSettings
|
||||
@auth(
|
||||
userIDField: "id"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
}
|
||||
|
||||
"""
|
||||
@@ -2896,7 +2986,7 @@ type Comment {
|
||||
@auth(
|
||||
roles: [MODERATOR, ADMIN]
|
||||
userIDField: "author_id"
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -6452,15 +6542,89 @@ input UpdateUserRoleInput {
|
||||
}
|
||||
|
||||
type UpdateUserRolePayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
user is the possibly modified User.
|
||||
"""
|
||||
user: User!
|
||||
}
|
||||
|
||||
##################
|
||||
# warnUser
|
||||
##################
|
||||
input WarnUserInput {
|
||||
"""
|
||||
userID is the ID of the User that should have their account banned.
|
||||
"""
|
||||
userID: ID!
|
||||
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
message is displayed to the user in the stream
|
||||
"""
|
||||
message: String!
|
||||
}
|
||||
|
||||
type WarnUserPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
user is the possibly modified User.
|
||||
"""
|
||||
user: User!
|
||||
}
|
||||
|
||||
input RemoveUserWarningInput {
|
||||
"""
|
||||
userID is the ID of the User that should be warned.
|
||||
"""
|
||||
userID: ID!
|
||||
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
}
|
||||
|
||||
type RemoveUserWarningPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
|
||||
"""
|
||||
user is the possibly modified User.
|
||||
"""
|
||||
user: User!
|
||||
}
|
||||
|
||||
input AcknowledgeWarningInput {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
}
|
||||
|
||||
type AcknowledgeWarningPayload {
|
||||
"""
|
||||
clientMutationId is required for Relay support.
|
||||
"""
|
||||
clientMutationId: String!
|
||||
"""
|
||||
user is the possibly modified User.
|
||||
"""
|
||||
user: User!
|
||||
}
|
||||
|
||||
##################
|
||||
@@ -7279,14 +7443,21 @@ type Mutation {
|
||||
"""
|
||||
setUsername(input: SetUsernameInput!): SetUsernamePayload!
|
||||
@auth(
|
||||
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [
|
||||
MISSING_NAME
|
||||
MISSING_EMAIL
|
||||
SUSPENDED
|
||||
BANNED
|
||||
PENDING_DELETION
|
||||
WARNED
|
||||
]
|
||||
)
|
||||
|
||||
"""
|
||||
updateUsername will update the users username.
|
||||
"""
|
||||
updateUsername(input: UpdateUsernameInput!): UpdateUsernamePayload!
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED])
|
||||
@rate(seconds: 10)
|
||||
|
||||
"""
|
||||
@@ -7295,7 +7466,14 @@ type Mutation {
|
||||
"""
|
||||
setEmail(input: SetEmailInput!): SetEmailPayload!
|
||||
@auth(
|
||||
permit: [MISSING_NAME, MISSING_EMAIL, SUSPENDED, BANNED, PENDING_DELETION]
|
||||
permit: [
|
||||
MISSING_NAME
|
||||
MISSING_EMAIL
|
||||
SUSPENDED
|
||||
BANNED
|
||||
PENDING_DELETION
|
||||
WARNED
|
||||
]
|
||||
)
|
||||
|
||||
"""
|
||||
@@ -7309,7 +7487,7 @@ type Mutation {
|
||||
they already have one associated with them.
|
||||
"""
|
||||
updatePassword(input: UpdatePasswordInput!): UpdatePasswordPayload!
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED])
|
||||
@rate(seconds: 10)
|
||||
|
||||
"""
|
||||
@@ -7319,7 +7497,7 @@ type Mutation {
|
||||
requestAccountDeletion(
|
||||
input: RequestAccountDeletionInput!
|
||||
): RequestAccountDeletionPayload!
|
||||
@auth(permit: [SUSPENDED, BANNED])
|
||||
@auth(permit: [SUSPENDED, BANNED, WARNED])
|
||||
@rate(seconds: 10)
|
||||
|
||||
"""
|
||||
@@ -7335,7 +7513,7 @@ type Mutation {
|
||||
cancelAccountDeletion(
|
||||
input: CancelAccountDeletionInput!
|
||||
): CancelAccountDeletionPayload!
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED])
|
||||
|
||||
"""
|
||||
createToken allows an administrator to create a Token based on the current
|
||||
@@ -7363,7 +7541,7 @@ type Mutation {
|
||||
updateEmail will update the current users email address.
|
||||
"""
|
||||
updateEmail(input: UpdateEmailInput!): UpdateEmailPayload!
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED])
|
||||
@rate(seconds: 10)
|
||||
|
||||
"""
|
||||
@@ -7373,7 +7551,7 @@ type Mutation {
|
||||
updateNotificationSettings(
|
||||
input: UpdateNotificationSettingsInput!
|
||||
): UpdateNotificationSettingsPayload!
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED])
|
||||
|
||||
"""
|
||||
updateUserMediaSettings can be used to update the media preferences for the
|
||||
@@ -7430,6 +7608,25 @@ type Mutation {
|
||||
suspendUser(input: SuspendUserInput!): SuspendUserPayload!
|
||||
@auth(roles: [ADMIN, MODERATOR])
|
||||
|
||||
"""
|
||||
warnUser will warn a user and prevent them from commenting until they acknowledge
|
||||
"""
|
||||
warnUser(input: WarnUserInput!): WarnUserPayload!
|
||||
@auth(roles: [ADMIN, MODERATOR])
|
||||
|
||||
"""
|
||||
removeUserWarning will remove a user warning
|
||||
"""
|
||||
removeUserWarning(input: RemoveUserWarningInput!): RemoveUserWarningPayload!
|
||||
@auth(roles: [ADMIN, MODERATOR])
|
||||
|
||||
"""
|
||||
acknowledgWarning will remove a warning
|
||||
"""
|
||||
acknowledgeWarning(
|
||||
input: AcknowledgeWarningInput!
|
||||
): AcknowledgeWarningPayload @auth(permit: [WARNED])
|
||||
|
||||
"""
|
||||
removeUserSuspension will remove an active suspension from a User if they have
|
||||
one.
|
||||
@@ -7442,14 +7639,14 @@ type Mutation {
|
||||
ignoreUser will mark the given User as ignored by the current logged in User.
|
||||
"""
|
||||
ignoreUser(input: IgnoreUserInput!): IgnoreUserPayload!
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED])
|
||||
|
||||
"""
|
||||
removeUserIgnore will remove the given User from the ignored user list from
|
||||
the current logged in User.
|
||||
"""
|
||||
removeUserIgnore(input: RemoveUserIgnoreInput!): RemoveUserIgnorePayload!
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED])
|
||||
|
||||
"""
|
||||
requestCommentsDownload allows a user to request to download their comments.
|
||||
@@ -7457,7 +7654,7 @@ type Mutation {
|
||||
requestCommentsDownload(
|
||||
input: RequestCommentsDownloadInput!
|
||||
): RequestCommentsDownloadPayload!
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION])
|
||||
@auth(permit: [SUSPENDED, BANNED, PENDING_DELETION, WARNED])
|
||||
|
||||
"""
|
||||
requestUserCommentsDownload allows a user to request to download their comments.
|
||||
|
||||
@@ -46,6 +46,9 @@ error-userAlreadySuspended = The user already has an active suspension until {$u
|
||||
error-userAlreadyBanned = The user is already banned.
|
||||
error-userBanned = Your account is currently banned.
|
||||
error-userSuspended = Your account is currently suspended until {$until}.
|
||||
error-userWarned =
|
||||
Your account has been issued a warning: {$message}.
|
||||
To continue participating in discussions, please press the "Acknowledge" button below.
|
||||
error-integrationDisabled = Specified integration is disabled.
|
||||
error-passwordResetTokenExpired = Password reset link expired.
|
||||
error-emailConfirmTokenExpired = Email confirmation link expired.
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
GQLUSER_ROLE,
|
||||
GQLUsernameStatus,
|
||||
GQLUserNotificationSettings,
|
||||
GQLWarningStatus,
|
||||
} from "coral-server/graph/schema/__generated__/types";
|
||||
|
||||
import {
|
||||
@@ -300,6 +301,36 @@ export interface PremodStatus {
|
||||
history: PremodStatusHistory[];
|
||||
}
|
||||
|
||||
export interface WarningStatusHistory {
|
||||
/**
|
||||
* active when true, indicates that the given user has not acknowledged the warning.
|
||||
*/
|
||||
active: boolean;
|
||||
/**
|
||||
* createdBy is the user that created this warning
|
||||
*/
|
||||
createdBy: string;
|
||||
|
||||
/**
|
||||
* createdAt is the time the username was created
|
||||
*/
|
||||
createdAt: Date;
|
||||
/**
|
||||
* acknowledgedAt is the time the commneter acknowledged it
|
||||
*/
|
||||
acknowledgedAt?: Date;
|
||||
|
||||
/**
|
||||
* message is the message sent to the commenter
|
||||
*/
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface WarningStatus {
|
||||
active: boolean;
|
||||
history: WarningStatusHistory[];
|
||||
}
|
||||
|
||||
/**
|
||||
* UserStatus stores the user status information regarding moderation state.
|
||||
*/
|
||||
@@ -325,6 +356,12 @@ export interface UserStatus {
|
||||
* premod status.
|
||||
*/
|
||||
premod: PremodStatus;
|
||||
|
||||
/**
|
||||
* warning stores whether a user has an unacknowledge warning and a history of
|
||||
* warnings
|
||||
*/
|
||||
warning?: WarningStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -545,6 +582,7 @@ async function findOrCreateUserInput(
|
||||
suspension: { history: [] },
|
||||
ban: { active: false, history: [] },
|
||||
premod: { active: false, history: [] },
|
||||
warning: { active: false, history: [] },
|
||||
},
|
||||
notifications: {
|
||||
onReply: false,
|
||||
@@ -1851,6 +1889,201 @@ export async function removeActiveUserSuspensions(
|
||||
return result.value;
|
||||
}
|
||||
|
||||
export async function warnUser(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
createdBy: string,
|
||||
message?: string,
|
||||
now = new Date()
|
||||
) {
|
||||
// Create the new warning.
|
||||
const warningHistory: WarningStatusHistory = {
|
||||
active: true,
|
||||
createdBy,
|
||||
createdAt: now,
|
||||
message,
|
||||
};
|
||||
// Try to update the user if the user isn't already warned.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
id,
|
||||
tenantID,
|
||||
"status.warning.active": {
|
||||
$ne: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
"status.warning.active": true,
|
||||
},
|
||||
$push: {
|
||||
"status.warning.history": warningHistory,
|
||||
},
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
// Get the user so we can figure out why the ban operation failed.
|
||||
const user = await retrieveUser(mongo, tenantID, id);
|
||||
if (!user) {
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
// Check to see if the user is already warned.
|
||||
const warning = consolidateUserWarningStatus(user.status.warning);
|
||||
if (warning && warning.active) {
|
||||
throw new Error("User already warned");
|
||||
}
|
||||
|
||||
throw new Error("an unexpected error occurred");
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* removeUserWarning will remove a warning from a User allowing them to interact with
|
||||
* the site again.
|
||||
*
|
||||
* @param mongo the mongo database handle
|
||||
* @param tenantID the Tenant's ID where the User exists
|
||||
* @param id the ID of the user having their warning removed
|
||||
* @param modifiedBy the ID of the user removing the warning
|
||||
* @param now the current date
|
||||
*/
|
||||
export async function removeUserWarning(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
createdBy: string,
|
||||
now = new Date()
|
||||
) {
|
||||
// Create the new history entry.
|
||||
const update: WarningStatusHistory = {
|
||||
active: false,
|
||||
createdBy,
|
||||
createdAt: now,
|
||||
};
|
||||
|
||||
// Try to update the user if the user isn't already warned.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
id,
|
||||
tenantID,
|
||||
$or: [
|
||||
{
|
||||
"status.warning.active": {
|
||||
$ne: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
"status.warning.history": {
|
||||
$size: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
"status.warning.active": false,
|
||||
},
|
||||
$push: {
|
||||
"status.warning.history": update,
|
||||
},
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
// Get the user so we can figure out why the warn operation failed.
|
||||
const user = await retrieveUser(mongo, tenantID, id);
|
||||
if (!user) {
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
// The user wasn't warned already, so nothing needs to be done
|
||||
return user;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* acknowledgeWarning will remove a warning from a User allowing them to interact with
|
||||
* the site again.
|
||||
*
|
||||
* @param mongo the mongo database handle
|
||||
* @param tenantID the Tenant's ID where the User exists
|
||||
* @param id the ID of the user having their warning removed
|
||||
* @param now the current date
|
||||
*/
|
||||
export async function acknowledgeOwnWarning(
|
||||
mongo: Db,
|
||||
tenantID: string,
|
||||
id: string,
|
||||
now = new Date()
|
||||
) {
|
||||
// Create the new update.
|
||||
const update: WarningStatusHistory = {
|
||||
active: false,
|
||||
acknowledgedAt: now,
|
||||
createdAt: now,
|
||||
createdBy: id,
|
||||
};
|
||||
|
||||
// Try to update the user if the user isn't already warned.
|
||||
const result = await collection(mongo).findOneAndUpdate(
|
||||
{
|
||||
id,
|
||||
tenantID,
|
||||
$or: [
|
||||
{
|
||||
"status.warning.active": {
|
||||
$ne: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
"status.warning.history": {
|
||||
$size: 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
$set: {
|
||||
"status.warning.active": false,
|
||||
},
|
||||
$push: {
|
||||
"status.warning.history": update,
|
||||
},
|
||||
},
|
||||
{
|
||||
// False to return the updated document instead of the original
|
||||
// document.
|
||||
returnOriginal: false,
|
||||
}
|
||||
);
|
||||
if (!result.value) {
|
||||
// Get the user so we can figure out why the warn operation failed.
|
||||
const user = await retrieveUser(mongo, tenantID, id);
|
||||
if (!user) {
|
||||
throw new UserNotFoundError(id);
|
||||
}
|
||||
|
||||
// The user wasn't warned already, so nothing needs to be done!
|
||||
return user;
|
||||
}
|
||||
|
||||
return result.value;
|
||||
}
|
||||
export type ConsolidatedBanStatus = Omit<GQLBanStatus, "history"> &
|
||||
Pick<BanStatus, "history">;
|
||||
|
||||
@@ -1860,6 +2093,9 @@ export type ConsolidatedUsernameStatus = Omit<GQLUsernameStatus, "history"> &
|
||||
export type ConsolidatedPremodStatus = Omit<GQLPremodStatus, "history"> &
|
||||
Pick<PremodStatus, "history">;
|
||||
|
||||
export type ConsolidatedWarningStatus = Omit<GQLWarningStatus, "history"> &
|
||||
Pick<PremodStatus, "history">;
|
||||
|
||||
export function consolidateUsernameStatus(
|
||||
username: User["status"]["username"]
|
||||
) {
|
||||
@@ -1874,6 +2110,23 @@ export function consolidateUserPremodStatus(premod: User["status"]["premod"]) {
|
||||
return premod;
|
||||
}
|
||||
|
||||
export function consolidateUserWarningStatus(
|
||||
warning: User["status"]["warning"]
|
||||
) {
|
||||
if (!warning) {
|
||||
return {
|
||||
active: false,
|
||||
history: [],
|
||||
};
|
||||
}
|
||||
const activeWarning = warning.history[warning.history.length - 1];
|
||||
return {
|
||||
active: warning.active,
|
||||
history: warning.history,
|
||||
message: activeWarning ? activeWarning.message : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export type ConsolidatedSuspensionStatus = Omit<
|
||||
GQLSuspensionStatus,
|
||||
"history"
|
||||
@@ -1909,6 +2162,7 @@ export interface ConsolidatedUserStatus {
|
||||
suspension: ConsolidatedSuspensionStatus;
|
||||
ban: ConsolidatedBanStatus;
|
||||
premod: ConsolidatedPremodStatus;
|
||||
warning: ConsolidatedWarningStatus;
|
||||
}
|
||||
|
||||
export function consolidateUserStatus(
|
||||
@@ -1920,6 +2174,7 @@ export function consolidateUserStatus(
|
||||
suspension: consolidateUserSuspensionStatus(status.suspension, now),
|
||||
ban: consolidateUserBanStatus(status.ban),
|
||||
premod: consolidateUserPremodStatus(status.premod),
|
||||
warning: consolidateUserWarningStatus(status.warning),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -38,11 +38,13 @@ import {
|
||||
Tenant,
|
||||
} from "coral-server/models/tenant";
|
||||
import {
|
||||
acknowledgeOwnWarning,
|
||||
banUser,
|
||||
clearDeletionDate,
|
||||
consolidateUserBanStatus,
|
||||
consolidateUserPremodStatus,
|
||||
consolidateUserSuspensionStatus,
|
||||
consolidateUserWarningStatus,
|
||||
createModeratorNote,
|
||||
createUser,
|
||||
createUserToken,
|
||||
@@ -58,6 +60,7 @@ import {
|
||||
removeUserBan,
|
||||
removeUserIgnore,
|
||||
removeUserPremod,
|
||||
removeUserWarning,
|
||||
retrieveUser,
|
||||
retrieveUserWithEmail,
|
||||
scheduleDeletionDate,
|
||||
@@ -78,6 +81,7 @@ import {
|
||||
User,
|
||||
UserModerationScopes,
|
||||
verifyUserPassword,
|
||||
warnUser,
|
||||
} from "coral-server/models/user";
|
||||
import {
|
||||
getLocalProfile,
|
||||
@@ -1008,6 +1012,88 @@ export async function removePremod(
|
||||
// For each of the suspensions, remove it.
|
||||
return removeUserPremod(mongo, tenant.id, userID, moderator.id, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* warn will warn a specific user.
|
||||
*
|
||||
* @param mongo mongo database to interact with
|
||||
* @param tenant Tenant where the User will be warned on
|
||||
* @param moderator the User that is warning the User
|
||||
* @param userID the ID of the User being warned
|
||||
* @param now the current time that the warning took effect
|
||||
*/
|
||||
export async function warn(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
moderator: User,
|
||||
userID: string,
|
||||
message: string,
|
||||
now = new Date()
|
||||
) {
|
||||
// Get the user being warned to check to see if the user already has an
|
||||
// existing warning.
|
||||
const targetUser = await retrieveUser(mongo, tenant.id, userID);
|
||||
if (!targetUser) {
|
||||
throw new UserNotFoundError(userID);
|
||||
}
|
||||
|
||||
// Check to see if the User is currently warned.
|
||||
const warningStatus = consolidateUserWarningStatus(targetUser.status.warning);
|
||||
if (warningStatus.active) {
|
||||
throw new Error("User already warned");
|
||||
}
|
||||
|
||||
// Ban the user.
|
||||
return warnUser(mongo, tenant.id, userID, moderator.id, message, now);
|
||||
}
|
||||
|
||||
export async function removeWarning(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
moderator: User,
|
||||
userID: string,
|
||||
now = new Date()
|
||||
) {
|
||||
// Get the user being suspended to check to see if the user already has an
|
||||
// existing warning.
|
||||
const targetUser = await retrieveUser(mongo, tenant.id, userID);
|
||||
if (!targetUser) {
|
||||
throw new UserNotFoundError(userID);
|
||||
}
|
||||
|
||||
// Check to see if the User is currently warned.
|
||||
const warningStatus = consolidateUserWarningStatus(targetUser.status.warning);
|
||||
if (!warningStatus.active) {
|
||||
// The user is not warned currently, just return the user because we
|
||||
// don't have to do anything.
|
||||
return targetUser;
|
||||
}
|
||||
|
||||
// remove warning.
|
||||
return removeUserWarning(mongo, tenant.id, userID, moderator.id, now);
|
||||
}
|
||||
|
||||
export async function acknowledgeWarning(
|
||||
mongo: Db,
|
||||
tenant: Tenant,
|
||||
userID: string,
|
||||
now = new Date()
|
||||
) {
|
||||
const targetUser = await retrieveUser(mongo, tenant.id, userID);
|
||||
if (!targetUser) {
|
||||
throw new UserNotFoundError(userID);
|
||||
}
|
||||
|
||||
const warningStatus = consolidateUserWarningStatus(targetUser.status.warning);
|
||||
if (!warningStatus.active) {
|
||||
// The user is not warned currently, just return the user because we
|
||||
// don't have to do anything.
|
||||
return targetUser;
|
||||
}
|
||||
|
||||
// remove warning
|
||||
return acknowledgeOwnWarning(mongo, tenant.id, userID, now);
|
||||
}
|
||||
/**
|
||||
* suspend will suspend a give user from interacting with Coral.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user