diff --git a/.env.example b/.env.example index 20cece26..dfcf215c 100644 --- a/.env.example +++ b/.env.example @@ -52,6 +52,11 @@ CACHE_DRIVER=inMemory REDIS_PORT= 6379 REDIS_HOST='localhost' +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +# em prod: https://api.vcnafacul.com.br/user/auth/google/callback +GOOGLE_CALLBACK_URL=http://localhost:3333/user/auth/google/callback +CLIENT_URL=http://localhost:5173 # Queue: 'memory' (in-memory, dev/homol) | 'redis' (prod) QUEUE_DRIVER=memory diff --git a/package-lock.json b/package-lock.json index fce0ff68..f8c45657 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "@nestjs/typeorm": "^10.0.0", "@react-email/components": "0.0.27", "@react-email/tailwind": "1.0.1", + "@types/passport-google-oauth20": "^2.0.17", "@types/qrcode": "^1.5.6", "axios": "^1.5.0", "basic-ftp": "^5.0.3", @@ -51,6 +52,7 @@ "nodemailer-express-handlebars": "^6.1.2", "openai": "^6.32.0", "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "pdfmake": "^0.2.20", "pg": "^8.11.3", @@ -7391,7 +7393,6 @@ "version": "1.19.6", "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", - "dev": true, "license": "MIT", "dependencies": { "@types/connect": "*", @@ -7409,7 +7410,6 @@ "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*" @@ -7475,7 +7475,6 @@ "version": "4.17.23", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.23.tgz", "integrity": "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ==", - "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -7488,7 +7487,6 @@ "version": "4.19.6", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz", "integrity": "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -7511,7 +7509,6 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", - "dev": true, "license": "MIT" }, "node_modules/@types/istanbul-lib-coverage": { @@ -7593,7 +7590,6 @@ "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", - "dev": true, "license": "MIT" }, "node_modules/@types/ms": { @@ -7671,16 +7667,33 @@ "@types/node": "*" } }, + "node_modules/@types/oauth": { + "version": "0.9.6", + "resolved": "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz", + "integrity": "sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/passport": { "version": "1.0.17", "resolved": "https://registry.npmjs.org/@types/passport/-/passport-1.0.17.tgz", "integrity": "sha512-aciLyx+wDwT2t2/kJGJR2AEeBz0nJU4WuRX04Wu9Dqc5lSUtwu0WERPHYsLhF9PtseiAMPBGNUOtFjxZ56prsg==", - "dev": true, "license": "MIT", "dependencies": { "@types/express": "*" } }, + "node_modules/@types/passport-google-oauth20": { + "version": "2.0.17", + "resolved": "https://registry.npmjs.org/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.17.tgz", + "integrity": "sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ==", + "dependencies": { + "@types/express": "*", + "@types/passport": "*", + "@types/passport-oauth2": "*" + } + }, "node_modules/@types/passport-jwt": { "version": "3.0.13", "resolved": "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-3.0.13.tgz", @@ -7733,6 +7746,16 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ==", + "dependencies": { + "@types/express": "*", + "@types/oauth": "*", + "@types/passport": "*" + } + }, "node_modules/@types/passport-strategy": { "version": "0.2.38", "resolved": "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz", @@ -7788,7 +7811,6 @@ "version": "5.0.3", "resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.3.tgz", "integrity": "sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==", - "dev": true, "license": "MIT", "dependencies": { "@types/body-parser": "*", @@ -7800,7 +7822,6 @@ "version": "5.0.6", "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.0.6.tgz", "integrity": "sha512-3xhRnjJPkULekpSzgtoNYYcTWgEZkp4myc+Saevii5JPnHNvHMRlBSHDbs7Bh1iPPoVTERHEZXyhyLbMEsExsA==", - "dev": true, "license": "MIT", "dependencies": { "@types/node": "*", @@ -7811,7 +7832,6 @@ }, "node_modules/@types/passport/node_modules/@types/node": { "version": "24.0.3", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.8.0" @@ -7821,7 +7841,6 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz", "integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==", - "dev": true, "license": "MIT" }, "node_modules/@types/pdfkit": { @@ -7865,14 +7884,12 @@ "version": "6.14.0", "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz", "integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/range-parser": { "version": "1.2.7", "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", - "dev": true, "license": "MIT" }, "node_modules/@types/react": { @@ -7928,7 +7945,6 @@ "version": "0.17.5", "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.5.tgz", "integrity": "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w==", - "dev": true, "license": "MIT", "dependencies": { "@types/mime": "^1", @@ -7939,7 +7955,6 @@ "version": "1.15.8", "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.8.tgz", "integrity": "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg==", - "dev": true, "license": "MIT", "dependencies": { "@types/http-errors": "*", @@ -9154,6 +9169,14 @@ "node": "^4.5.0 || >= 5.9" } }, + "node_modules/base64url": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", + "integrity": "sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==", + "engines": { + "node": ">=6.0.0" + } + }, "node_modules/basic-auth": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz", @@ -16151,6 +16174,11 @@ "integrity": "sha512-K7J4xq5xAD5jHsGM5ReWXRTFa3JRGofHiMcVgQ8PRwgWxzjHpMWCIzsmyf60+mh8KLsqYPcjUMa0AC4hd6lPyQ==", "license": "MIT" }, + "node_modules/oauth": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz", + "integrity": "sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -16522,6 +16550,17 @@ "url": "https://github.com/sponsors/jaredhanson" } }, + "node_modules/passport-google-oauth20": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz", + "integrity": "sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ==", + "dependencies": { + "passport-oauth2": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/passport-jwt": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz", @@ -16532,6 +16571,25 @@ "passport-strategy": "^1.0.0" } }, + "node_modules/passport-oauth2": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", + "integrity": "sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==", + "dependencies": { + "base64url": "3.x.x", + "oauth": "0.10.x", + "passport-strategy": "1.x.x", + "uid2": "0.0.x", + "utils-merge": "1.x.x" + }, + "engines": { + "node": ">= 0.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/jaredhanson" + } + }, "node_modules/passport-strategy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz", @@ -20217,6 +20275,11 @@ "node": ">=8" } }, + "node_modules/uid2": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz", + "integrity": "sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==" + }, "node_modules/uint8array-extras": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.4.0.tgz", diff --git a/package.json b/package.json index f9ea6a35..7cef4acd 100644 --- a/package.json +++ b/package.json @@ -43,6 +43,7 @@ "@nestjs/typeorm": "^10.0.0", "@react-email/components": "0.0.27", "@react-email/tailwind": "1.0.1", + "@types/passport-google-oauth20": "^2.0.17", "@types/qrcode": "^1.5.6", "axios": "^1.5.0", "basic-ftp": "^5.0.3", @@ -68,6 +69,7 @@ "nodemailer-express-handlebars": "^6.1.2", "openai": "^6.32.0", "passport": "^0.7.0", + "passport-google-oauth20": "^2.0.0", "passport-jwt": "^4.0.1", "pdfmake": "^0.2.20", "pg": "^8.11.3", diff --git a/src/db/migrations/1778250000000-google-social-login.ts b/src/db/migrations/1778250000000-google-social-login.ts new file mode 100644 index 00000000..7f6cf08a --- /dev/null +++ b/src/db/migrations/1778250000000-google-social-login.ts @@ -0,0 +1,88 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class GoogleSocialLogin1778250000000 implements MigrationInterface { + name = 'GoogleSocialLogin1778250000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`users\` MODIFY \`password\` varchar(255) NULL`, + ); + await queryRunner.query( + `ALTER TABLE \`users\` MODIFY \`phone\` varchar(255) NULL`, + ); + await queryRunner.query( + `ALTER TABLE \`users\` MODIFY \`gender\` int NULL`, + ); + await queryRunner.query( + `ALTER TABLE \`users\` MODIFY \`birthday\` datetime NULL`, + ); + await queryRunner.query( + `ALTER TABLE \`users\` MODIFY \`state\` varchar(255) NULL`, + ); + await queryRunner.query( + `ALTER TABLE \`users\` MODIFY \`city\` varchar(255) NULL`, + ); + await queryRunner.query( + `ALTER TABLE \`users\` ADD \`google_id\` varchar(255) NULL`, + ); + await queryRunner.query( + `ALTER TABLE \`users\` ADD UNIQUE INDEX \`IDX_users_google_id\` (\`google_id\`)`, + ); + await queryRunner.query( + `ALTER TABLE \`users\` ADD \`profile_complete\` tinyint NULL`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE \`users\` DROP COLUMN \`profile_complete\``, + ); + await queryRunner.query( + `ALTER TABLE \`users\` DROP INDEX \`IDX_users_google_id\``, + ); + // Remover usuários criados via Google antes de restaurar NOT NULL, + // pois esses registros possuem colunas obrigatórias nulas. + await queryRunner.query( + `DELETE FROM \`users\` WHERE \`google_id\` IS NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE \`users\` DROP COLUMN \`google_id\``, + ); + await queryRunner.query( + `UPDATE \`users\` SET \`city\` = '' WHERE \`city\` IS NULL`, + ); + await queryRunner.query( + `ALTER TABLE \`users\` MODIFY \`city\` varchar(255) NOT NULL`, + ); + await queryRunner.query( + `UPDATE \`users\` SET \`state\` = '' WHERE \`state\` IS NULL`, + ); + await queryRunner.query( + `ALTER TABLE \`users\` MODIFY \`state\` varchar(255) NOT NULL`, + ); + await queryRunner.query( + `UPDATE \`users\` SET \`birthday\` = '1970-01-01' WHERE \`birthday\` IS NULL`, + ); + await queryRunner.query( + `ALTER TABLE \`users\` MODIFY \`birthday\` datetime NOT NULL`, + ); + await queryRunner.query( + `UPDATE \`users\` SET \`gender\` = 0 WHERE \`gender\` IS NULL`, + ); + await queryRunner.query( + `ALTER TABLE \`users\` MODIFY \`gender\` int NOT NULL`, + ); + await queryRunner.query( + `UPDATE \`users\` SET \`phone\` = '' WHERE \`phone\` IS NULL`, + ); + await queryRunner.query( + `ALTER TABLE \`users\` MODIFY \`phone\` varchar(255) NOT NULL`, + ); + await queryRunner.query( + `UPDATE \`users\` SET \`password\` = '' WHERE \`password\` IS NULL`, + ); + await queryRunner.query( + `ALTER TABLE \`users\` MODIFY \`password\` varchar(255) NOT NULL`, + ); + } +} diff --git a/src/modules/user/dto/complete-profile.dto.ts b/src/modules/user/dto/complete-profile.dto.ts new file mode 100644 index 00000000..678636c8 --- /dev/null +++ b/src/modules/user/dto/complete-profile.dto.ts @@ -0,0 +1,37 @@ +import { + Equals, + IsBoolean, + IsDateString, + IsEnum, + IsNotEmpty, + IsString, + MaxLength, +} from 'class-validator'; +import { Gender } from '../enum/gender'; + +export class CompleteProfileDto { + @IsString() + @IsNotEmpty() + @MaxLength(255) + phone: string; + + @IsEnum(Gender) + gender: Gender; + + @IsDateString() + birthday: string; + + @IsString() + @IsNotEmpty() + @MaxLength(255) + state: string; + + @IsString() + @IsNotEmpty() + @MaxLength(255) + city: string; + + @IsBoolean() + @Equals(true, { message: 'LGPD deve ser aceita' }) + lgpd: boolean; +} diff --git a/src/modules/user/dto/user.dto.output.ts b/src/modules/user/dto/user.dto.output.ts index 0cb16ca6..c3f9f2d6 100644 --- a/src/modules/user/dto/user.dto.output.ts +++ b/src/modules/user/dto/user.dto.output.ts @@ -72,4 +72,6 @@ export class UserDtoOutput { collaboratorFrentes?: string[] = undefined; afinidades?: AfinidadeDto[] = undefined; profiles?: string[] = undefined; + + profileComplete: boolean | null = null; } diff --git a/src/modules/user/guards/google-auth.guard.ts b/src/modules/user/guards/google-auth.guard.ts new file mode 100644 index 00000000..4a2c87ac --- /dev/null +++ b/src/modules/user/guards/google-auth.guard.ts @@ -0,0 +1,5 @@ +import { Injectable } from '@nestjs/common'; +import { AuthGuard } from '@nestjs/passport'; + +@Injectable() +export class GoogleAuthGuard extends AuthGuard('google') {} diff --git a/src/modules/user/strategy/google.strategy.ts b/src/modules/user/strategy/google.strategy.ts new file mode 100644 index 00000000..1dd4849f --- /dev/null +++ b/src/modules/user/strategy/google.strategy.ts @@ -0,0 +1,43 @@ +import { Injectable, UnauthorizedException } from '@nestjs/common'; +import { PassportStrategy } from '@nestjs/passport'; +import { Strategy, Profile } from 'passport-google-oauth20'; +import { EnvService } from 'src/shared/modules/env/env.service'; + +export interface GoogleUser { + googleId: string; + email: string; + firstName: string; + lastName: string; +} + +@Injectable() +export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { + constructor(envService: EnvService) { + super({ + clientID: envService.get('GOOGLE_CLIENT_ID'), + clientSecret: envService.get('GOOGLE_CLIENT_SECRET'), + callbackURL: envService.get('GOOGLE_CALLBACK_URL'), + scope: ['email', 'profile'], + }); + } + + async validate( + _accessToken: string, + _refreshToken: string, + profile: Profile, + ): Promise { + const email = profile.emails?.[0]?.value; + if (!email) { + throw new UnauthorizedException('Google profile sem email'); + } + if (profile.emails[0].verified === false) { + throw new UnauthorizedException('Email Google não verificado'); + } + return { + googleId: profile.id, + email, + firstName: profile.name?.givenName, + lastName: profile.name?.familyName, + }; + } +} diff --git a/src/modules/user/user.controller.ts b/src/modules/user/user.controller.ts index 3d0946ee..c365822c 100644 --- a/src/modules/user/user.controller.ts +++ b/src/modules/user/user.controller.ts @@ -13,7 +13,12 @@ import { SetMetadata, UseGuards, } from '@nestjs/common'; -import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { + ApiBearerAuth, + ApiExcludeEndpoint, + ApiOperation, + ApiTags, +} from '@nestjs/swagger'; import { Throttle } from '@nestjs/throttler'; import { THROTTLE_CONFIG } from 'src/shared/config/email.config'; import { Request, Response } from 'express'; @@ -22,6 +27,7 @@ import { GetAllDtoOutput } from 'src/shared/dtos/get-all.dto.output'; import { JwtAuthGuard } from 'src/shared/guards/jwt-auth.guard'; import { PermissionsGuard } from 'src/shared/guards/permission.guard'; import { Permissions } from '../role/role.entity'; +import { CompleteProfileDto } from './dto/complete-profile.dto'; import { CreateUserDtoInput } from './dto/create.dto.input'; import { ForgotPasswordDtoInput } from './dto/forgot-password.dto.input'; import { GetUserDtoInput } from './dto/get-user.dto.input'; @@ -34,6 +40,8 @@ import { SendBulkEmailDtoInput } from './dto/send-bulk-email.dto.input'; import { UpdateUserRoleInput } from './dto/update-user-role.dto.input'; import { UpdateUserDTOInput } from './dto/update.dto.input'; import { UserWithRoleName } from './dto/userWithRoleName'; +import { GoogleAuthGuard } from './guards/google-auth.guard'; +import { GoogleUser } from './strategy/google.strategy'; import { User } from './user.entity'; import { UserService } from './user.service'; @@ -135,6 +143,30 @@ export class UserController { }); } + @Get('auth/google') + @ApiOperation({ summary: 'Inicia o fluxo de autenticação com Google OAuth' }) + @UseGuards(GoogleAuthGuard) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async googleAuth(@Req() _req: Request) {} + + @Get('auth/google/callback') + @ApiExcludeEndpoint() + @UseGuards(GoogleAuthGuard) + async googleAuthCallback(@Req() req: Request, @Res() res: Response) { + const redirectUrl = await this.userService.handleGoogleCallback( + req.user as GoogleUser, + ); + return res.redirect(redirectUrl); + } + + @Patch('complete-profile') + @ApiOperation({ summary: 'Completa o perfil de usuário criado via Google OAuth' }) + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + async completeProfile(@Body() dto: CompleteProfileDto, @Req() req: Request) { + return await this.userService.completeProfile(dto, (req.user as User).id); + } + @Post('hasemail') // eslint-disable-next-line @typescript-eslint/no-unused-vars async hasEmail(@Body() dto: HasEmailDtoInput, @Res() res: Response) { diff --git a/src/modules/user/user.entity.ts b/src/modules/user/user.entity.ts index 7a2838d5..0f102aff 100644 --- a/src/modules/user/user.entity.ts +++ b/src/modules/user/user.entity.ts @@ -18,9 +18,9 @@ export class User extends BaseEntity { @Column({ unique: true }) public email: string; - @Column() + @Column({ nullable: true }) @Exclude() - public password: string; + public password: string | null; @Column() public firstName: string; @@ -34,14 +34,14 @@ export class User extends BaseEntity { @Column({ default: false }) public useSocialName: boolean; - @Column() - public phone: string; + @Column({ nullable: true }) + public phone: string | null; - @Column() - public gender: Gender; + @Column({ nullable: true }) + public gender: Gender | null; - @Column() - public birthday: Date; + @Column({ nullable: true }) + public birthday: Date | null; @Column({ nullable: true }) public street?: string; @@ -58,11 +58,11 @@ export class User extends BaseEntity { @Column({ nullable: true }) public neighborhood?: string; - @Column() - public state: string; + @Column({ nullable: true }) + public state: string | null; - @Column() - public city: string; + @Column({ nullable: true }) + public city: string | null; @Column({ nullable: true }) public about?: string; @@ -101,4 +101,10 @@ export class User extends BaseEntity { @Column({ nullable: true }) public lastAccess: Date; + + @Column({ nullable: true, unique: true, name: 'google_id' }) + public googleId: string | null; + + @Column({ nullable: true, name: 'profile_complete' }) + public profileComplete: boolean | null; } diff --git a/src/modules/user/user.module.ts b/src/modules/user/user.module.ts index 447e1bc1..30ac0b1e 100644 --- a/src/modules/user/user.module.ts +++ b/src/modules/user/user.module.ts @@ -20,6 +20,7 @@ import { RefreshTokenService } from './services/refresh-token.service'; import { UserController } from './user.controller'; import { UserRepository } from './user.repository'; import { UserService } from './user.service'; +import { GoogleStrategy } from './strategy/google.strategy'; import { EmailExistValidator } from './validator/email-exist.validator'; import { EmailUniqueValidator } from './validator/email-unique.validator'; import { UserExistValidator } from './validator/user-exist.validator'; @@ -44,6 +45,7 @@ import { UserExistValidator } from './validator/user-exist.validator'; StudentCourseRepository, DiscordWebhook, ProfileDetectorService, + GoogleStrategy, { provide: STUDENT_COURSE_REPO_TOKEN, useExisting: StudentCourseRepository, diff --git a/src/modules/user/user.service.ts b/src/modules/user/user.service.ts index f2658a19..6d146913 100644 --- a/src/modules/user/user.service.ts +++ b/src/modules/user/user.service.ts @@ -25,12 +25,14 @@ import { LoginDtoInput } from './dto/login.dto.input'; import { ResetPasswordDtoInput } from './dto/reset-password.dto.input'; import { SearchUsersDtoInput } from './dto/search-users.dto.input'; import { SearchUsersDtoOutput } from './dto/search-users.dto.output'; +import { CompleteProfileDto } from './dto/complete-profile.dto'; import { UpdateUserDTOInput } from './dto/update.dto.input'; import { UserDtoOutput } from './dto/user.dto.output'; import { UserWithRoleName } from './dto/userWithRoleName'; import { CreateFlow } from './enum/create-flow'; import { ProfileDetectorService } from './services/profile-detector.service'; import { RefreshTokenService } from './services/refresh-token.service'; +import { GoogleUser } from './strategy/google.strategy'; import { User } from './user.entity'; import { UserRepository } from './user.repository'; @@ -143,6 +145,9 @@ export class UserService extends BaseService { if (!userFullInfo || userFullInfo.deletedAt != null) { throw new HttpException('User not found', HttpStatus.NOT_FOUND); } + if (!userFullInfo.password) { + throw new HttpException('invalid credentials', HttpStatus.UNAUTHORIZED); + } if (!(await bcrypt.compare(loginInput.password, userFullInfo?.password))) { throw new HttpException('password invalid', HttpStatus.CONFLICT); } @@ -218,6 +223,86 @@ export class UserService extends BaseService { ); } + async handleGoogleCallback(googleUser: GoogleUser): Promise { + if (!googleUser.email) { + throw new HttpException('invalid credentials', HttpStatus.UNAUTHORIZED); + } + const clientUrl = this.envService.get('CLIENT_URL'); + + let user = await this.userRepository.findOneBy({ + email: googleUser.email, + }); + + if (user) { + if (!user.googleId) { + // emailConfirmSended é null quando o email foi confirmado; não-null significa pendente + if (user.emailConfirmSended !== null) { + const url = new URL(`${clientUrl}/login`); + url.searchParams.set('error', 'account_not_confirmed'); + return url.toString(); + } + user.googleId = googleUser.googleId; + await this.userRepository.update(user); + } + const tokenData = await this.getAccessToken(user); + if (user.profileComplete === false) { + const url = new URL(`${clientUrl}/onboarding`); + url.searchParams.set('token', tokenData.access_token); + return url.toString(); + } + const url = new URL(`${clientUrl}/auth/callback`); + url.searchParams.set('token', tokenData.access_token); + return url.toString(); + } + + const role = await this.roleRepository.findOneBy({ name: 'aluno' }); + if (!role) { + throw new HttpException( + 'role not found', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + const newUser = new User(); + newUser.email = googleUser.email; + newUser.firstName = googleUser.firstName; + newUser.lastName = googleUser.lastName; + newUser.googleId = googleUser.googleId; + newUser.profileComplete = false; + newUser.emailConfirmSended = null; + newUser.lgpd = false; + newUser.role = role; + const created = await this.userRepository.create(newUser); + const tokenData = await this.getAccessToken(created); + const url = new URL(`${clientUrl}/onboarding`); + url.searchParams.set('token', tokenData.access_token); + return url.toString(); + } + + async completeProfile( + dto: CompleteProfileDto, + userId: string, + ): Promise { + const user = await this.userRepository.findOneBy({ id: userId }); + if (!user) { + throw new HttpException('User not found', HttpStatus.NOT_FOUND); + } + if (user.profileComplete !== false) { + throw new HttpException( + 'Only Google OAuth users with incomplete profiles can use this endpoint', + HttpStatus.FORBIDDEN, + ); + } + user.phone = dto.phone; + user.gender = dto.gender; + user.birthday = new Date(dto.birthday); + user.state = dto.state; + user.city = dto.city; + user.lgpd = dto.lgpd; + user.profileComplete = true; + await this.userRepository.update(user); + return this.getAccessToken(user); + } + async reset(resetPassword: ResetPasswordDtoInput, userId: string) { const bcrypt = await import('bcrypt'); const user = await this.findUserById(userId); @@ -411,6 +496,7 @@ export class UserService extends BaseService { } const profiles = await this.profileDetector.detect(domain.id); + const profileComplete = domain.profileComplete === false ? false : true; // Gera o access token (15 minutos) const accessToken = await this.jwtService.signAsync({ @@ -418,6 +504,7 @@ export class UserService extends BaseService { roles, roleId: domain.role.id, profiles, + profileComplete, }); // Gera o refresh token (7 dias) diff --git a/src/shared/modules/env/env.ts b/src/shared/modules/env/env.ts index 56be4044..29c8482f 100644 --- a/src/shared/modules/env/env.ts +++ b/src/shared/modules/env/env.ts @@ -89,6 +89,15 @@ export const envSchema = z.object({ .string() .default('AIzaSyCK0EJwZiLpGw46t2sC811b63L8pVvT3A'), + // Google OAuth + GOOGLE_CLIENT_ID: z.string().default('dev-disabled'), + GOOGLE_CLIENT_SECRET: z.string().default('dev-disabled'), + GOOGLE_CALLBACK_URL: z + .string() + .url() + .default('http://localhost:3333/user/auth/google/callback'), + CLIENT_URL: z.string().url().default('http://localhost:5173'), + // Essay AI ESSAY_AI_ENABLED: z .enum(['true', 'false']) diff --git a/yarn.lock b/yarn.lock index e3cc47b8..8ad40324 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1806,7 +1806,7 @@ "@esbuild/linux-x64@0.19.11": version "0.19.11" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.19.11.tgz#b7390c4d5184f203ebe7ddaedf073df82a658766" + resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.11.tgz" integrity sha512-grbyMlVCvJSfxFQUndw5mCtWs5LO1gUlwP4CDi4iJBbVpZcqLVT29FxgGuBJGSzyOxotFG4LoO5X+M1350zmPA== "@esbuild/netbsd-x64@0.19.11": @@ -2115,7 +2115,7 @@ "@img/sharp-darwin-arm64@0.33.5": version "0.33.5" - resolved "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz" + resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz#ef5b5a07862805f1e8145a377c8ba6e98813ca08" integrity sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ== optionalDependencies: "@img/sharp-libvips-darwin-arm64" "1.0.4" @@ -2129,7 +2129,7 @@ "@img/sharp-libvips-darwin-arm64@1.0.4": version "1.0.4" - resolved "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz#447c5026700c01a993c7804eb8af5f6e9868c07f" integrity sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg== "@img/sharp-libvips-darwin-x64@1.0.4": @@ -2154,7 +2154,7 @@ "@img/sharp-libvips-linux-x64@1.0.4": version "1.0.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz#d4c4619cdd157774906e15770ee119931c7ef5e0" + resolved "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz" integrity sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw== "@img/sharp-libvips-linuxmusl-arm64@1.0.4": @@ -2164,7 +2164,7 @@ "@img/sharp-libvips-linuxmusl-x64@1.0.4": version "1.0.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz#93794e4d7720b077fcad3e02982f2f1c246751ff" + resolved "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz" integrity sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw== "@img/sharp-linux-arm64@0.33.5": @@ -2190,7 +2190,7 @@ "@img/sharp-linux-x64@0.33.5": version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz#d806e0afd71ae6775cc87f0da8f2d03a7c2209cb" + resolved "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz" integrity sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA== optionalDependencies: "@img/sharp-libvips-linux-x64" "1.0.4" @@ -2204,7 +2204,7 @@ "@img/sharp-linuxmusl-x64@0.33.5": version "0.33.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz#3f4609ac5d8ef8ec7dadee80b560961a60fd4f48" + resolved "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz" integrity sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw== optionalDependencies: "@img/sharp-libvips-linuxmusl-x64" "1.0.4" @@ -2587,12 +2587,12 @@ "@napi-rs/snappy-linux-x64-gnu@7.2.2": version "7.2.2" - resolved "https://registry.yarnpkg.com/@napi-rs/snappy-linux-x64-gnu/-/snappy-linux-x64-gnu-7.2.2.tgz#681bfa25d8ed38a0bbec56827aa2762146c7e035" + resolved "https://registry.npmjs.org/@napi-rs/snappy-linux-x64-gnu/-/snappy-linux-x64-gnu-7.2.2.tgz" integrity sha512-jZt8Jit/HHDcavt80zxEkDpH+R1Ic0ssiVCoueASzMXa7vwPJeF4ZxZyqUw4qeSy7n8UUExomu8G8ZbP6VKhgw== "@napi-rs/snappy-linux-x64-musl@7.2.2": version "7.2.2" - resolved "https://registry.yarnpkg.com/@napi-rs/snappy-linux-x64-musl/-/snappy-linux-x64-musl-7.2.2.tgz#4607f33fd0ef95a11143deff0d465428abcaae5a" + resolved "https://registry.npmjs.org/@napi-rs/snappy-linux-x64-musl/-/snappy-linux-x64-musl-7.2.2.tgz" integrity sha512-Dh96IXgcZrV39a+Tej/owcd9vr5ihiZ3KRix11rr1v0MWtVb61+H1GXXlz6+Zcx9y8jM1NmOuiIuJwkV4vZ4WA== "@napi-rs/snappy-win32-arm64-msvc@7.2.2": @@ -2782,12 +2782,12 @@ "@next/swc-linux-x64-gnu@14.2.10": version "14.2.10" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.10.tgz#086f2f16a0678890a1eb46518c4dda381b046082" + resolved "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-14.2.10.tgz" integrity sha512-GXvajAWh2woTT0GKEDlkVhFNxhJS/XdDmrVHrPOA83pLzlGPQnixqxD8u3bBB9oATBKB//5e4vpACnx5Vaxdqg== "@next/swc-linux-x64-musl@14.2.10": version "14.2.10" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.10.tgz#1befef10ed8dbcc5047b5d637a25ae3c30a0bfc3" + resolved "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-14.2.10.tgz" integrity sha512-opFFN5B0SnO+HTz4Wq4HaylXGFV+iHrVxd3YvREUX9K+xfc4ePbRrxqOuPOFjtSuiVouwe6uLeDtabjEIbkmDA== "@next/swc-win32-arm64-msvc@14.2.10": @@ -3953,6 +3953,22 @@ dependencies: "@types/node" "*" +"@types/oauth@*": + version "0.9.6" + resolved "https://registry.npmjs.org/@types/oauth/-/oauth-0.9.6.tgz" + integrity sha512-H9TRCVKBNOhZZmyHLqFt9drPM9l+ShWiqqJijU1B8P3DX3ub84NjxDuy+Hjrz+fEca5Kwip3qPMKNyiLgNJtIA== + dependencies: + "@types/node" "*" + +"@types/passport-google-oauth20@^2.0.17": + version "2.0.17" + resolved "https://registry.yarnpkg.com/@types/passport-google-oauth20/-/passport-google-oauth20-2.0.17.tgz#3dd53ac66b8a3df40d505296da32c831efd8a64f" + integrity sha512-MHNOd2l7gOTCn3iS+wInPQMiukliAUvMpODO3VlXxOiwNEMSyzV7UNvAdqxSN872o8OXx1SqPDVT6tLW74AtqQ== + dependencies: + "@types/express" "*" + "@types/passport" "*" + "@types/passport-oauth2" "*" + "@types/passport-jwt@^3.0.9": version "3.0.13" resolved "https://registry.npmjs.org/@types/passport-jwt/-/passport-jwt-3.0.13.tgz" @@ -3962,6 +3978,15 @@ "@types/jsonwebtoken" "*" "@types/passport-strategy" "*" +"@types/passport-oauth2@*": + version "1.8.0" + resolved "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz" + integrity sha512-6//z+4orIOy/g3zx17HyQ71GSRK4bs7Sb+zFasRoc2xzlv7ZCJ+vkDBYFci8U6HY+or6Zy7ajf4mz4rK7nsWJQ== + dependencies: + "@types/express" "*" + "@types/oauth" "*" + "@types/passport" "*" + "@types/passport-strategy@*": version "0.2.38" resolved "https://registry.npmjs.org/@types/passport-strategy/-/passport-strategy-0.2.38.tgz" @@ -4820,6 +4845,11 @@ base64id@2.0.0, base64id@~2.0.0: resolved "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz" integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog== +base64url@3.x.x: + version "3.0.1" + resolved "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz" + integrity sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A== + basic-auth@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/basic-auth/-/basic-auth-2.0.1.tgz" @@ -8945,6 +8975,11 @@ nssocket@0.6.0: eventemitter2 "~0.4.14" lazy "~1.0.11" +oauth@0.10.x: + version "0.10.2" + resolved "https://registry.npmjs.org/oauth/-/oauth-0.10.2.tgz" + integrity sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q== + object-assign@^4, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz" @@ -9155,6 +9190,13 @@ parseurl@~1.3.3: resolved "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== +passport-google-oauth20@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/passport-google-oauth20/-/passport-google-oauth20-2.0.0.tgz" + integrity sha512-KSk6IJ15RoxuGq7D1UKK/8qKhNfzbLeLrG3gkLZ7p4A6DBCcv7xpyQwuXtWdpyR0+E0mwkpjY1VfPOhxQrKzdQ== + dependencies: + passport-oauth2 "1.x.x" + passport-jwt@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/passport-jwt/-/passport-jwt-4.0.1.tgz" @@ -9163,6 +9205,17 @@ passport-jwt@^4.0.1: jsonwebtoken "^9.0.0" passport-strategy "^1.0.0" +passport-oauth2@1.x.x: + version "1.8.0" + resolved "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz" + integrity sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA== + dependencies: + base64url "3.x.x" + oauth "0.10.x" + passport-strategy "1.x.x" + uid2 "0.0.x" + utils-merge "1.x.x" + passport-strategy@1.x.x, passport-strategy@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/passport-strategy/-/passport-strategy-1.0.0.tgz" @@ -11036,6 +11089,11 @@ uglify-js@^3.1.4: resolved "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz" integrity sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ== +uid2@0.0.x: + version "0.0.4" + resolved "https://registry.npmjs.org/uid2/-/uid2-0.0.4.tgz" + integrity sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA== + uid@2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/uid/-/uid-2.0.2.tgz" @@ -11153,7 +11211,7 @@ util-deprecate@^1.0.1, util-deprecate@~1.0.1: resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== -utils-merge@1.0.1, utils-merge@^1.0.1: +utils-merge@1.0.1, utils-merge@1.x.x, utils-merge@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz" integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==