feat(server): ban account (#10761)

close CLOUD-158
This commit is contained in:
forehalo
2025-03-12 02:52:18 +00:00
parent cd63e0ed8b
commit ea72599bde
15 changed files with 215 additions and 76 deletions

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "disabled" BOOLEAN NOT NULL DEFAULT false;

View File

@@ -1,3 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "postgresql"
provider = "postgresql"

View File

@@ -21,6 +21,7 @@ model User {
/// Indicate whether the user finished the signup progress.
/// for example, the value will be false if user never registered and invited into a workspace by others.
registered Boolean @default(true)
disabled Boolean @default(false)
features UserFeature[]
userStripeCustomer UserStripeCustomer?
@@ -130,8 +131,8 @@ model WorkspaceDoc {
mode Int @default(0) @db.SmallInt
// Whether the doc is blocked
blocked Boolean @default(false)
title String? @db.VarChar
summary String? @db.VarChar
title String? @db.VarChar
summary String? @db.VarChar
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@ -647,16 +648,16 @@ enum NotificationLevel {
}
model Notification {
id String @id @default(uuid()) @db.VarChar
userId String @map("user_id") @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
level NotificationLevel
read Boolean @default(false)
type NotificationType
body Json @db.JsonB
id String @id @default(uuid()) @db.VarChar
userId String @map("user_id") @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
level NotificationLevel
read Boolean @default(false)
type NotificationType
body Json @db.JsonB
user User @relation(name: "user_notifications", fields: [userId], references: [id], onDelete: Cascade)
user User @relation(name: "user_notifications", fields: [userId], references: [id], onDelete: Cascade)
// for user notifications list, including read and unread, ordered by createdAt
@@index([userId, createdAt, read])

View File

@@ -214,9 +214,9 @@ test('should delete userSession success by userId', async t => {
data: {},
});
await t.context.session.createOrRefreshUserSession(user.id, session.id);
let count = await t.context.session.deleteUserSession(user.id);
let count = await t.context.session.deleteUserSessions(user.id);
t.is(count, 1);
count = await t.context.session.deleteUserSession(user.id);
count = await t.context.session.deleteUserSessions(user.id);
t.is(count, 0);
});
@@ -228,7 +228,7 @@ test('should delete userSession success by userId and sessionId', async t => {
data: {},
});
await t.context.session.createOrRefreshUserSession(user.id, session.id);
const count = await t.context.session.deleteUserSession(user.id, session.id);
const count = await t.context.session.deleteUserSessions(user.id, session.id);
t.is(count, 1);
});
@@ -240,7 +240,7 @@ test('should delete userSession fail when sessionId not match', async t => {
data: {},
});
await t.context.session.createOrRefreshUserSession(user.id, session.id);
const count = await t.context.session.deleteUserSession(
const count = await t.context.session.deleteUserSessions(
user.id,
'not-exists-session-id'
);

View File

@@ -181,21 +181,6 @@ test('should get workspace user by id', async t => {
t.is(workspaceUser!.email, user.email);
});
test('should get workspace user by email', async t => {
const user = await t.context.user.create({
email: 'test@affine.pro',
});
const workspaceUser = await t.context.user.getWorkspaceUserByEmail(
user.email
);
t.not(workspaceUser, null);
t.is(workspaceUser!.id, user.id);
t.true(!('password' in workspaceUser!));
t.is(workspaceUser!.email, user.email);
});
test('should get user by email', async t => {
const user = await t.context.user.create({
email: 'test@affine.pro',
@@ -305,6 +290,7 @@ test('should paginate users', async t => {
name: `test-paginate-${i}`,
email: `test-paginate-${i}@affine.pro`,
createdAt: new Date(now + i),
disabled: i % 2 === 0,
})
)
);
@@ -317,14 +303,94 @@ test('should paginate users', async t => {
);
});
test('should check if user exists', async t => {
// #region disabled user
test('should not get disabled user by default', async t => {
const user = await t.context.user.create({
email: 'test@affine.pro',
disabled: true,
});
const user2 = await t.context.user.get(user.id);
const user3 = await t.context.user.getPublicUser(user.id);
const user4 = await t.context.user.getPublicUserByEmail(user.email);
const userList1 = await t.context.user.getPublicUsers([user.id]);
const user5 = await t.context.user.getWorkspaceUser(user.id);
const userList2 = await t.context.user.getWorkspaceUsers([user.id]);
t.is(user2, null);
t.is(user3, null);
t.is(user4, null);
t.is(user5, null);
t.is(userList1.length, 0);
t.is(userList2.length, 0);
});
test('should get disabled user `withDisabled`', async t => {
const user = await t.context.user.create({
email: 'test@affine.pro',
disabled: true,
});
const user2 = await t.context.user.get(user.id, { withDisabled: true });
const user3 = await t.context.user.getUserByEmail(user.email, {
withDisabled: true,
});
t.is(user2!.id, user.id);
t.is(user3!.id, user.id);
});
test('should not be able to update email to disabled user', async t => {
const user = await t.context.user.create({
email: 'test@affine.pro',
disabled: false,
});
const user2 = await t.context.user.create({
email: 'test2@affine.pro',
disabled: true,
});
await t.throwsAsync(
t.context.user.update(user.id, {
email: user2.email,
}),
{
instanceOf: EmailAlreadyUsed,
}
);
});
test('should ban user', async t => {
const user = await t.context.user.create({
email: 'test@affine.pro',
});
t.true(await t.context.user.exists(user.id));
t.false(await t.context.user.exists('non-existing-user'));
const event = t.context.module.get(EventBus);
const spy = Sinon.spy();
event.on('user.deleted', spy);
await t.context.user.ban(user.id);
t.true(spy.calledOnce);
const user2 = await t.context.user.get(user.id);
t.is(user2, null);
});
test('should enable user', async t => {
const user = await t.context.user.create({
email: 'test@affine.pro',
disabled: true,
});
const user2 = await t.context.user.enable(user.id);
t.is(user2.disabled, false);
const user3 = await t.context.user.get(user.id);
t.is(user3!.id, user.id);
});
// #endregion
// #region ConnectedAccount
test('should create, get, update, delete connected account', async t => {

View File

@@ -10,11 +10,11 @@ import type { CurrentUser } from './session';
export function sessionUser(
user: Pick<
User,
'id' | 'email' | 'avatarUrl' | 'name' | 'emailVerifiedAt'
'id' | 'email' | 'avatarUrl' | 'name' | 'emailVerifiedAt' | 'disabled'
> & { password?: string | null }
): CurrentUser {
// use pick to avoid unexpected fields
return assign(pick(user, 'id', 'email', 'avatarUrl', 'name'), {
return assign(pick(user, 'id', 'email', 'avatarUrl', 'name', 'disabled'), {
hasPassword: user.password !== null,
emailVerified: user.emailVerifiedAt !== null,
});
@@ -105,7 +105,7 @@ export class AuthService implements OnApplicationBootstrap {
if (!userId) {
await this.models.session.deleteSession(sessionId);
} else {
await this.models.session.deleteUserSession(userId, sessionId);
await this.models.session.deleteUserSessions(userId, sessionId);
}
}
@@ -195,7 +195,7 @@ export class AuthService implements OnApplicationBootstrap {
}
async revokeUserSessions(userId: string) {
return await this.models.session.deleteUserSession(userId);
return await this.models.session.deleteUserSessions(userId);
}
getSessionOptionsFromRequest(req: Request) {

View File

@@ -45,7 +45,7 @@ export const CurrentUser = createParamDecorator(
);
export interface CurrentUser
extends Pick<User, 'id' | 'email' | 'avatarUrl' | 'name'> {
extends Pick<User, 'id' | 'email' | 'avatarUrl' | 'name' | 'disabled'> {
hasPassword: boolean | null;
emailVerified: boolean;
}

View File

@@ -202,7 +202,9 @@ export class UserManagementResolver {
description: 'Get user by id',
})
async getUser(@Args('id') id: string) {
const user = await this.models.user.get(id);
const user = await this.models.user.get(id, {
withDisabled: true,
});
if (!user) {
return null;
@@ -217,7 +219,9 @@ export class UserManagementResolver {
nullable: true,
})
async getUserByEmail(@Args('email') email: string) {
const user = await this.models.user.getUserByEmail(email);
const user = await this.models.user.getUserByEmail(email, {
withDisabled: true,
});
if (!user) {
return null;
@@ -256,7 +260,7 @@ export class UserManagementResolver {
}
@Mutation(() => UserType, {
description: 'Update a user',
description: 'Update an user',
})
async updateUser(
@Args('id') id: string,
@@ -282,4 +286,18 @@ export class UserManagementResolver {
})
);
}
@Mutation(() => UserType, {
description: 'Ban an user',
})
async banUser(@Args('id') id: string): Promise<UserType> {
return sessionUser(await this.models.user.ban(id));
}
@Mutation(() => UserType, {
description: 'Reenable an banned user',
})
async enableUser(@Args('id') id: string): Promise<UserType> {
return sessionUser(await this.models.user.enable(id));
}
}

View File

@@ -41,6 +41,11 @@ export class UserType implements CurrentUser {
nullable: true,
})
createdAt?: Date | null;
@Field(() => Boolean, {
description: 'User is disabled',
})
disabled!: boolean;
}
@ObjectType()

View File

@@ -144,6 +144,14 @@ export class DocUserModel extends BaseModel {
});
}
async deleteByUserId(userId: string) {
await this.db.workspaceDocUserRole.deleteMany({
where: {
userId,
},
});
}
async getOwner(workspaceId: string, docId: string) {
return await this.db.workspaceDocUserRole.findFirst({
where: {

View File

@@ -123,7 +123,7 @@ export class SessionModel extends BaseModel {
});
}
async deleteUserSession(userId: string, sessionId?: string) {
async deleteUserSessions(userId: string, sessionId?: string) {
const { count } = await this.db.userSession.deleteMany({
where: {
userId,
@@ -131,7 +131,7 @@ export class SessionModel extends BaseModel {
},
});
this.logger.log(
`Deleted user session success by userId: ${userId} and sessionId: ${sessionId}`
`Deleted user sessions success by userId: ${userId} and sessionId: ${sessionId}`
);
return count;
}

View File

@@ -1,5 +1,7 @@
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import { type ConnectedAccount, Prisma, type User } from '@prisma/client';
import { omit } from 'lodash-es';
import {
CryptoHelper,
@@ -49,6 +51,10 @@ declare global {
}
}
interface UserFilter {
withDisabled?: boolean;
}
export type PublicUser = Pick<User, keyof typeof publicUserSelect>;
export type WorkspaceUser = Pick<User, keyof typeof workspaceUserSelect>;
export type { ConnectedAccount, User };
@@ -62,52 +68,49 @@ export class UserModel extends BaseModel {
super();
}
async get(id: string) {
async get(id: string, filter: UserFilter = {}) {
return this.db.user.findUnique({
where: { id },
where: { id, disabled: filter.withDisabled ? undefined : false },
});
}
async exists(id: string) {
const count = await this.db.user.count({
where: { id },
});
return count > 0;
}
async getPublicUser(id: string): Promise<PublicUser | null> {
return this.db.user.findUnique({
select: publicUserSelect,
where: { id },
where: { id, disabled: false },
});
}
async getPublicUsers(ids: string[]): Promise<PublicUser[]> {
return this.db.user.findMany({
select: publicUserSelect,
where: { id: { in: ids } },
where: { id: { in: ids }, disabled: false },
});
}
async getWorkspaceUser(id: string): Promise<WorkspaceUser | null> {
return this.db.user.findUnique({
select: workspaceUserSelect,
where: { id },
where: { id, disabled: false },
});
}
async getWorkspaceUsers(ids: string[]): Promise<WorkspaceUser[]> {
return this.db.user.findMany({
select: workspaceUserSelect,
where: { id: { in: ids } },
where: { id: { in: ids }, disabled: false },
});
}
async getUserByEmail(email: string): Promise<User | null> {
async getUserByEmail(
email: string,
filter: UserFilter = {}
): Promise<User | null> {
const rows = await this.db.$queryRaw<User[]>`
SELECT id, name, email, password, registered, email_verified as emailVerifiedAt, avatar_url as avatarUrl, registered, created_at as createdAt
FROM "users"
WHERE lower("email") = lower(${email})
${Prisma.raw(filter.withDisabled ? '' : 'AND disabled = false')}
`;
return rows[0] ?? null;
@@ -141,23 +144,14 @@ export class UserModel extends BaseModel {
SELECT id, name, avatar_url as avatarUrl
FROM "users"
WHERE lower("email") = lower(${email})
`;
return rows[0] ?? null;
}
async getWorkspaceUserByEmail(email: string): Promise<WorkspaceUser | null> {
const rows = await this.db.$queryRaw<WorkspaceUser[]>`
SELECT id, name, email, avatar_url as avatarUrl
FROM "users"
WHERE lower("email") = lower(${email})
AND disabled = false
`;
return rows[0] ?? null;
}
async create(data: CreateUserInput) {
let user = await this.getUserByEmail(data.email);
let user = await this.getUserByEmail(data.email, { withDisabled: true });
if (user) {
throw new EmailAlreadyUsed();
@@ -183,13 +177,16 @@ export class UserModel extends BaseModel {
return user;
}
@Transactional()
async update(id: string, data: UpdateUserInput) {
if (data.password) {
data.password = await this.crypto.encryptPassword(data.password);
}
if (data.email) {
const user = await this.getUserByEmail(data.email);
const user = await this.getUserByEmail(data.email, {
withDisabled: true,
});
if (user && user.id !== id) {
throw new EmailAlreadyUsed();
}
@@ -211,7 +208,7 @@ export class UserModel extends BaseModel {
* When user created by others invitation, we will leave it as unregistered.
*/
async fulfill(email: string, data: Omit<UpdateUserInput, 'email'> = {}) {
const user = await this.getUserByEmail(email);
const user = await this.getUserByEmail(email, { withDisabled: true });
if (!user) {
return this.create({
@@ -258,6 +255,30 @@ export class UserModel extends BaseModel {
return user;
}
async ban(id: string) {
// ban an user barely share the same logic with delete an user,
// but keep the record with `disabled` flag
// we delete the account and create it again to trigger all cleanups
let user = await this.delete(id);
user = await this.db.user.create({
data: {
...omit(user, 'id'),
disabled: true,
},
});
await this.event.emitAsync('user.postCreated', user);
return user;
}
async enable(id: string) {
return await this.db.user.update({
where: { id },
data: { disabled: false },
});
}
async pagination(skip: number = 0, take: number = 20, after?: Date) {
return this.db.user.findMany({
where: {

View File

@@ -196,6 +196,14 @@ export class WorkspaceUserModel extends BaseModel {
});
}
async deleteByUserId(userId: string) {
await this.db.workspaceUserRole.deleteMany({
where: {
userId,
},
});
}
async get(workspaceId: string, userId: string) {
return await this.db.workspaceUserRole.findUnique({
where: {

View File

@@ -574,7 +574,7 @@ export class SubscriptionService implements OnApplicationBootstrap {
return null;
}
const user = await this.models.user.getWorkspaceUserByEmail(customer.email);
const user = await this.models.user.getUserByEmail(customer.email);
if (!user) {
return {
@@ -620,9 +620,7 @@ export class SubscriptionService implements OnApplicationBootstrap {
return null;
}
const user = await this.models.user.getWorkspaceUserByEmail(
invoice.customer_email
);
const user = await this.models.user.getUserByEmail(invoice.customer_email);
return {
userId: user?.id,

View File

@@ -618,6 +618,9 @@ type InviteUserType {
"""User email verified"""
createdAt: DateTime @deprecated(reason: "useless")
"""User is disabled"""
disabled: Boolean
"""User email"""
email: String
@@ -767,6 +770,9 @@ type Mutation {
addContextDoc(options: AddContextDocInput!): [CopilotContextListItem!]!
addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean!
approveMember(userId: String!, workspaceId: String!): Boolean!
"""Ban an user"""
banUser(id: String!): UserType!
cancelSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType!
changeEmail(email: String!, token: String!): UserType!
changePassword(newPassword: String!, token: String!, userId: String): Boolean!
@@ -810,6 +816,9 @@ type Mutation {
deleteUser(id: String!): DeleteAccount!
deleteWorkspace(id: String!): Boolean!
"""Reenable an banned user"""
enableUser(id: String!): UserType!
"""Create a chat session"""
forkCopilotSession(options: ForkChatSessionInput!): String!
generateLicenseKey(sessionId: String!): String!
@@ -864,7 +873,7 @@ type Mutation {
updateRuntimeConfigs(updates: JSONObject!): [ServerRuntimeConfigType!]!
updateSubscriptionRecurring(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, recurring: SubscriptionRecurring!, workspaceId: String): SubscriptionType!
"""Update a user"""
"""Update an user"""
updateUser(id: String!, input: ManageUserInput!): UserType!
"""update user enabled feature"""
@@ -1359,6 +1368,9 @@ type UserType {
"""User email verified"""
createdAt: DateTime @deprecated(reason: "useless")
"""User is disabled"""
disabled: Boolean!
"""User email"""
email: String!