diff --git a/packages/hoppscotch-backend/prisma/migrations/20240519093155_add_last_logged_on_to_user/migration.sql b/packages/hoppscotch-backend/prisma/migrations/20240519093155_add_last_logged_on_to_user/migration.sql new file mode 100644 index 000000000..5664c4fc0 --- /dev/null +++ b/packages/hoppscotch-backend/prisma/migrations/20240519093155_add_last_logged_on_to_user/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "lastLoggedOn" TIMESTAMP(3); diff --git a/packages/hoppscotch-backend/prisma/schema.prisma b/packages/hoppscotch-backend/prisma/schema.prisma index 3225d9ccc..0da385415 100644 --- a/packages/hoppscotch-backend/prisma/schema.prisma +++ b/packages/hoppscotch-backend/prisma/schema.prisma @@ -41,31 +41,31 @@ model TeamInvitation { } model TeamCollection { - id String @id @default(cuid()) + id String @id @default(cuid()) parentID String? data Json? - parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id]) - children TeamCollection[] @relation("TeamCollectionChildParent") + parent TeamCollection? @relation("TeamCollectionChildParent", fields: [parentID], references: [id]) + children TeamCollection[] @relation("TeamCollectionChildParent") requests TeamRequest[] teamID String - team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) + team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) title String orderIndex Int - createdOn DateTime @default(now()) @db.Timestamp(3) - updatedOn DateTime @updatedAt @db.Timestamp(3) + createdOn DateTime @default(now()) @db.Timestamp(3) + updatedOn DateTime @updatedAt @db.Timestamp(3) } model TeamRequest { - id String @id @default(cuid()) + id String @id @default(cuid()) collectionID String - collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade) + collection TeamCollection @relation(fields: [collectionID], references: [id], onDelete: Cascade) teamID String - team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) + team Team @relation(fields: [teamID], references: [id], onDelete: Cascade) title String request Json orderIndex Int - createdOn DateTime @default(now()) @db.Timestamp(3) - updatedOn DateTime @updatedAt @db.Timestamp(3) + createdOn DateTime @default(now()) @db.Timestamp(3) + updatedOn DateTime @updatedAt @db.Timestamp(3) } model Shortcode { @@ -104,6 +104,7 @@ model User { userRequests UserRequest[] currentRESTSession Json? currentGQLSession Json? + lastLoggedOn DateTime? createdOn DateTime @default(now()) @db.Timestamp(3) invitedUsers InvitedUsers[] shortcodes Shortcode[] diff --git a/packages/hoppscotch-backend/src/admin/admin.service.spec.ts b/packages/hoppscotch-backend/src/admin/admin.service.spec.ts index a7ebc0682..335348ab4 100644 --- a/packages/hoppscotch-backend/src/admin/admin.service.spec.ts +++ b/packages/hoppscotch-backend/src/admin/admin.service.spec.ts @@ -74,6 +74,7 @@ const dbAdminUsers: DbUser[] = [ refreshToken: 'refreshToken', currentRESTSession: '', currentGQLSession: '', + lastLoggedOn: new Date(), createdOn: new Date(), }, { @@ -85,20 +86,10 @@ const dbAdminUsers: DbUser[] = [ refreshToken: 'refreshToken', currentRESTSession: '', currentGQLSession: '', + lastLoggedOn: new Date(), createdOn: new Date(), }, ]; -const dbNonAminUser: DbUser = { - uid: 'uid 3', - displayName: 'displayName', - email: 'email@email.com', - photoURL: 'photoURL', - isAdmin: false, - refreshToken: 'refreshToken', - currentRESTSession: '', - currentGQLSession: '', - createdOn: new Date(), -}; describe('AdminService', () => { describe('fetchInvitedUsers', () => { diff --git a/packages/hoppscotch-backend/src/auth/auth.controller.ts b/packages/hoppscotch-backend/src/auth/auth.controller.ts index b8cde8f3d..9d652db49 100644 --- a/packages/hoppscotch-backend/src/auth/auth.controller.ts +++ b/packages/hoppscotch-backend/src/auth/auth.controller.ts @@ -7,6 +7,7 @@ import { Request, Res, UseGuards, + UseInterceptors, } from '@nestjs/common'; import { AuthService } from './auth.service'; import { SignInMagicDto } from './dto/signin-magic.dto'; @@ -27,6 +28,7 @@ import { SkipThrottle } from '@nestjs/throttler'; import { AUTH_PROVIDER_NOT_SPECIFIED } from 'src/errors'; import { ConfigService } from '@nestjs/config'; import { throwHTTPErr } from 'src/utils'; +import { UserLastLoginInterceptor } from 'src/interceptors/user-last-login.interceptor'; @UseGuards(ThrottlerBehindProxyGuard) @Controller({ path: 'auth', version: '1' }) @@ -110,6 +112,7 @@ export class AuthController { @Get('google/callback') @SkipThrottle() @UseGuards(GoogleSSOGuard) + @UseInterceptors(UserLastLoginInterceptor) async googleAuthRedirect(@Request() req, @Res() res) { const authTokens = await this.authService.generateAuthTokens(req.user.uid); if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left); @@ -135,6 +138,7 @@ export class AuthController { @Get('github/callback') @SkipThrottle() @UseGuards(GithubSSOGuard) + @UseInterceptors(UserLastLoginInterceptor) async githubAuthRedirect(@Request() req, @Res() res) { const authTokens = await this.authService.generateAuthTokens(req.user.uid); if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left); @@ -160,6 +164,7 @@ export class AuthController { @Get('microsoft/callback') @SkipThrottle() @UseGuards(MicrosoftSSOGuard) + @UseInterceptors(UserLastLoginInterceptor) async microsoftAuthRedirect(@Request() req, @Res() res) { const authTokens = await this.authService.generateAuthTokens(req.user.uid); if (E.isLeft(authTokens)) throwHTTPErr(authTokens.left); diff --git a/packages/hoppscotch-backend/src/auth/auth.service.spec.ts b/packages/hoppscotch-backend/src/auth/auth.service.spec.ts index c8979518b..90a9e3ba9 100644 --- a/packages/hoppscotch-backend/src/auth/auth.service.spec.ts +++ b/packages/hoppscotch-backend/src/auth/auth.service.spec.ts @@ -51,6 +51,7 @@ const user: AuthUser = { photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', isAdmin: false, refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + lastLoggedOn: currentTime, createdOn: currentTime, currentGQLSession: {}, currentRESTSession: {}, @@ -172,9 +173,11 @@ describe('verifyMagicLinkTokens', () => { // generateAuthTokens mockJWT.sign.mockReturnValue(user.refreshToken); // UpdateUserRefreshToken - mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user)); + mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user)); // deletePasswordlessVerificationToken mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData); + // usersService.updateUserLastLoggedOn + mockUser.updateUserLastLoggedOn.mockResolvedValue(E.right(true)); const result = await authService.verifyMagicLinkTokens(magicLinkVerify); expect(result).toEqualRight({ @@ -197,9 +200,11 @@ describe('verifyMagicLinkTokens', () => { // generateAuthTokens mockJWT.sign.mockReturnValue(user.refreshToken); // UpdateUserRefreshToken - mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user)); + mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user)); // deletePasswordlessVerificationToken mockPrisma.verificationToken.delete.mockResolvedValueOnce(passwordlessData); + // usersService.updateUserLastLoggedOn + mockUser.updateUserLastLoggedOn.mockResolvedValue(E.right(true)); const result = await authService.verifyMagicLinkTokens(magicLinkVerify); expect(result).toEqualRight({ @@ -239,7 +244,7 @@ describe('verifyMagicLinkTokens', () => { // generateAuthTokens mockJWT.sign.mockReturnValue(user.refreshToken); // UpdateUserRefreshToken - mockUser.UpdateUserRefreshToken.mockResolvedValueOnce( + mockUser.updateUserRefreshToken.mockResolvedValueOnce( E.left(USER_NOT_FOUND), ); @@ -264,7 +269,7 @@ describe('verifyMagicLinkTokens', () => { // generateAuthTokens mockJWT.sign.mockReturnValue(user.refreshToken); // UpdateUserRefreshToken - mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user)); + mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user)); // deletePasswordlessVerificationToken mockPrisma.verificationToken.delete.mockRejectedValueOnce('RecordNotFound'); @@ -280,7 +285,7 @@ describe('generateAuthTokens', () => { test('Should successfully generate tokens with valid inputs', async () => { mockJWT.sign.mockReturnValue(user.refreshToken); // UpdateUserRefreshToken - mockUser.UpdateUserRefreshToken.mockResolvedValueOnce(E.right(user)); + mockUser.updateUserRefreshToken.mockResolvedValueOnce(E.right(user)); const result = await authService.generateAuthTokens(user.uid); expect(result).toEqualRight({ @@ -292,7 +297,7 @@ describe('generateAuthTokens', () => { test('Should throw USER_NOT_FOUND when updating refresh tokens fails', async () => { mockJWT.sign.mockReturnValue(user.refreshToken); // UpdateUserRefreshToken - mockUser.UpdateUserRefreshToken.mockResolvedValueOnce( + mockUser.updateUserRefreshToken.mockResolvedValueOnce( E.left(USER_NOT_FOUND), ); @@ -319,7 +324,7 @@ describe('refreshAuthTokens', () => { // generateAuthTokens mockJWT.sign.mockReturnValue(user.refreshToken); // UpdateUserRefreshToken - mockUser.UpdateUserRefreshToken.mockResolvedValueOnce( + mockUser.updateUserRefreshToken.mockResolvedValueOnce( E.left(USER_NOT_FOUND), ); @@ -348,7 +353,7 @@ describe('refreshAuthTokens', () => { // generateAuthTokens mockJWT.sign.mockReturnValue('sdhjcbjsdhcbshjdcb'); // UpdateUserRefreshToken - mockUser.UpdateUserRefreshToken.mockResolvedValueOnce( + mockUser.updateUserRefreshToken.mockResolvedValueOnce( E.right({ ...user, refreshToken: 'sdhjcbjsdhcbshjdcb', diff --git a/packages/hoppscotch-backend/src/auth/auth.service.ts b/packages/hoppscotch-backend/src/auth/auth.service.ts index 9f5db2ec7..c04bdb59f 100644 --- a/packages/hoppscotch-backend/src/auth/auth.service.ts +++ b/packages/hoppscotch-backend/src/auth/auth.service.ts @@ -112,7 +112,7 @@ export class AuthService { const refreshTokenHash = await argon2.hash(refreshToken); - const updatedUser = await this.usersService.UpdateUserRefreshToken( + const updatedUser = await this.usersService.updateUserRefreshToken( refreshTokenHash, userUid, ); @@ -320,6 +320,8 @@ export class AuthService { statusCode: HttpStatus.NOT_FOUND, }); + this.usersService.updateUserLastLoggedOn(passwordlessTokens.value.userUid); + return E.right(tokens.right); } diff --git a/packages/hoppscotch-backend/src/interceptors/user-last-login.interceptor.ts b/packages/hoppscotch-backend/src/interceptors/user-last-login.interceptor.ts new file mode 100644 index 000000000..ae204a1dc --- /dev/null +++ b/packages/hoppscotch-backend/src/interceptors/user-last-login.interceptor.ts @@ -0,0 +1,26 @@ +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; +import { Observable } from 'rxjs'; +import { tap } from 'rxjs/operators'; +import { AuthUser } from 'src/types/AuthUser'; +import { UserService } from 'src/user/user.service'; + +@Injectable() +export class UserLastLoginInterceptor implements NestInterceptor { + constructor(private userService: UserService) {} + + intercept(context: ExecutionContext, next: CallHandler): Observable { + const user: AuthUser = context.switchToHttp().getRequest().user; + + const now = Date.now(); + return next.handle().pipe( + tap(() => { + this.userService.updateUserLastLoggedOn(user.uid); + }), + ); + } +} diff --git a/packages/hoppscotch-backend/src/shortcode/shortcode.service.spec.ts b/packages/hoppscotch-backend/src/shortcode/shortcode.service.spec.ts index 7614fd0a4..7e23cf031 100644 --- a/packages/hoppscotch-backend/src/shortcode/shortcode.service.spec.ts +++ b/packages/hoppscotch-backend/src/shortcode/shortcode.service.spec.ts @@ -48,6 +48,7 @@ const user: AuthUser = { photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', isAdmin: false, refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + lastLoggedOn: createdOn, createdOn: createdOn, currentGQLSession: {}, currentRESTSession: {}, diff --git a/packages/hoppscotch-backend/src/team-collection/team-collection.service.spec.ts b/packages/hoppscotch-backend/src/team-collection/team-collection.service.spec.ts index 041e3858c..d7bea4036 100644 --- a/packages/hoppscotch-backend/src/team-collection/team-collection.service.spec.ts +++ b/packages/hoppscotch-backend/src/team-collection/team-collection.service.spec.ts @@ -39,6 +39,7 @@ const user: AuthUser = { photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', isAdmin: false, refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + lastLoggedOn: currentTime, createdOn: currentTime, currentGQLSession: {}, currentRESTSession: {}, diff --git a/packages/hoppscotch-backend/src/user-collection/user-collection.service.spec.ts b/packages/hoppscotch-backend/src/user-collection/user-collection.service.spec.ts index ceb8f3b45..9234c05e6 100644 --- a/packages/hoppscotch-backend/src/user-collection/user-collection.service.spec.ts +++ b/packages/hoppscotch-backend/src/user-collection/user-collection.service.spec.ts @@ -38,6 +38,7 @@ const user: AuthUser = { photoURL: 'https://en.wikipedia.org/wiki/Dwight_Schrute', isAdmin: false, refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + lastLoggedOn: currentTime, createdOn: currentTime, currentGQLSession: {}, currentRESTSession: {}, diff --git a/packages/hoppscotch-backend/src/user-request/user-request.service.spec.ts b/packages/hoppscotch-backend/src/user-request/user-request.service.spec.ts index 2ff0db6fa..edd128d6c 100644 --- a/packages/hoppscotch-backend/src/user-request/user-request.service.spec.ts +++ b/packages/hoppscotch-backend/src/user-request/user-request.service.spec.ts @@ -41,6 +41,7 @@ const user: AuthUser = { photoURL: 'https://example.com/photo.png', isAdmin: false, refreshToken: null, + lastLoggedOn: new Date(), createdOn: new Date(), currentGQLSession: null, currentRESTSession: null, diff --git a/packages/hoppscotch-backend/src/user-settings/user-settings.service.spec.ts b/packages/hoppscotch-backend/src/user-settings/user-settings.service.spec.ts index 547489407..814388062 100644 --- a/packages/hoppscotch-backend/src/user-settings/user-settings.service.spec.ts +++ b/packages/hoppscotch-backend/src/user-settings/user-settings.service.spec.ts @@ -27,6 +27,7 @@ const user: AuthUser = { refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', currentGQLSession: {}, currentRESTSession: {}, + lastLoggedOn: currentTime, createdOn: currentTime, }; diff --git a/packages/hoppscotch-backend/src/user/user.model.ts b/packages/hoppscotch-backend/src/user/user.model.ts index 6889e93db..df3c33db7 100644 --- a/packages/hoppscotch-backend/src/user/user.model.ts +++ b/packages/hoppscotch-backend/src/user/user.model.ts @@ -30,6 +30,12 @@ export class User { }) isAdmin: boolean; + @Field({ + nullable: true, + description: 'Date when the user last logged in', + }) + lastLoggedOn: Date; + @Field({ description: 'Date when the user account was created', }) diff --git a/packages/hoppscotch-backend/src/user/user.service.spec.ts b/packages/hoppscotch-backend/src/user/user.service.spec.ts index b5093831c..c27a3acac 100644 --- a/packages/hoppscotch-backend/src/user/user.service.spec.ts +++ b/packages/hoppscotch-backend/src/user/user.service.spec.ts @@ -42,6 +42,7 @@ const user: AuthUser = { currentRESTSession: {}, currentGQLSession: {}, refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + lastLoggedOn: currentTime, createdOn: currentTime, }; @@ -54,6 +55,7 @@ const adminUser: AuthUser = { currentRESTSession: {}, currentGQLSession: {}, refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + lastLoggedOn: currentTime, createdOn: currentTime, }; @@ -67,6 +69,7 @@ const users: AuthUser[] = [ currentRESTSession: {}, currentGQLSession: {}, refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + lastLoggedOn: currentTime, createdOn: currentTime, }, { @@ -78,6 +81,7 @@ const users: AuthUser[] = [ currentRESTSession: {}, currentGQLSession: {}, refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + lastLoggedOn: currentTime, createdOn: currentTime, }, { @@ -89,6 +93,7 @@ const users: AuthUser[] = [ currentRESTSession: {}, currentGQLSession: {}, refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + lastLoggedOn: currentTime, createdOn: currentTime, }, ]; @@ -103,6 +108,7 @@ const adminUsers: AuthUser[] = [ currentRESTSession: {}, currentGQLSession: {}, refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + lastLoggedOn: currentTime, createdOn: currentTime, }, { @@ -114,6 +120,7 @@ const adminUsers: AuthUser[] = [ currentRESTSession: {}, currentGQLSession: {}, refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + lastLoggedOn: currentTime, createdOn: currentTime, }, { @@ -125,6 +132,7 @@ const adminUsers: AuthUser[] = [ currentRESTSession: {}, currentGQLSession: {}, refreshToken: 'hbfvdkhjbvkdvdfjvbnkhjb', + lastLoggedOn: currentTime, createdOn: currentTime, }, ]; @@ -495,6 +503,26 @@ describe('UserService', () => { }); }); + describe('updateUserLastLoggedOn', () => { + test('should resolve right and update user last logged on', async () => { + const currentTime = new Date(); + mockPrisma.user.update.mockResolvedValueOnce({ + ...user, + lastLoggedOn: currentTime, + }); + + const result = await userService.updateUserLastLoggedOn(user.uid); + expect(result).toEqualRight(true); + }); + + test('should resolve left and error when invalid user uid is passed', async () => { + mockPrisma.user.update.mockRejectedValueOnce('NotFoundError'); + + const result = await userService.updateUserLastLoggedOn('invalidUserUid'); + expect(result).toEqualLeft(USER_NOT_FOUND); + }); + }); + describe('fetchAllUsers', () => { test('should resolve right and return 20 users when cursor is null', async () => { mockPrisma.user.findMany.mockResolvedValueOnce(users); diff --git a/packages/hoppscotch-backend/src/user/user.service.ts b/packages/hoppscotch-backend/src/user/user.service.ts index 26018894b..38664763b 100644 --- a/packages/hoppscotch-backend/src/user/user.service.ts +++ b/packages/hoppscotch-backend/src/user/user.service.ts @@ -114,7 +114,7 @@ export class UserService { * @param userUid User uid * @returns Either of User with updated refreshToken */ - async UpdateUserRefreshToken(refreshTokenHash: string, userUid: string) { + async updateUserRefreshToken(refreshTokenHash: string, userUid: string) { try { const user = await this.prisma.user.update({ where: { @@ -174,6 +174,7 @@ export class UserService { displayName: userDisplayName, email: profile.emails[0].value, photoURL: userPhotoURL, + lastLoggedOn: new Date(), providerAccounts: { create: { provider: profile.provider, @@ -221,7 +222,7 @@ export class UserService { } /** - * Update User displayName and photoURL + * Update User displayName and photoURL when logged in via a SSO provider * * @param user User object * @param profile Data received from SSO provider on the users account @@ -236,6 +237,7 @@ export class UserService { data: { displayName: !profile.displayName ? null : profile.displayName, photoURL: !profile.photos ? null : profile.photos[0].value, + lastLoggedOn: new Date(), }, }); return E.right(updatedUser); @@ -289,7 +291,7 @@ export class UserService { } /** - * Update a user's data + * Update a user's displayName * @param userUID User UID * @param displayName User's displayName * @returns a Either of User or error @@ -316,6 +318,22 @@ export class UserService { } } + /** + * Update user's lastLoggedOn timestamp + * @param userUID User UID + */ + async updateUserLastLoggedOn(userUid: string) { + try { + await this.prisma.user.update({ + where: { uid: userUid }, + data: { lastLoggedOn: new Date() }, + }); + return E.right(true); + } catch (e) { + return E.left(USER_NOT_FOUND); + } + } + /** * Validate and parse currentRESTSession and currentGQLSession * @param sessionData string of the session