mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 13:25:12 +00:00
feat(server): use user model (#9710)
This commit is contained in:
@@ -27,7 +27,6 @@ import {
|
||||
UseNamedGuard,
|
||||
} from '../../base';
|
||||
import { Models, TokenType } from '../../models';
|
||||
import { UserService } from '../user';
|
||||
import { validators } from '../utils/validators';
|
||||
import { Public } from './guard';
|
||||
import { AuthService } from './service';
|
||||
@@ -56,7 +55,6 @@ export class AuthController {
|
||||
constructor(
|
||||
private readonly url: URLHelper,
|
||||
private readonly auth: AuthService,
|
||||
private readonly user: UserService,
|
||||
private readonly models: Models,
|
||||
private readonly config: Config,
|
||||
private readonly runtime: Runtime
|
||||
@@ -81,9 +79,7 @@ export class AuthController {
|
||||
}
|
||||
validators.assertValidEmail(params.email);
|
||||
|
||||
const user = await this.user.findUserWithHashedPasswordByEmail(
|
||||
params.email
|
||||
);
|
||||
const user = await this.models.user.getUserByEmail(params.email);
|
||||
|
||||
const magicLinkAvailable = !!this.config.mailer.host;
|
||||
|
||||
@@ -159,7 +155,7 @@ export class AuthController {
|
||||
redirectUrl?: string
|
||||
) {
|
||||
// send email magic link
|
||||
const user = await this.user.findUserByEmail(email);
|
||||
const user = await this.models.user.getUserByEmail(email);
|
||||
if (!user) {
|
||||
const allowSignup = await this.runtime.fetch('auth/allowSignup');
|
||||
if (!allowSignup) {
|
||||
@@ -263,10 +259,7 @@ export class AuthController {
|
||||
throw new InvalidEmailToken();
|
||||
}
|
||||
|
||||
const user = await this.user.fulfillUser(email, {
|
||||
emailVerifiedAt: new Date(),
|
||||
registered: true,
|
||||
});
|
||||
const user = await this.models.user.fulfill(email);
|
||||
|
||||
await this.auth.setCookies(req, res, user.id);
|
||||
res.send({ id: user.id });
|
||||
|
||||
@@ -23,7 +23,6 @@ import {
|
||||
} from '../../base';
|
||||
import { Models, TokenType } from '../../models';
|
||||
import { Admin } from '../common';
|
||||
import { UserService } from '../user';
|
||||
import { UserType } from '../user/types';
|
||||
import { validators } from '../utils/validators';
|
||||
import { Public } from './guard';
|
||||
@@ -48,7 +47,6 @@ export class AuthResolver {
|
||||
constructor(
|
||||
private readonly url: URLHelper,
|
||||
private readonly auth: AuthService,
|
||||
private readonly user: UserService,
|
||||
private readonly models: Models
|
||||
) {}
|
||||
|
||||
@@ -234,7 +232,7 @@ export class AuthResolver {
|
||||
throw new InvalidEmailToken();
|
||||
}
|
||||
|
||||
const hasRegistered = await this.user.findUserByEmail(email);
|
||||
const hasRegistered = await this.models.user.getUserByEmail(email);
|
||||
|
||||
if (hasRegistered) {
|
||||
if (hasRegistered.id !== user.id) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Models, type User, type UserSession } from '../../models';
|
||||
import { FeatureManagementService } from '../features/management';
|
||||
import { QuotaService } from '../quota/service';
|
||||
import { QuotaType } from '../quota/types';
|
||||
import { UserService } from '../user/service';
|
||||
import type { CurrentUser } from './session';
|
||||
|
||||
export function sessionUser(
|
||||
@@ -47,17 +46,16 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
private readonly models: Models,
|
||||
private readonly mailer: MailService,
|
||||
private readonly feature: FeatureManagementService,
|
||||
private readonly quota: QuotaService,
|
||||
private readonly user: UserService
|
||||
private readonly quota: QuotaService
|
||||
) {}
|
||||
|
||||
async onApplicationBootstrap() {
|
||||
if (this.config.node.dev) {
|
||||
try {
|
||||
const [email, name, password] = ['dev@affine.pro', 'Dev User', 'dev'];
|
||||
let devUser = await this.user.findUserByEmail(email);
|
||||
let devUser = await this.models.user.getUserByEmail(email);
|
||||
if (!devUser) {
|
||||
devUser = await this.user.createUser_without_verification({
|
||||
devUser = await this.models.user.create({
|
||||
email,
|
||||
name,
|
||||
password,
|
||||
@@ -86,8 +84,8 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
);
|
||||
}
|
||||
|
||||
return this.user
|
||||
.createUser_without_verification({
|
||||
return this.models.user
|
||||
.create({
|
||||
email,
|
||||
password,
|
||||
})
|
||||
@@ -95,7 +93,7 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
}
|
||||
|
||||
async signIn(email: string, password: string): Promise<CurrentUser> {
|
||||
return this.user.signIn(email, password).then(sessionUser);
|
||||
return this.models.user.signIn(email, password).then(sessionUser);
|
||||
}
|
||||
|
||||
async signOut(sessionId: string, userId?: string) {
|
||||
@@ -131,7 +129,7 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
userSession = sessions.at(-1)!;
|
||||
}
|
||||
|
||||
const user = await this.user.findUserById(userSession.userId);
|
||||
const user = await this.models.user.get(userSession.userId);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
@@ -284,25 +282,23 @@ export class AuthService implements OnApplicationBootstrap {
|
||||
id: string,
|
||||
newPassword: string
|
||||
): Promise<Omit<User, 'password'>> {
|
||||
return this.user.updateUser(id, { password: newPassword });
|
||||
return this.models.user.update(id, { password: newPassword });
|
||||
}
|
||||
|
||||
async changeEmail(
|
||||
id: string,
|
||||
newEmail: string
|
||||
): Promise<Omit<User, 'password'>> {
|
||||
return this.user.updateUser(id, {
|
||||
return this.models.user.update(id, {
|
||||
email: newEmail,
|
||||
emailVerifiedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
async setEmailVerified(id: string) {
|
||||
return await this.user.updateUser(
|
||||
id,
|
||||
{ emailVerifiedAt: new Date() },
|
||||
{ emailVerifiedAt: true }
|
||||
);
|
||||
return await this.models.user.update(id, {
|
||||
emailVerifiedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
async sendChangePasswordEmail(email: string, callbackUrl: string) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { type EventPayload, OnEvent, Runtime } from '../../base';
|
||||
import { UserService } from '../user/service';
|
||||
import { Models } from '../../models';
|
||||
import { FeatureService } from './service';
|
||||
import { FeatureType } from './types';
|
||||
|
||||
@@ -18,7 +18,7 @@ export class FeatureManagementService {
|
||||
|
||||
constructor(
|
||||
private readonly feature: FeatureService,
|
||||
private readonly user: UserService,
|
||||
private readonly models: Models,
|
||||
private readonly runtime: Runtime
|
||||
) {}
|
||||
|
||||
@@ -100,7 +100,7 @@ export class FeatureManagementService {
|
||||
);
|
||||
|
||||
if (earlyAccessControlEnabled && !this.isStaff(email)) {
|
||||
const user = await this.user.findUserByEmail(email);
|
||||
const user = await this.models.user.getUserByEmail(email);
|
||||
if (!user) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
InternalServerError,
|
||||
Mutex,
|
||||
PasswordRequired,
|
||||
Runtime,
|
||||
} from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import { AuthService, Public } from '../auth';
|
||||
import { ServerService } from '../config';
|
||||
import { UserService } from '../user/service';
|
||||
import { validators } from '../utils/validators';
|
||||
|
||||
interface CreateUserInput {
|
||||
email: string;
|
||||
@@ -20,11 +22,12 @@ interface CreateUserInput {
|
||||
@Controller('/api/setup')
|
||||
export class CustomSetupController {
|
||||
constructor(
|
||||
private readonly user: UserService,
|
||||
private readonly models: Models,
|
||||
private readonly auth: AuthService,
|
||||
private readonly event: EventEmitter,
|
||||
private readonly mutex: Mutex,
|
||||
private readonly server: ServerService
|
||||
private readonly server: ServerService,
|
||||
private readonly runtime: Runtime
|
||||
) {}
|
||||
|
||||
@Public()
|
||||
@@ -34,21 +37,33 @@ export class CustomSetupController {
|
||||
@Res() res: Response,
|
||||
@Body() input: CreateUserInput
|
||||
) {
|
||||
if (await this.server.initialized()) {
|
||||
throw new ActionForbidden('First user already created');
|
||||
}
|
||||
|
||||
validators.assertValidEmail(input.email);
|
||||
|
||||
if (!input.password) {
|
||||
throw new PasswordRequired();
|
||||
}
|
||||
|
||||
const config = await this.runtime.fetchAll({
|
||||
'auth/password.max': true,
|
||||
'auth/password.min': true,
|
||||
});
|
||||
|
||||
validators.assertValidPassword(input.password, {
|
||||
max: config['auth/password.max'],
|
||||
min: config['auth/password.min'],
|
||||
});
|
||||
|
||||
await using lock = await this.mutex.acquire('createFirstAdmin');
|
||||
|
||||
if (!lock) {
|
||||
throw new InternalServerError();
|
||||
}
|
||||
|
||||
if (await this.server.initialized()) {
|
||||
throw new ActionForbidden('First user already created');
|
||||
}
|
||||
|
||||
const user = await this.user.createUser({
|
||||
const user = await this.models.user.create({
|
||||
email: input.email,
|
||||
password: input.password,
|
||||
registered: true,
|
||||
@@ -59,7 +74,7 @@ export class CustomSetupController {
|
||||
await this.auth.setCookies(req, res, user.id);
|
||||
res.send({ id: user.id, email: user.email, name: user.name });
|
||||
} catch (e) {
|
||||
await this.user.deleteUser(user.id);
|
||||
await this.models.user.delete(user.id);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
61
packages/backend/server/src/core/user/event.ts
Normal file
61
packages/backend/server/src/core/user/event.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
|
||||
import { Config, type EventPayload, OnEvent } from '../../base';
|
||||
|
||||
@Injectable()
|
||||
export class UserEventsListener {
|
||||
private readonly logger = new Logger(UserEventsListener.name);
|
||||
|
||||
constructor(private readonly config: Config) {}
|
||||
|
||||
@OnEvent('user.updated')
|
||||
async onUserUpdated(user: EventPayload<'user.updated'>) {
|
||||
const { enabled, customerIo } = this.config.metrics;
|
||||
if (enabled && customerIo?.token) {
|
||||
const payload = {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
created_at: Number(user.createdAt) / 1000,
|
||||
};
|
||||
try {
|
||||
await fetch(`https://track.customer.io/api/v1/customers/${user.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Basic ${customerIo.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to publish user update event:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent('user.deleted')
|
||||
async onUserDeleted(user: EventPayload<'user.deleted'>) {
|
||||
const { enabled, customerIo } = this.config.metrics;
|
||||
if (enabled && customerIo?.token) {
|
||||
try {
|
||||
if (user.emailVerifiedAt) {
|
||||
// suppress email if email is verified
|
||||
await fetch(
|
||||
`https://track.customer.io/api/v1/customers/${user.email}/suppress`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${customerIo.token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
await fetch(`https://track.customer.io/api/v1/customers/${user.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Basic ${customerIo.token}` },
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to publish user delete event:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,16 +3,14 @@ import { Module } from '@nestjs/common';
|
||||
import { PermissionModule } from '../permission';
|
||||
import { StorageModule } from '../storage';
|
||||
import { UserAvatarController } from './controller';
|
||||
import { UserEventsListener } from './event';
|
||||
import { UserManagementResolver, UserResolver } from './resolver';
|
||||
import { UserService } from './service';
|
||||
|
||||
@Module({
|
||||
imports: [StorageModule, PermissionModule],
|
||||
providers: [UserResolver, UserService, UserManagementResolver],
|
||||
providers: [UserResolver, UserManagementResolver, UserEventsListener],
|
||||
controllers: [UserAvatarController],
|
||||
exports: [UserService],
|
||||
})
|
||||
export class UserModule {}
|
||||
|
||||
export { UserService } from './service';
|
||||
export { UserType } from './types';
|
||||
|
||||
@@ -17,13 +17,13 @@ import {
|
||||
Throttle,
|
||||
UserNotFound,
|
||||
} from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import { Public } from '../auth/guard';
|
||||
import { sessionUser } from '../auth/service';
|
||||
import { CurrentUser } from '../auth/session';
|
||||
import { Admin } from '../common';
|
||||
import { AvatarStorage } from '../storage';
|
||||
import { validators } from '../utils/validators';
|
||||
import { UserService } from './service';
|
||||
import {
|
||||
DeleteAccount,
|
||||
ManageUserInput,
|
||||
@@ -37,7 +37,7 @@ import {
|
||||
export class UserResolver {
|
||||
constructor(
|
||||
private readonly storage: AvatarStorage,
|
||||
private readonly users: UserService
|
||||
private readonly models: Models
|
||||
) {}
|
||||
|
||||
@Throttle('strict')
|
||||
@@ -54,7 +54,7 @@ export class UserResolver {
|
||||
validators.assertValidEmail(email);
|
||||
|
||||
// TODO(@forehalo): need to limit a user can only get another user witch is in the same workspace
|
||||
const user = await this.users.findUserWithHashedPasswordByEmail(email);
|
||||
const user = await this.models.user.getUserByEmail(email);
|
||||
|
||||
// return empty response when user not exists
|
||||
if (!user) return null;
|
||||
@@ -99,7 +99,7 @@ export class UserResolver {
|
||||
await this.storage.delete(user.avatarUrl);
|
||||
}
|
||||
|
||||
return this.users.updateUser(user.id, { avatarUrl });
|
||||
return this.models.user.update(user.id, { avatarUrl });
|
||||
}
|
||||
|
||||
@Mutation(() => UserType, {
|
||||
@@ -115,7 +115,7 @@ export class UserResolver {
|
||||
return user;
|
||||
}
|
||||
|
||||
return sessionUser(await this.users.updateUser(user.id, input));
|
||||
return sessionUser(await this.models.user.update(user.id, input));
|
||||
}
|
||||
|
||||
@Mutation(() => RemoveAvatar, {
|
||||
@@ -126,7 +126,7 @@ export class UserResolver {
|
||||
if (!user) {
|
||||
throw new UserNotFound();
|
||||
}
|
||||
await this.users.updateUser(user.id, { avatarUrl: null });
|
||||
await this.models.user.update(user.id, { avatarUrl: null });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ export class UserResolver {
|
||||
async deleteAccount(
|
||||
@CurrentUser() user: CurrentUser
|
||||
): Promise<DeleteAccount> {
|
||||
await this.users.deleteUser(user.id);
|
||||
await this.models.user.delete(user.id);
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -162,7 +162,7 @@ class CreateUserInput {
|
||||
export class UserManagementResolver {
|
||||
constructor(
|
||||
private readonly db: PrismaClient,
|
||||
private readonly user: UserService
|
||||
private readonly models: Models
|
||||
) {}
|
||||
|
||||
@Query(() => Int, {
|
||||
@@ -178,11 +178,7 @@ export class UserManagementResolver {
|
||||
async users(
|
||||
@Args({ name: 'filter', type: () => ListUserInput }) input: ListUserInput
|
||||
): Promise<UserType[]> {
|
||||
const users = await this.db.user.findMany({
|
||||
select: { ...this.user.defaultUserSelect, password: true },
|
||||
skip: input.skip,
|
||||
take: input.first,
|
||||
});
|
||||
const users = await this.models.user.pagination(input.skip, input.first);
|
||||
|
||||
return users.map(sessionUser);
|
||||
}
|
||||
@@ -192,12 +188,7 @@ export class UserManagementResolver {
|
||||
description: 'Get user by id',
|
||||
})
|
||||
async getUser(@Args('id') id: string) {
|
||||
const user = await this.db.user.findUnique({
|
||||
select: { ...this.user.defaultUserSelect, password: true },
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
});
|
||||
const user = await this.models.user.get(id);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
@@ -212,12 +203,7 @@ export class UserManagementResolver {
|
||||
nullable: true,
|
||||
})
|
||||
async getUserByEmail(@Args('email') email: string) {
|
||||
const user = await this.db.user.findUnique({
|
||||
select: { ...this.user.defaultUserSelect, password: true },
|
||||
where: {
|
||||
email,
|
||||
},
|
||||
});
|
||||
const user = await this.models.user.getUserByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
@@ -232,7 +218,7 @@ export class UserManagementResolver {
|
||||
async createUser(
|
||||
@Args({ name: 'input', type: () => CreateUserInput }) input: CreateUserInput
|
||||
) {
|
||||
const { id } = await this.user.createUser({
|
||||
const { id } = await this.models.user.create({
|
||||
email: input.email,
|
||||
registered: true,
|
||||
});
|
||||
@@ -251,7 +237,7 @@ export class UserManagementResolver {
|
||||
if (user.id === id) {
|
||||
throw new CannotDeleteOwnAccount();
|
||||
}
|
||||
await this.user.deleteUser(id);
|
||||
await this.models.user.delete(id);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
@@ -276,7 +262,7 @@ export class UserManagementResolver {
|
||||
}
|
||||
|
||||
return sessionUser(
|
||||
await this.user.updateUser(user.id, {
|
||||
await this.models.user.update(user.id, {
|
||||
email: input.email,
|
||||
name: input.name,
|
||||
})
|
||||
|
||||
@@ -1,339 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma, PrismaClient, User } from '@prisma/client';
|
||||
|
||||
import {
|
||||
Config,
|
||||
CryptoHelper,
|
||||
EmailAlreadyUsed,
|
||||
EventEmitter,
|
||||
type EventPayload,
|
||||
OnEvent,
|
||||
Runtime,
|
||||
WrongSignInCredentials,
|
||||
WrongSignInMethod,
|
||||
} from '../../base';
|
||||
import { PermissionService } from '../permission';
|
||||
import { Quota_FreePlanV1_1 } from '../quota/schema';
|
||||
import { validators } from '../utils/validators';
|
||||
|
||||
type CreateUserInput = Omit<Prisma.UserCreateInput, 'name'> & { name?: string };
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
private readonly logger = new Logger(UserService.name);
|
||||
|
||||
defaultUserSelect = {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
emailVerifiedAt: true,
|
||||
avatarUrl: true,
|
||||
registered: true,
|
||||
createdAt: true,
|
||||
} satisfies Prisma.UserSelect;
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly runtime: Runtime,
|
||||
private readonly crypto: CryptoHelper,
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly emitter: EventEmitter,
|
||||
private readonly permission: PermissionService
|
||||
) {}
|
||||
|
||||
get userCreatingData() {
|
||||
return {
|
||||
name: 'Unnamed',
|
||||
features: {
|
||||
create: {
|
||||
reason: 'sign up',
|
||||
activated: true,
|
||||
feature: {
|
||||
connect: {
|
||||
feature_version: Quota_FreePlanV1_1,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async createUser(data: CreateUserInput) {
|
||||
validators.assertValidEmail(data.email);
|
||||
|
||||
if (data.password) {
|
||||
const config = await this.runtime.fetchAll({
|
||||
'auth/password.max': true,
|
||||
'auth/password.min': true,
|
||||
});
|
||||
validators.assertValidPassword(data.password, {
|
||||
max: config['auth/password.max'],
|
||||
min: config['auth/password.min'],
|
||||
});
|
||||
}
|
||||
|
||||
return this.createUser_without_verification(data);
|
||||
}
|
||||
|
||||
async createUser_without_verification(data: CreateUserInput) {
|
||||
const user = await this.findUserByEmail(data.email);
|
||||
|
||||
if (user) {
|
||||
throw new EmailAlreadyUsed();
|
||||
}
|
||||
|
||||
if (data.password) {
|
||||
data.password = await this.crypto.encryptPassword(data.password);
|
||||
}
|
||||
|
||||
if (!data.name) {
|
||||
data.name = data.email.split('@')[0];
|
||||
}
|
||||
|
||||
return this.prisma.user.create({
|
||||
select: this.defaultUserSelect,
|
||||
data: {
|
||||
...this.userCreatingData,
|
||||
...data,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async findUserById(id: string) {
|
||||
return this.prisma.user
|
||||
.findUnique({
|
||||
where: { id },
|
||||
select: this.defaultUserSelect,
|
||||
})
|
||||
.catch(() => {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
async findUserByEmail(
|
||||
email: string
|
||||
): Promise<Pick<User, keyof typeof this.defaultUserSelect> | null> {
|
||||
validators.assertValidEmail(email);
|
||||
const rows = await this.prisma.$queryRaw<
|
||||
// see [this.defaultUserSelect]
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
email_verified: Date | null;
|
||||
avatar_url: string | null;
|
||||
registered: boolean;
|
||||
created_at: Date;
|
||||
}[]
|
||||
>`
|
||||
SELECT "id", "name", "email", "email_verified", "avatar_url", "registered", "created_at"
|
||||
FROM "users"
|
||||
WHERE lower("email") = lower(${email})
|
||||
`;
|
||||
|
||||
const user = rows[0];
|
||||
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...user,
|
||||
emailVerifiedAt: user.email_verified,
|
||||
avatarUrl: user.avatar_url,
|
||||
createdAt: user.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* supposed to be used only for `Credential SignIn`
|
||||
*/
|
||||
async findUserWithHashedPasswordByEmail(email: string): Promise<User | null> {
|
||||
validators.assertValidEmail(email);
|
||||
|
||||
// see https://www.prisma.io/docs/orm/prisma-client/using-raw-sql/raw-queries#typing-queryraw-results
|
||||
const rows = await this.prisma.$queryRaw<
|
||||
{
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
password: string | null;
|
||||
email_verified: Date | null;
|
||||
avatar_url: string | null;
|
||||
registered: boolean;
|
||||
created_at: Date;
|
||||
}[]
|
||||
>`
|
||||
SELECT *
|
||||
FROM "users"
|
||||
WHERE lower("email") = lower(${email})
|
||||
`;
|
||||
|
||||
const user = rows[0];
|
||||
if (!user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
...user,
|
||||
emailVerifiedAt: user.email_verified,
|
||||
avatarUrl: user.avatar_url,
|
||||
createdAt: user.created_at,
|
||||
};
|
||||
}
|
||||
|
||||
async signIn(email: string, password: string) {
|
||||
const user = await this.findUserWithHashedPasswordByEmail(email);
|
||||
|
||||
if (!user) {
|
||||
throw new WrongSignInCredentials({ email });
|
||||
}
|
||||
|
||||
if (!user.password) {
|
||||
throw new WrongSignInMethod();
|
||||
}
|
||||
|
||||
const passwordMatches = await this.crypto.verifyPassword(
|
||||
password,
|
||||
user.password
|
||||
);
|
||||
|
||||
if (!passwordMatches) {
|
||||
throw new WrongSignInCredentials({ email });
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async fulfillUser(
|
||||
email: string,
|
||||
data: Omit<Partial<Prisma.UserCreateInput>, 'id'>
|
||||
) {
|
||||
const user = await this.findUserByEmail(email);
|
||||
if (!user) {
|
||||
return this.createUser({
|
||||
...this.userCreatingData,
|
||||
email,
|
||||
name: email.split('@')[0],
|
||||
...data,
|
||||
});
|
||||
} else {
|
||||
if (user.registered) {
|
||||
delete data.registered;
|
||||
}
|
||||
if (user.emailVerifiedAt) {
|
||||
delete data.emailVerifiedAt;
|
||||
}
|
||||
|
||||
if (Object.keys(data).length) {
|
||||
return await this.prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// @ts-expect-error will be removed
|
||||
this.emitter.emit('user.updated', user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async updateUser(
|
||||
id: string,
|
||||
data: Omit<Partial<Prisma.UserCreateInput>, 'id'>,
|
||||
select: Prisma.UserSelect = this.defaultUserSelect
|
||||
) {
|
||||
if (data.password) {
|
||||
const config = await this.runtime.fetchAll({
|
||||
'auth/password.max': true,
|
||||
'auth/password.min': true,
|
||||
});
|
||||
validators.assertValidPassword(data.password, {
|
||||
max: config['auth/password.max'],
|
||||
min: config['auth/password.min'],
|
||||
});
|
||||
|
||||
data.password = await this.crypto.encryptPassword(data.password);
|
||||
}
|
||||
|
||||
if (data.email) {
|
||||
validators.assertValidEmail(data.email);
|
||||
const emailTaken = await this.prisma.user.count({
|
||||
where: {
|
||||
email: data.email,
|
||||
id: {
|
||||
not: id,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (emailTaken) {
|
||||
throw new EmailAlreadyUsed();
|
||||
}
|
||||
}
|
||||
|
||||
const user = await this.prisma.user.update({ where: { id }, data, select });
|
||||
|
||||
this.emitter.emit('user.updated', user);
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
async deleteUser(id: string) {
|
||||
const ownedWorkspaces = await this.permission.getOwnedWorkspaces(id);
|
||||
const user = await this.prisma.user.delete({ where: { id } });
|
||||
this.emitter.emit('user.deleted', { ...user, ownedWorkspaces });
|
||||
}
|
||||
|
||||
@OnEvent('user.updated')
|
||||
async onUserUpdated(user: EventPayload<'user.updated'>) {
|
||||
const { enabled, customerIo } = this.config.metrics;
|
||||
if (enabled && customerIo?.token) {
|
||||
const payload = {
|
||||
name: user.name,
|
||||
email: user.email,
|
||||
created_at: Number(user.createdAt) / 1000,
|
||||
};
|
||||
try {
|
||||
await fetch(`https://track.customer.io/api/v1/customers/${user.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
Authorization: `Basic ${customerIo.token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to publish user update event:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OnEvent('user.deleted')
|
||||
async onUserDeleted(user: EventPayload<'user.deleted'>) {
|
||||
const { enabled, customerIo } = this.config.metrics;
|
||||
if (enabled && customerIo?.token) {
|
||||
try {
|
||||
if (user.emailVerifiedAt) {
|
||||
// suppress email if email is verified
|
||||
await fetch(
|
||||
`https://track.customer.io/api/v1/customers/${user.email}/suppress`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Basic ${customerIo.token}`,
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
await fetch(`https://track.customer.io/api/v1/customers/${user.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Basic ${customerIo.token}` },
|
||||
});
|
||||
} catch (e) {
|
||||
this.logger.error('Failed to publish user delete event:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,10 +3,10 @@ import { PrismaClient } from '@prisma/client';
|
||||
import { getStreamAsBuffer } from 'get-stream';
|
||||
|
||||
import { Cache, MailService, UserNotFound } from '../../../base';
|
||||
import { Models } from '../../../models';
|
||||
import { DocContentService } from '../../doc-renderer';
|
||||
import { Permission, PermissionService } from '../../permission';
|
||||
import { WorkspaceBlobStorage } from '../../storage';
|
||||
import { UserService } from '../../user';
|
||||
|
||||
export const defaultWorkspaceAvatar =
|
||||
'iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAQtSURBVHgBfVa9jhxFEK6q7rkf+4T2AgdIIC0ZoXkBuNQJtngBuIzs1hIRye1FhL438D0CRgKRGUeE6wwkhHYlkE2AtGdkbN/MdJe/qu7Z27PWnnG5Znq7v/rqd47pHddkNh/918tR1/FBamXc9zxOPVFKfJ4yP86qD1LD3/986/3F2zB40+LXv83HrHq/6+gAoNS1kF4odUz2nhJRTkI5E6mD6Bk1crLJkLy5cHc+P4ohzxLng8RKLqKUq6hkUtBSe8Zvdmfir7TT2a0fnkzeaeCbv/44ztSfZskjP2ygVRM0mbYTpgHMMMS8CsIIj/c+//Hp8UYD3z758whQUwdeEwPjAZQLqJhI0VxB2MVco+kXP/0zuZKD6dP5uM397ELzqEtMba/UJ4t7iXeq8U94z52Q+js09qjlIXMxAEsRDJpI59dVPzlDTooHko7BdlR2FcYmAtbGMmAt2mFI4yDQkIjtEQkxUAMKAPD9SiOK4b578N0S7Nt+fqFKbTbmRD1YGXurEmdtnjjz4kFuIV0gtWewV62hMHBY2gpEOw3Rnmztx9jnO72xzTV/YkzgNmgkiypeYJdCLjonqyAAg7VCshVpjTbD08HbxrySdhKxcDvoJTA5gLvpeXVQ+K340WKea9UkNeZVqGSba/IbF6athj+LUeRmRCyiAVnlAKhJJQfmugGZ28ZWna24RGzwNUNUqpWGf6HkajvAgNA4NsSjHgcb9obx+k5c3DUttcwd3NcHxpVurXQ2d4MZACGw9TwEHsdtbEwytL1xywAGcxavjoH1quLVywuGi+aBhFWexRilFSwK0QzgdUdkkVMeKw4wijrgxjzz2CefCRZn+21ViOWW4Ym9nNnyFLMbMS8ivNhGP8RdlgUojBkuBLDpEPi+5LpWiDURgFkKOIIckJTgN/sZ84KtKkKpDnsOZiTQ47jD4ZGwHghbw6AXIL3lo5Zg6Tp2AwIAyYJ8BRzGfmfPl6kI7HOLUdN2LIg+4IfL5SiFdvkK4blI6h50qda7jQI0CUMLdEhFIkqtQciMvXsgpaZ1pWtVUfrIa+TX5/8+RBcftAhTa91r8ycXA5ZxBqhAh2zgVagUAddxMkxfF/JxfvbpB+8d2jhBtsPhtuqsE0HJlhxYeHKdkCU8xUCos8dmkDdnGaOlJ1yy9dM52J2spqldvz9fTgB4z+aQd2kqjUY2KU2s4dTT7ezD0AqDAbvZiKF/VO9+fGPv9IoBu+b/P5ti6djDY+JlSg4ug1jc6fJbMAx9/3b4CNGTD/evT698D9avv188m4gKvko8MiMeJC3jmOvU9MSuHXZohAVpOrmxd+10HW/jR3/58uU45TRFt35ZR2XpY61DzW+tH3z/7xdM8sP93d3Fm1gbDawbEtU7CMtt/JVxEw01Kh7RAmoBE4+u7eycYv38bRivAZbdHBtPrwOHAAAAAElFTkSuQmCC';
|
||||
@@ -35,7 +35,7 @@ export class WorkspaceService {
|
||||
private readonly mailer: MailService,
|
||||
private readonly permission: PermissionService,
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly user: UserService
|
||||
private readonly models: Models
|
||||
) {}
|
||||
|
||||
async getInviteInfo(inviteId: string): Promise<InviteInfo> {
|
||||
@@ -92,7 +92,7 @@ export class WorkspaceService {
|
||||
return;
|
||||
}
|
||||
const workspace = await this.getWorkspaceInfo(workspaceId);
|
||||
const invitee = await this.user.findUserById(inviteeUserId);
|
||||
const invitee = await this.models.user.getPublicUser(inviteeUserId);
|
||||
if (!invitee) {
|
||||
this.logger.error(
|
||||
`Invitee user not found in workspace: ${workspaceId}, userId: ${inviteeUserId}`
|
||||
@@ -111,10 +111,10 @@ export class WorkspaceService {
|
||||
await this.getInviteInfo(inviteId);
|
||||
const workspace = await this.getWorkspaceInfo(workspaceId);
|
||||
const invitee = inviteeUserId
|
||||
? await this.user.findUserById(inviteeUserId)
|
||||
? await this.models.user.getPublicUser(inviteeUserId)
|
||||
: null;
|
||||
const inviter = inviterUserId
|
||||
? await this.user.findUserById(inviterUserId)
|
||||
? await this.models.user.getPublicUser(inviterUserId)
|
||||
: await this.permission.getWorkspaceOwner(workspaceId);
|
||||
|
||||
if (!inviter || !invitee) {
|
||||
@@ -138,7 +138,7 @@ export class WorkspaceService {
|
||||
return;
|
||||
}
|
||||
|
||||
const invitee = await this.user.findUserById(inviteeUserId);
|
||||
const invitee = await this.models.user.getPublicUser(inviteeUserId);
|
||||
if (!invitee) {
|
||||
this.logger.error(
|
||||
`Invitee user not found for inviteId: ${inviteId}, userId: ${inviteeUserId}`
|
||||
@@ -199,7 +199,7 @@ export class WorkspaceService {
|
||||
userId: string,
|
||||
ws: { id: string; role: Permission }
|
||||
) {
|
||||
const user = await this.user.findUserById(userId);
|
||||
const user = await this.models.user.getPublicUser(userId);
|
||||
if (!user) throw new UserNotFound();
|
||||
const workspace = await this.getWorkspaceInfo(ws.id);
|
||||
await this.mailer.sendRoleChangedEmail(user?.email, {
|
||||
|
||||
@@ -20,10 +20,10 @@ import {
|
||||
URLHelper,
|
||||
UserFriendlyError,
|
||||
} from '../../../base';
|
||||
import { Models } from '../../../models';
|
||||
import { CurrentUser } from '../../auth';
|
||||
import { Permission, PermissionService } from '../../permission';
|
||||
import { QuotaManagementService } from '../../quota';
|
||||
import { UserService } from '../../user';
|
||||
import {
|
||||
InviteLink,
|
||||
InviteResult,
|
||||
@@ -47,7 +47,7 @@ export class TeamWorkspaceResolver {
|
||||
private readonly url: URLHelper,
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly permissions: PermissionService,
|
||||
private readonly users: UserService,
|
||||
private readonly models: Models,
|
||||
private readonly quota: QuotaManagementService,
|
||||
private readonly mutex: RequestMutex,
|
||||
private readonly workspaceService: WorkspaceService
|
||||
@@ -92,7 +92,7 @@ export class TeamWorkspaceResolver {
|
||||
for (const [idx, email] of emails.entries()) {
|
||||
const ret: InviteResult = { email, sentSuccess: false, inviteId: null };
|
||||
try {
|
||||
let target = await this.users.findUserByEmail(email);
|
||||
let target = await this.models.user.getUserByEmail(email);
|
||||
if (target) {
|
||||
const originRecord =
|
||||
await this.prisma.workspaceUserPermission.findFirst({
|
||||
@@ -104,7 +104,7 @@ export class TeamWorkspaceResolver {
|
||||
// only invite if the user is not already in the workspace
|
||||
if (originRecord) continue;
|
||||
} else {
|
||||
target = await this.users.createUser({
|
||||
target = await this.models.user.create({
|
||||
email,
|
||||
registered: false,
|
||||
});
|
||||
@@ -352,7 +352,7 @@ export class TeamWorkspaceResolver {
|
||||
userId,
|
||||
workspaceId,
|
||||
}: EventPayload<'workspace.members.requestDeclined'>) {
|
||||
const user = await this.users.findUserById(userId);
|
||||
const user = await this.models.user.getPublicUser(userId);
|
||||
const workspace = await this.workspaceService.getWorkspaceInfo(workspaceId);
|
||||
// send decline mail
|
||||
await this.workspaceService.sendReviewDeclinedEmail(
|
||||
|
||||
@@ -30,11 +30,12 @@ import {
|
||||
UserFriendlyError,
|
||||
UserNotFound,
|
||||
} from '../../../base';
|
||||
import { Models } from '../../../models';
|
||||
import { CurrentUser, Public } from '../../auth';
|
||||
import { type Editor, PgWorkspaceDocStorageAdapter } from '../../doc';
|
||||
import { Permission, PermissionService } from '../../permission';
|
||||
import { QuotaManagementService, QuotaQueryType } from '../../quota';
|
||||
import { UserService, UserType } from '../../user';
|
||||
import { UserType } from '../../user';
|
||||
import {
|
||||
InvitationType,
|
||||
InviteUserType,
|
||||
@@ -82,7 +83,7 @@ export class WorkspaceResolver {
|
||||
private readonly prisma: PrismaClient,
|
||||
private readonly permissions: PermissionService,
|
||||
private readonly quota: QuotaManagementService,
|
||||
private readonly users: UserService,
|
||||
private readonly models: Models,
|
||||
private readonly event: EventEmitter,
|
||||
private readonly mutex: RequestMutex,
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
@@ -407,7 +408,7 @@ export class WorkspaceResolver {
|
||||
// member limit check
|
||||
await this.quota.checkWorkspaceSeat(workspaceId);
|
||||
|
||||
let target = await this.users.findUserByEmail(email);
|
||||
let target = await this.models.user.getUserByEmail(email);
|
||||
if (target) {
|
||||
const originRecord =
|
||||
await this.prisma.workspaceUserPermission.findFirst({
|
||||
@@ -419,7 +420,7 @@ export class WorkspaceResolver {
|
||||
// only invite if the user is not already in the workspace
|
||||
if (originRecord) return originRecord.id;
|
||||
} else {
|
||||
target = await this.users.createUser({
|
||||
target = await this.models.user.create({
|
||||
email,
|
||||
registered: false,
|
||||
});
|
||||
@@ -480,7 +481,7 @@ export class WorkspaceResolver {
|
||||
|
||||
const inviteeId = inviteeUserId || user?.id;
|
||||
if (!inviteeId) throw new UserNotFound();
|
||||
const invitee = await this.users.findUserById(inviteeId);
|
||||
const invitee = await this.models.user.getPublicUser(inviteeId);
|
||||
|
||||
return { workspace, user: owner, invitee };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user