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
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "disabled" BOOLEAN NOT NULL DEFAULT false;
@@ -1,3 +1,3 @@
# Please do not edit this file manually # Please do not edit this file manually
# It should be added in your version-control system (i.e. Git) # It should be added in your version-control system (i.e. Git)
provider = "postgresql" provider = "postgresql"
+12 -11
View File
@@ -21,6 +21,7 @@ model User {
/// Indicate whether the user finished the signup progress. /// 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. /// for example, the value will be false if user never registered and invited into a workspace by others.
registered Boolean @default(true) registered Boolean @default(true)
disabled Boolean @default(false)
features UserFeature[] features UserFeature[]
userStripeCustomer UserStripeCustomer? userStripeCustomer UserStripeCustomer?
@@ -130,8 +131,8 @@ model WorkspaceDoc {
mode Int @default(0) @db.SmallInt mode Int @default(0) @db.SmallInt
// Whether the doc is blocked // Whether the doc is blocked
blocked Boolean @default(false) blocked Boolean @default(false)
title String? @db.VarChar title String? @db.VarChar
summary String? @db.VarChar summary String? @db.VarChar
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@ -647,16 +648,16 @@ enum NotificationLevel {
} }
model Notification { model Notification {
id String @id @default(uuid()) @db.VarChar id String @id @default(uuid()) @db.VarChar
userId String @map("user_id") @db.VarChar userId String @map("user_id") @db.VarChar
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3) updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(3)
level NotificationLevel level NotificationLevel
read Boolean @default(false) read Boolean @default(false)
type NotificationType type NotificationType
body Json @db.JsonB 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 // for user notifications list, including read and unread, ordered by createdAt
@@index([userId, createdAt, read]) @@index([userId, createdAt, read])
@@ -214,9 +214,9 @@ test('should delete userSession success by userId', async t => {
data: {}, data: {},
}); });
await t.context.session.createOrRefreshUserSession(user.id, session.id); 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); t.is(count, 1);
count = await t.context.session.deleteUserSession(user.id); count = await t.context.session.deleteUserSessions(user.id);
t.is(count, 0); t.is(count, 0);
}); });
@@ -228,7 +228,7 @@ test('should delete userSession success by userId and sessionId', async t => {
data: {}, data: {},
}); });
await t.context.session.createOrRefreshUserSession(user.id, session.id); 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); t.is(count, 1);
}); });
@@ -240,7 +240,7 @@ test('should delete userSession fail when sessionId not match', async t => {
data: {}, data: {},
}); });
await t.context.session.createOrRefreshUserSession(user.id, session.id); 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, user.id,
'not-exists-session-id' 'not-exists-session-id'
); );
@@ -181,21 +181,6 @@ test('should get workspace user by id', async t => {
t.is(workspaceUser!.email, user.email); 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 => { test('should get user by email', async t => {
const user = await t.context.user.create({ const user = await t.context.user.create({
email: 'test@affine.pro', email: 'test@affine.pro',
@@ -305,6 +290,7 @@ test('should paginate users', async t => {
name: `test-paginate-${i}`, name: `test-paginate-${i}`,
email: `test-paginate-${i}@affine.pro`, email: `test-paginate-${i}@affine.pro`,
createdAt: new Date(now + i), 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({ const user = await t.context.user.create({
email: 'test@affine.pro', email: 'test@affine.pro',
}); });
t.true(await t.context.user.exists(user.id)); const event = t.context.module.get(EventBus);
t.false(await t.context.user.exists('non-existing-user')); 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 // #region ConnectedAccount
test('should create, get, update, delete connected account', async t => { test('should create, get, update, delete connected account', async t => {
@@ -10,11 +10,11 @@ import type { CurrentUser } from './session';
export function sessionUser( export function sessionUser(
user: Pick< user: Pick<
User, User,
'id' | 'email' | 'avatarUrl' | 'name' | 'emailVerifiedAt' 'id' | 'email' | 'avatarUrl' | 'name' | 'emailVerifiedAt' | 'disabled'
> & { password?: string | null } > & { password?: string | null }
): CurrentUser { ): CurrentUser {
// use pick to avoid unexpected fields // 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, hasPassword: user.password !== null,
emailVerified: user.emailVerifiedAt !== null, emailVerified: user.emailVerifiedAt !== null,
}); });
@@ -105,7 +105,7 @@ export class AuthService implements OnApplicationBootstrap {
if (!userId) { if (!userId) {
await this.models.session.deleteSession(sessionId); await this.models.session.deleteSession(sessionId);
} else { } 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) { async revokeUserSessions(userId: string) {
return await this.models.session.deleteUserSession(userId); return await this.models.session.deleteUserSessions(userId);
} }
getSessionOptionsFromRequest(req: Request) { getSessionOptionsFromRequest(req: Request) {
@@ -45,7 +45,7 @@ export const CurrentUser = createParamDecorator(
); );
export interface CurrentUser export interface CurrentUser
extends Pick<User, 'id' | 'email' | 'avatarUrl' | 'name'> { extends Pick<User, 'id' | 'email' | 'avatarUrl' | 'name' | 'disabled'> {
hasPassword: boolean | null; hasPassword: boolean | null;
emailVerified: boolean; emailVerified: boolean;
} }
@@ -202,7 +202,9 @@ export class UserManagementResolver {
description: 'Get user by id', description: 'Get user by id',
}) })
async getUser(@Args('id') id: string) { 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) { if (!user) {
return null; return null;
@@ -217,7 +219,9 @@ export class UserManagementResolver {
nullable: true, nullable: true,
}) })
async getUserByEmail(@Args('email') email: string) { 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) { if (!user) {
return null; return null;
@@ -256,7 +260,7 @@ export class UserManagementResolver {
} }
@Mutation(() => UserType, { @Mutation(() => UserType, {
description: 'Update a user', description: 'Update an user',
}) })
async updateUser( async updateUser(
@Args('id') id: string, @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));
}
} }
@@ -41,6 +41,11 @@ export class UserType implements CurrentUser {
nullable: true, nullable: true,
}) })
createdAt?: Date | null; createdAt?: Date | null;
@Field(() => Boolean, {
description: 'User is disabled',
})
disabled!: boolean;
} }
@ObjectType() @ObjectType()
@@ -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) { async getOwner(workspaceId: string, docId: string) {
return await this.db.workspaceDocUserRole.findFirst({ return await this.db.workspaceDocUserRole.findFirst({
where: { where: {
@@ -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({ const { count } = await this.db.userSession.deleteMany({
where: { where: {
userId, userId,
@@ -131,7 +131,7 @@ export class SessionModel extends BaseModel {
}, },
}); });
this.logger.log( 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; return count;
} }
+48 -27
View File
@@ -1,5 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import { type ConnectedAccount, Prisma, type User } from '@prisma/client'; import { type ConnectedAccount, Prisma, type User } from '@prisma/client';
import { omit } from 'lodash-es';
import { import {
CryptoHelper, CryptoHelper,
@@ -49,6 +51,10 @@ declare global {
} }
} }
interface UserFilter {
withDisabled?: boolean;
}
export type PublicUser = Pick<User, keyof typeof publicUserSelect>; export type PublicUser = Pick<User, keyof typeof publicUserSelect>;
export type WorkspaceUser = Pick<User, keyof typeof workspaceUserSelect>; export type WorkspaceUser = Pick<User, keyof typeof workspaceUserSelect>;
export type { ConnectedAccount, User }; export type { ConnectedAccount, User };
@@ -62,52 +68,49 @@ export class UserModel extends BaseModel {
super(); super();
} }
async get(id: string) { async get(id: string, filter: UserFilter = {}) {
return this.db.user.findUnique({ 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> { async getPublicUser(id: string): Promise<PublicUser | null> {
return this.db.user.findUnique({ return this.db.user.findUnique({
select: publicUserSelect, select: publicUserSelect,
where: { id }, where: { id, disabled: false },
}); });
} }
async getPublicUsers(ids: string[]): Promise<PublicUser[]> { async getPublicUsers(ids: string[]): Promise<PublicUser[]> {
return this.db.user.findMany({ return this.db.user.findMany({
select: publicUserSelect, select: publicUserSelect,
where: { id: { in: ids } }, where: { id: { in: ids }, disabled: false },
}); });
} }
async getWorkspaceUser(id: string): Promise<WorkspaceUser | null> { async getWorkspaceUser(id: string): Promise<WorkspaceUser | null> {
return this.db.user.findUnique({ return this.db.user.findUnique({
select: workspaceUserSelect, select: workspaceUserSelect,
where: { id }, where: { id, disabled: false },
}); });
} }
async getWorkspaceUsers(ids: string[]): Promise<WorkspaceUser[]> { async getWorkspaceUsers(ids: string[]): Promise<WorkspaceUser[]> {
return this.db.user.findMany({ return this.db.user.findMany({
select: workspaceUserSelect, 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[]>` 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 SELECT id, name, email, password, registered, email_verified as emailVerifiedAt, avatar_url as avatarUrl, registered, created_at as createdAt
FROM "users" FROM "users"
WHERE lower("email") = lower(${email}) WHERE lower("email") = lower(${email})
${Prisma.raw(filter.withDisabled ? '' : 'AND disabled = false')}
`; `;
return rows[0] ?? null; return rows[0] ?? null;
@@ -141,23 +144,14 @@ export class UserModel extends BaseModel {
SELECT id, name, avatar_url as avatarUrl SELECT id, name, avatar_url as avatarUrl
FROM "users" FROM "users"
WHERE lower("email") = lower(${email}) WHERE lower("email") = lower(${email})
`; AND disabled = false
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})
`; `;
return rows[0] ?? null; return rows[0] ?? null;
} }
async create(data: CreateUserInput) { async create(data: CreateUserInput) {
let user = await this.getUserByEmail(data.email); let user = await this.getUserByEmail(data.email, { withDisabled: true });
if (user) { if (user) {
throw new EmailAlreadyUsed(); throw new EmailAlreadyUsed();
@@ -183,13 +177,16 @@ export class UserModel extends BaseModel {
return user; return user;
} }
@Transactional()
async update(id: string, data: UpdateUserInput) { async update(id: string, data: UpdateUserInput) {
if (data.password) { if (data.password) {
data.password = await this.crypto.encryptPassword(data.password); data.password = await this.crypto.encryptPassword(data.password);
} }
if (data.email) { if (data.email) {
const user = await this.getUserByEmail(data.email); const user = await this.getUserByEmail(data.email, {
withDisabled: true,
});
if (user && user.id !== id) { if (user && user.id !== id) {
throw new EmailAlreadyUsed(); throw new EmailAlreadyUsed();
} }
@@ -211,7 +208,7 @@ export class UserModel extends BaseModel {
* When user created by others invitation, we will leave it as unregistered. * When user created by others invitation, we will leave it as unregistered.
*/ */
async fulfill(email: string, data: Omit<UpdateUserInput, 'email'> = {}) { async fulfill(email: string, data: Omit<UpdateUserInput, 'email'> = {}) {
const user = await this.getUserByEmail(email); const user = await this.getUserByEmail(email, { withDisabled: true });
if (!user) { if (!user) {
return this.create({ return this.create({
@@ -258,6 +255,30 @@ export class UserModel extends BaseModel {
return user; 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) { async pagination(skip: number = 0, take: number = 20, after?: Date) {
return this.db.user.findMany({ return this.db.user.findMany({
where: { where: {
@@ -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) { async get(workspaceId: string, userId: string) {
return await this.db.workspaceUserRole.findUnique({ return await this.db.workspaceUserRole.findUnique({
where: { where: {
@@ -574,7 +574,7 @@ export class SubscriptionService implements OnApplicationBootstrap {
return null; return null;
} }
const user = await this.models.user.getWorkspaceUserByEmail(customer.email); const user = await this.models.user.getUserByEmail(customer.email);
if (!user) { if (!user) {
return { return {
@@ -620,9 +620,7 @@ export class SubscriptionService implements OnApplicationBootstrap {
return null; return null;
} }
const user = await this.models.user.getWorkspaceUserByEmail( const user = await this.models.user.getUserByEmail(invoice.customer_email);
invoice.customer_email
);
return { return {
userId: user?.id, userId: user?.id,
+13 -1
View File
@@ -618,6 +618,9 @@ type InviteUserType {
"""User email verified""" """User email verified"""
createdAt: DateTime @deprecated(reason: "useless") createdAt: DateTime @deprecated(reason: "useless")
"""User is disabled"""
disabled: Boolean
"""User email""" """User email"""
email: String email: String
@@ -767,6 +770,9 @@ type Mutation {
addContextDoc(options: AddContextDocInput!): [CopilotContextListItem!]! addContextDoc(options: AddContextDocInput!): [CopilotContextListItem!]!
addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean! addWorkspaceFeature(feature: FeatureType!, workspaceId: String!): Boolean!
approveMember(userId: String!, 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! cancelSubscription(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, workspaceId: String): SubscriptionType!
changeEmail(email: String!, token: String!): UserType! changeEmail(email: String!, token: String!): UserType!
changePassword(newPassword: String!, token: String!, userId: String): Boolean! changePassword(newPassword: String!, token: String!, userId: String): Boolean!
@@ -810,6 +816,9 @@ type Mutation {
deleteUser(id: String!): DeleteAccount! deleteUser(id: String!): DeleteAccount!
deleteWorkspace(id: String!): Boolean! deleteWorkspace(id: String!): Boolean!
"""Reenable an banned user"""
enableUser(id: String!): UserType!
"""Create a chat session""" """Create a chat session"""
forkCopilotSession(options: ForkChatSessionInput!): String! forkCopilotSession(options: ForkChatSessionInput!): String!
generateLicenseKey(sessionId: String!): String! generateLicenseKey(sessionId: String!): String!
@@ -864,7 +873,7 @@ type Mutation {
updateRuntimeConfigs(updates: JSONObject!): [ServerRuntimeConfigType!]! updateRuntimeConfigs(updates: JSONObject!): [ServerRuntimeConfigType!]!
updateSubscriptionRecurring(idempotencyKey: String @deprecated(reason: "use header `Idempotency-Key`"), plan: SubscriptionPlan = Pro, recurring: SubscriptionRecurring!, workspaceId: String): SubscriptionType! 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! updateUser(id: String!, input: ManageUserInput!): UserType!
"""update user enabled feature""" """update user enabled feature"""
@@ -1359,6 +1368,9 @@ type UserType {
"""User email verified""" """User email verified"""
createdAt: DateTime @deprecated(reason: "useless") createdAt: DateTime @deprecated(reason: "useless")
"""User is disabled"""
disabled: Boolean!
"""User email""" """User email"""
email: String! email: String!