From 49a74bff51333ae31c3ea3fbb995b0132f6db80e Mon Sep 17 00:00:00 2001 From: Adelina Enache Date: Fri, 7 Jun 2024 16:56:49 +0300 Subject: [PATCH] feat: Connect social accounts (#95) --- src/auth/controller/auth.controller.ts | 130 +++++++++++++-- src/auth/service/auth.service.ts | 156 +++++++++++++++--- .../migration.sql | 6 + src/prisma/schema.prisma | 12 +- src/user/service/user.service.ts | 1 - 5 files changed, 267 insertions(+), 38 deletions(-) create mode 100644 src/prisma/migrations/20240606141406_social_accounts_fields/migration.sql diff --git a/src/auth/controller/auth.controller.ts b/src/auth/controller/auth.controller.ts index 2e99498..87a617b 100644 --- a/src/auth/controller/auth.controller.ts +++ b/src/auth/controller/auth.controller.ts @@ -35,6 +35,8 @@ import { import { userProperties } from '../../schemas/user.properties'; import { LowercasePipe } from '../../common/pipes/lowercase.pipe'; import { GithubOAuthStrategyFactory } from '../../oauth/factory/github/github-strategy.factory'; +import { CurrentUser } from '../../decorators/current-user.decorator'; +import { SocialAccountType, User } from '@prisma/client'; @Controller('auth') @ApiTags('Auth Controller') @@ -96,17 +98,50 @@ export class AuthController { res.status(302).redirect('/api/auth/facebook/callback'); } + @Public() + @Get('facebook/connect') + @ApiOperation({ + summary: 'Facebook Social Account connect', + description: 'Connect account with Facebook profile', + }) + async facebookSocialConnect(@Res() res, @Query() query, @Req() req) { + if (!this.facebookOAuthStrategyFactory.isOAuthEnabled()) { + throw new HttpException( + 'Facebook Auth is not enabled in this environment.', + HttpStatus.BAD_REQUEST, + ); + } + + req.session.app_url = query.app_url; + + const token = query.token; + const user = await this.authService.getUserFromToken(token); + req.session.intent = { facebook: 'connect' }; + req.session.userId = user.id; + + res.status(302).redirect('/api/auth/facebook/callback'); + } + @Public() @Get('facebook/callback') @UseGuards(AuthGuard('facebook')) async facebookOAuthCallback(@Req() req, @Res() res) { - const user = await this.authService.handleFacebookOAuthLogin(req); - const host = req.session.app_url; - res.send( - ``, - ); + if (req.session.intent?.facebook == 'connect') { + await this.authService.connectSocialPlatform( + SocialAccountType.FACEBOOK, + req.session.userId, + req, + ); + + res.send(``); + } else { + const user = await this.authService.handleFacebookOAuthLogin(req); + res.send( + ``, + ); + } } @Public() @@ -127,16 +162,46 @@ export class AuthController { res.status(302).redirect('/api/auth/linkedin/callback'); } + @Public() + @Get('linkedin/connect') + @ApiOperation({ + summary: 'LinkedIn Social Account connect', + description: 'Sign in or sign up with LinkedIn', + }) + async linkedinSocialConnect(@Res() res, @Query() query, @Req() req) { + if (!this.linkedinOAuthStrategyFactory.isOAuthEnabled()) { + throw new HttpException( + 'LinkedIn Auth is not enabled in this environment.', + HttpStatus.BAD_REQUEST, + ); + } + const token = query.token; + const user = await this.authService.getUserFromToken(token); + req.session.app_url = query.app_url; + req.session.intent = { linkedin: 'connect' }; + req.session.userId = user.id; + res.status(302).redirect('/api/auth/linkedin/callback'); + } + @Public() @Get('linkedin/callback') @UseGuards(AuthGuard('linkedin')) async linkedinOAuthCallback(@Req() req, @Res() res) { - const user = await this.authService.handleLinkedInOAuthLogin(req); const host = req.session.app_url; - res.send( - ``, - ); + if (req.session.intent?.linkedin == 'connect') { + await this.authService.connectSocialPlatform( + SocialAccountType.LINKEDIN, + req.session.userId, + req, + ); + res.send(``); + } else { + const user = await this.authService.handleLinkedInOAuthLogin(req); + res.send( + ``, + ); + } } @Public() @@ -189,16 +254,50 @@ export class AuthController { res.status(302).redirect('/api/auth/github/callback'); } + @Public() + @Get('github/connect') + @ApiOperation({ + summary: 'Github Social Account connect', + description: 'Connect account with Github profile', + }) + async githubSocialConnect(@Res() res, @Query() query, @Req() req) { + if (!this.githubOAuthStrategyFactory.isOAuthEnabled()) { + throw new HttpException( + 'Github Auth is not enabled in this environment.', + HttpStatus.BAD_REQUEST, + ); + } + + req.session.app_url = query.app_url; + + const token = query.token; + const user = await this.authService.getUserFromToken(token); + req.session.intent = { github: 'connect' }; + req.session.userId = user.id; + + res.status(302).redirect('/api/auth/github/callback'); + } + @Public() @Get('github/callback') @UseGuards(AuthGuard('github')) async githubOAuthCallback(@Req() req, @Res() res) { - const user = await this.authService.handleGithubOAuthLogin(req); const host = req.session.app_url; - res.send( - ``, - ); + if (req.session.intent?.github == 'connect') { + await this.authService.connectSocialPlatform( + SocialAccountType.GITHUB, + req.session.userId, + req, + ); + + res.send(``); + } else { + const user = await this.authService.handleGithubOAuthLogin(req); + res.send( + ``, + ); + } } @Public() @@ -287,4 +386,9 @@ export class AuthController { async verifyEmail(@Body() dto: EmailVerificationDto) { return await this.authService.verifyEmail(dto.email, dto.code); } + + @Get('/social-accounts') + async getSocialAccounts(@CurrentUser() user: User) { + return this.authService.getSocialAccounts(user.id); + } } diff --git a/src/auth/service/auth.service.ts b/src/auth/service/auth.service.ts index 0842425..d01981a 100644 --- a/src/auth/service/auth.service.ts +++ b/src/auth/service/auth.service.ts @@ -1,12 +1,13 @@ import { BadRequestException, ConflictException, + ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; import { PrismaService } from '../../prisma/prisma.service'; import { JwtService } from '@nestjs/jwt'; -import { AuthType, User } from '@prisma/client'; +import { AuthType, SocialAccountType, User } from '@prisma/client'; import { SignupDto } from '../dto/signup.dto'; import { MailService } from '../../mail/mail.service'; import { SigninDto } from '../dto/signin.dto'; @@ -77,13 +78,34 @@ export class AuthService { const displayName = name.givenName + ' ' + name.familyName; const profilePictureUrl = photos[0].value; - const user = await this.createUserIfNotExists( - email, - AuthType.FACEBOOK, - displayName, - profilePictureUrl, - false, - ); + const socialAccount = await this.prisma.socialAccount.findFirst({ + where: { + socialId: req.user.socialId, + platform: SocialAccountType.FACEBOOK, + }, + include: { + user: true, + }, + }); + + let user; + + if (socialAccount) { + user = socialAccount.user; + } else { + user = await this.createUserIfNotExists( + email, + AuthType.FACEBOOK, + displayName, + profilePictureUrl, + false, + ); + await this.connectSocialPlatform( + SocialAccountType.FACEBOOK, + user.id, + req, + ); + } const token = await this.generateToken(user); @@ -99,12 +121,34 @@ export class AuthService { const displayName = name.givenName + ' ' + name.familyName; const profilePictureUrl = photos[0].value; - const user = await this.createUserIfNotExists( - email, - AuthType.LINKEDIN, - displayName, - profilePictureUrl, - ); + const socialAccount = await this.prisma.socialAccount.findFirst({ + where: { + socialId: req.user.socialId, + platform: SocialAccountType.LINKEDIN, + }, + include: { + user: true, + }, + }); + + let user; + + if (socialAccount) { + user = socialAccount.user; + } else { + user = await this.createUserIfNotExists( + email, + AuthType.LINKEDIN, + displayName, + profilePictureUrl, + false, + ); + await this.connectSocialPlatform( + SocialAccountType.LINKEDIN, + user.id, + req, + ); + } const token = await this.generateToken(user); @@ -134,12 +178,30 @@ export class AuthService { async handleGithubOAuthLogin(req: any) { const { email, name, login, avatar_url } = req.user._json; - const user = await this.createUserIfNotExists( - email || login, - AuthType.GITHUB, - name, - avatar_url, - ); + const socialAccount = await this.prisma.socialAccount.findFirst({ + where: { + socialId: req.user.socialId, + platform: SocialAccountType.GITHUB, + }, + include: { + user: true, + }, + }); + + let user; + + if (socialAccount) { + user = socialAccount.user; + } else { + user = await this.createUserIfNotExists( + email || login, + AuthType.GITHUB, + name, + avatar_url, + false, + ); + await this.connectSocialPlatform(SocialAccountType.GITHUB, user.id, req); + } const token = await this.generateToken(user); @@ -322,4 +384,58 @@ export class AuthService { }); await this.mailService.sendEmailVerificationCode(email, code); } + + async connectSocialPlatform( + platform: SocialAccountType, + userId: string, + req: any, + ) { + console.log(platform, userId, req.user); + + const socialAcc = await this.prisma.socialAccount.findMany({ + where: { socialId: req.user.id, platform }, + }); + + if (socialAcc.length !== 0) { + throw new BadRequestException( + 'Social Account Already conected with another account', + ); + } + + return this.prisma.socialAccount.create({ + data: { + platform, + displayName: + req.user.displayName || + (req.user._json.first_name + ? `${req.user._json.first_name} ${req.user._json.last_name}` + : req.user._json.login), + email: req.user.emails?.[0]?.value || req.user._json.email, + socialId: req.user.id, + profileUrl: req.user.profileUrl, + pictureUrl: req.user.photos[0].value, + userId, + }, + }); + } + + async getSocialAccounts(userId: string) { + return this.prisma.socialAccount.findMany({ where: { userId } }); + } + + async getUserFromToken(token: string) { + try { + const payload = await this.jwt.verifyAsync(token, { + secret: process.env.JWT_SECRET, + }); + + return await this.prisma.user.findUnique({ + where: { + id: payload['id'], + }, + }); + } catch { + throw new ForbiddenException(); + } + } } diff --git a/src/prisma/migrations/20240606141406_social_accounts_fields/migration.sql b/src/prisma/migrations/20240606141406_social_accounts_fields/migration.sql new file mode 100644 index 0000000..347f80d --- /dev/null +++ b/src/prisma/migrations/20240606141406_social_accounts_fields/migration.sql @@ -0,0 +1,6 @@ +-- AlterTable +ALTER TABLE "SocialAccount" ADD COLUMN "displayName" TEXT, +ADD COLUMN "email" TEXT, +ADD COLUMN "pictureUrl" TEXT, +ADD COLUMN "socialId" TEXT, +ALTER COLUMN "profileUrl" DROP NOT NULL; diff --git a/src/prisma/schema.prisma b/src/prisma/schema.prisma index b8c149d..b9d135a 100644 --- a/src/prisma/schema.prisma +++ b/src/prisma/schema.prisma @@ -57,10 +57,14 @@ model Connection { } model SocialAccount { - id Int @id @default(autoincrement()) - platform SocialAccountType - profileUrl String - addedOn DateTime @default(now()) + id Int @id @default(autoincrement()) + platform SocialAccountType + displayName String? + email String? + socialId String? + profileUrl String? + pictureUrl String? + addedOn DateTime @default(now()) user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) userId String diff --git a/src/user/service/user.service.ts b/src/user/service/user.service.ts index a13e434..b267090 100644 --- a/src/user/service/user.service.ts +++ b/src/user/service/user.service.ts @@ -13,7 +13,6 @@ import { S3_CLIENT } from '../../provider/s3.provider'; import { REDIS_CLIENT } from '../../provider/redis.provider'; import { Redis } from 'ioredis'; import { v4 } from 'uuid'; - import { getMimeType } from '../../utils/image'; import { UpdateUserSettingsDto } from '../dto/update-user-settings.dto';