Skip to content

Commit

Permalink
feat: support email invitation for unregistered users (#637)
Browse files Browse the repository at this point in the history
* feat: support email invitation for unregistered users

* fix: registered judgements

* chore: remove useless e2e
  • Loading branch information
boris-w committed Jun 3, 2024
1 parent 06d6145 commit 51b456f
Show file tree
Hide file tree
Showing 8 changed files with 68 additions and 37 deletions.
31 changes: 20 additions & 11 deletions apps/nestjs-backend/src/features/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,20 +51,35 @@ export class AuthService {

async validateUserByEmail(email: string, pass: string) {
const user = await this.userService.getUserByEmail(email);
if (!user) {
if (!user || (user.accounts.length === 0 && user.password == null)) {
throw new BadRequestException(`${email} not registered`);
}

if (!user.password) {
throw new BadRequestException('Password is not set');
}

const { password, salt, ...result } = user;
return (await this.comparePassword(pass, password, salt)) ? { ...result, password } : null;
}

async signup(email: string, password: string) {
const user = await this.userService.getUserByEmail(email);
if (user) {
if (user && (user.password !== null || user.accounts.length > 0)) {
throw new HttpException(`User ${email} is already registered`, HttpStatus.BAD_REQUEST);
}
const { salt, hashPassword } = await this.encodePassword(password);
return await this.prismaService.$tx(async () => {
if (user) {
return await this.prismaService.user.update({
where: { id: user.id, deletedTime: null },
data: {
salt,
password: hashPassword,
lastSignTime: new Date().toISOString(),
},
});
}
return await this.userService.createUser({
id: generateUserId(),
name: email.split('@')[0],
Expand Down Expand Up @@ -109,18 +124,12 @@ export class AuthService {
await this.sessionStoreService.clearByUserId(userId);
}

async refreshLastSignTime(userId: string) {
await this.prismaService.user.update({
where: { id: userId, deletedTime: null },
data: { lastSignTime: new Date().toISOString() },
});
}

async sendResetPasswordEmail(email: string) {
const user = await this.userService.getUserByEmail(email);
if (!user) {
throw new BadRequestException('Email is not registered');
if (!user || (user.accounts.length === 0 && user.password == null)) {
throw new BadRequestException(`${email} not registered`);
}

const resetPasswordCode = getRandomString(30);

const url = `${this.mailConfig.origin}/auth/reset-password?code=${resetPasswordCode}`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { pickUserMe } from '../utils';
export class GithubStrategy extends PassportStrategy(Strategy, 'github') {
constructor(
@AuthConfig() readonly config: ConfigType<typeof authConfig>,
private usersService: UserService,
private userService: UserService,
oauthStoreService: OauthStoreService
) {
const { clientID, clientSecret } = config.github;
Expand All @@ -31,7 +31,7 @@ export class GithubStrategy extends PassportStrategy(Strategy, 'github') {
if (!email) {
throw new UnauthorizedException('No email provided from GitHub');
}
const user = await this.usersService.findOrCreateUser({
const user = await this.userService.findOrCreateUser({
name: displayName,
email,
provider: 'github',
Expand All @@ -42,6 +42,7 @@ export class GithubStrategy extends PassportStrategy(Strategy, 'github') {
if (!user) {
throw new UnauthorizedException('Failed to create user from GitHub profile');
}
await this.userService.refreshLastSignTime(user.id);
return pickUserMe(user);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import { pickUserMe } from '../utils';
export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
constructor(
@AuthConfig() readonly config: ConfigType<typeof authConfig>,
private usersService: UserService,
private userService: UserService,
oauthStoreService: OauthStoreService
) {
const { clientID, clientSecret, callbackURL } = config.google;
Expand All @@ -33,7 +33,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
if (!email) {
throw new UnauthorizedException('No email provided from Google');
}
const user = await this.usersService.findOrCreateUser({
const user = await this.userService.findOrCreateUser({
name: displayName,
email,
provider: 'google',
Expand All @@ -44,6 +44,7 @@ export class GoogleStrategy extends PassportStrategy(Strategy, 'google') {
if (!user) {
throw new UnauthorizedException('Failed to create user from Google profile');
}
await this.userService.refreshLastSignTime(user.id);
return pickUserMe(user);
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { Strategy } from 'passport-local';
import { UserService } from '../../user/user.service';
import { AuthService } from '../auth.service';
import { pickUserMe } from '../utils';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
constructor(private readonly authService: AuthService) {
constructor(
private readonly userService: UserService,
private readonly authService: AuthService
) {
super({
usernameField: 'email',
passwordField: 'password',
Expand All @@ -18,7 +22,7 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
if (!user) {
throw new BadRequestException('Incorrect password.');
}
await this.authService.refreshLastSignTime(user.id);
await this.userService.refreshLastSignTime(user.id);
return pickUserMe(user);
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { CollaboratorModule } from '../collaborator/collaborator.module';
import { UserModule } from '../user/user.module';
import { InvitationController } from './invitation.controller';
import { InvitationService } from './invitation.service';

@Module({
imports: [CollaboratorModule],
imports: [CollaboratorModule, UserModule],
providers: [InvitationService],
exports: [InvitationService],
controllers: [InvitationController],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,18 +97,6 @@ describe('InvitationService', () => {
).rejects.toThrow('Space not found');
});

it('should throw error if emails empty', async () => {
prismaService.user.findMany.mockResolvedValue([]);
prismaService.space.findFirst.mockResolvedValue(mockSpace as any);

await expect(
invitationService.emailInvitationBySpace(mockSpace.id, {
emails: [],
role: SpaceRole.Viewer,
})
).rejects.toThrow('Email not exist');
});

it('should send invitation email correctly', async () => {
// mock data
prismaService.space.findFirst.mockResolvedValue(mockSpace as any);
Expand Down
23 changes: 19 additions & 4 deletions apps/nestjs-backend/src/features/invitation/invitation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,14 @@ import type {
UpdateSpaceInvitationLinkRo,
} from '@teable/openapi';
import dayjs from 'dayjs';
import { pick } from 'lodash';
import { ClsService } from 'nestjs-cls';
import type { IMailConfig } from '../../configs/mail.config';
import type { IClsStore } from '../../types/cls';
import { generateInvitationCode } from '../../utils/code-generate';
import { CollaboratorService } from '../collaborator/collaborator.service';
import { MailSenderService } from '../mail-sender/mail-sender.service';
import { UserService } from '../user/user.service';

@Injectable()
export class InvitationService {
Expand All @@ -31,14 +33,24 @@ export class InvitationService {
private readonly cls: ClsService<IClsStore>,
private readonly configService: ConfigService,
private readonly mailSenderService: MailSenderService,
private readonly collaboratorService: CollaboratorService
private readonly collaboratorService: CollaboratorService,
private readonly userService: UserService
) {}

private generateInviteUrl(invitationId: string, invitationCode: string) {
const mailConfig = this.configService.get<IMailConfig>('mail');
return `${mailConfig?.origin}/invite?invitationId=${invitationId}&invitationCode=${invitationCode}`;
}

private async createNotExistedUser(emails: string[]) {
const users: { email: string; name: string; id: string }[] = [];
for (const email of emails) {
const user = await this.userService.createUser({ email });
users.push(pick(user, 'id', 'name', 'email'));
}
return users;
}

async emailInvitationBySpace(spaceId: string, data: EmailSpaceInvitationRo) {
const user = this.cls.get('user');
const space = await this.prismaService.space.findFirst({
Expand All @@ -53,11 +65,14 @@ export class InvitationService {
select: { id: true, name: true, email: true },
where: { email: { in: data.emails } },
});
if (sendUsers.length === 0) {
throw new BadRequestException('Email not exist');
}

const noExistEmails = data.emails.filter((email) => !sendUsers.find((u) => u.email === email));

return await this.prismaService.$tx(async () => {
// create user if not exist
const newUsers = await this.createNotExistedUser(noExistEmails);
sendUsers.push(...newUsers);

const { role } = data;
const result: EmailInvitationVo = {};
for (const sendUser of sendUsers) {
Expand Down
18 changes: 15 additions & 3 deletions apps/nestjs-backend/src/features/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export class UserService {
async getUserByEmail(email: string) {
return await this.prismaService
.txClient()
.user.findUnique({ where: { email, deletedTime: null } });
.user.findUnique({ where: { email, deletedTime: null }, include: { accounts: true } });
}

async createSpaceBySignup(createSpaceRo: ICreateSpaceRo) {
Expand Down Expand Up @@ -73,7 +73,7 @@ export class UserService {
}

async createUser(
user: Prisma.UserCreateInput,
user: Omit<Prisma.UserCreateInput, 'name'> & { name?: string },
account?: Omit<Prisma.AccountUncheckedCreateInput, 'userId'>
) {
// defaults
Expand All @@ -95,7 +95,12 @@ export class UserService {
};
}
// default space created
const newUser = await this.prismaService.txClient().user.create({ data: user });
const newUser = await this.prismaService.txClient().user.create({
data: {
...user,
name: user.name ?? user.email.split('@')[0],
},
});
const { id, name } = newUser;
if (account) {
await this.prismaService.txClient().account.create({
Expand Down Expand Up @@ -276,4 +281,11 @@ export class UserService {
return existUser;
});
}

async refreshLastSignTime(userId: string) {
await this.prismaService.txClient().user.update({
where: { id: userId, deletedTime: null },
data: { lastSignTime: new Date().toISOString() },
});
}
}

0 comments on commit 51b456f

Please sign in to comment.