Skip to content

Commit ee84eee

Browse files
authored
PLU-341: User last logged in tracking (#801)
## Problem There is no last login tracking for users. Closes https://linear.app/ogp/issue/PLU-341/user-last-logged-in-tracking ## Solution - Create a new `last_login_at` column in the `users` table - Update this column when user logs in successfully via OTP or sgID **Improvements**: - Added test cases for `getOrCreateUser` function ## Before & After Screenshots **BEFORE**: ![Screenshot 2024-11-20 at 3 45 29 PM](https://github.com/user-attachments/assets/5a84c7b0-ae99-4485-877f-7cad43f77a71) **AFTER**: ![Screenshot 2024-11-20 at 3 45 52 PM](https://github.com/user-attachments/assets/34510761-a22e-4d8c-884b-4ad9beb8092b) * OTP Login * Before login ![Screenshot 2024-11-20 at 3 46 28 PM](https://github.com/user-attachments/assets/ba8bdb7f-c668-4751-92b7-50295cbfd02a) * After login https://github.com/user-attachments/assets/c78bd337-3b03-4295-a0ea-687aaa9723c2 ![Screenshot 2024-11-20 at 3 54 50 PM](https://github.com/user-attachments/assets/feccac00-ea1d-43cf-b450-ed5d96008964) * Singpass Login * Before login ![Screenshot 2024-11-20 at 3 55 29 PM](https://github.com/user-attachments/assets/8dee96ff-13fa-44cc-b583-9b17efe028e0) * After login https://github.com/user-attachments/assets/dc7dfe5a-8298-4484-a39a-cbcc44f0ae6d (Singpass was slow in sending the profile details) ![Screenshot 2024-11-20 at 4 01 30 PM](https://github.com/user-attachments/assets/ca8d7d70-16b0-47c0-b29f-75c4271da125) ## Tests - [x] Login via OTP and check that `last_login_at` is updated correctly - [x] Login via Singpass / sgID and check that `last_login_at` is updated correctly - [x] Key in wrong OTP multiple times and check that `last_login_at` is not updated ## Deploy Notes Note, need to run DB migration to add `last_login_at` column before deploying. **New scripts**: - `packages/backend/src/db/migrations/20241119090013_add_user_last_login_at.ts` : DB migration script to create `last_login_at` column in `users` table
1 parent 63406ca commit ee84eee

File tree

9 files changed

+180
-3
lines changed

9 files changed

+180
-3
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { Knex } from 'knex'
2+
3+
export async function up(knex: Knex): Promise<void> {
4+
return knex.schema.table('users', (table) => {
5+
table.timestamp('last_login_at').nullable()
6+
})
7+
}
8+
9+
export async function down(knex: Knex): Promise<void> {
10+
return knex.schema.table('users', (table) => {
11+
table.dropColumn('last_login_at')
12+
})
13+
}

packages/backend/src/graphql/__tests__/mutations/login-with-selected-sgid.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ import type Context from '@/types/express/context'
88
const mocks = vi.hoisted(() => ({
99
setAuthCookie: vi.fn(),
1010
getOrCreateUser: vi.fn(),
11+
updateLastLogin: vi.fn(),
1112
logError: vi.fn(),
1213
verifyJwt: vi.fn(),
1314
}))
1415

1516
vi.mock('@/helpers/auth', () => ({
1617
setAuthCookie: mocks.setAuthCookie,
1718
getOrCreateUser: mocks.getOrCreateUser,
19+
updateLastLogin: mocks.updateLastLogin,
1820
}))
1921

2022
vi.mock('jsonwebtoken', () => ({
@@ -79,6 +81,7 @@ describe('Login with selected SGID', () => {
7981
expect(mocks.getOrCreateUser).toHaveBeenCalledWith(
8082
8183
)
84+
expect(mocks.updateLastLogin).toHaveBeenCalledWith('abc-def')
8285
expect(mocks.setAuthCookie).toHaveBeenCalledWith(expect.anything(), {
8386
userId: 'abc-def',
8487
})
@@ -118,6 +121,7 @@ describe('Login with selected SGID', () => {
118121
).rejects.toThrowError('Invalid work email')
119122

120123
expect(mocks.getOrCreateUser).not.toBeCalled()
124+
expect(mocks.updateLastLogin).not.toBeCalled()
121125
expect(mocks.setAuthCookie).not.toBeCalled()
122126
})
123127

packages/backend/src/graphql/__tests__/mutations/login-with-sgid.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
99
sgidUserInfo: vi.fn(),
1010
setAuthCookie: vi.fn(),
1111
getOrCreateUser: vi.fn(),
12+
updateLastLogin: vi.fn(),
1213
isWhitelistedEmail: vi.fn(),
1314
logError: vi.fn(),
1415
setCookie: vi.fn(),
@@ -44,6 +45,7 @@ vi.mock('@opengovsg/sgid-client', () => ({
4445
vi.mock('@/helpers/auth', () => ({
4546
setAuthCookie: mocks.setAuthCookie,
4647
getOrCreateUser: mocks.getOrCreateUser,
48+
updateLastLogin: mocks.updateLastLogin,
4749
}))
4850

4951
vi.mock('@/models/login-whitelist-entry', () => ({
@@ -89,6 +91,7 @@ describe('Login with SGID', () => {
8991
expect(mocks.getOrCreateUser).toHaveBeenCalledWith(
9092
9193
)
94+
expect(mocks.updateLastLogin).toHaveBeenCalledWith('abc-def')
9295
expect(mocks.setAuthCookie).toHaveBeenCalledWith(expect.anything(), {
9396
userId: 'abc-def',
9497
})
@@ -139,6 +142,7 @@ describe('Login with SGID', () => {
139142
const result = await loginWithSgid(null, STUB_PARAMS, STUB_CONTEXT)
140143

141144
expect(mocks.getOrCreateUser).not.toBeCalled()
145+
expect(mocks.updateLastLogin).not.toBeCalled()
142146
expect(mocks.setAuthCookie).not.toBeCalled()
143147
expect(result.publicOfficerEmployments).toEqual([])
144148
})
@@ -176,6 +180,7 @@ describe('Login with SGID', () => {
176180
const result = await loginWithSgid(null, STUB_PARAMS, STUB_CONTEXT)
177181

178182
expect(mocks.getOrCreateUser).toHaveBeenCalledWith('[email protected]')
183+
expect(mocks.updateLastLogin).toHaveBeenCalledWith('abc-def')
179184
expect(mocks.setAuthCookie).toHaveBeenCalledWith(expect.anything(), {
180185
userId: 'abc-def',
181186
})
@@ -254,6 +259,7 @@ describe('Login with SGID', () => {
254259
},
255260
)
256261
expect(mocks.getOrCreateUser).not.toBeCalled()
262+
expect(mocks.updateLastLogin).not.toBeCalled()
257263
expect(mocks.setAuthCookie).not.toBeCalled()
258264
})
259265

@@ -270,6 +276,7 @@ describe('Login with SGID', () => {
270276
event: 'sgid-login-failed-user-info',
271277
})
272278
expect(mocks.getOrCreateUser).not.toBeCalled()
279+
expect(mocks.updateLastLogin).not.toBeCalled()
273280
expect(mocks.setAuthCookie).not.toBeCalled()
274281
})
275282

packages/backend/src/graphql/mutations/login-with-selected-sgid.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { type Request } from 'express'
22
import { verify as verifyJwt } from 'jsonwebtoken'
33

44
import appConfig from '@/config/app'
5-
import { getOrCreateUser, setAuthCookie } from '@/helpers/auth'
5+
import { getOrCreateUser, setAuthCookie, updateLastLogin } from '@/helpers/auth'
66
import logger from '@/helpers/logger'
77
import {
88
type PublicOfficerEmployment,
@@ -44,6 +44,7 @@ const loginWithSelectedSgid: MutationResolvers['loginWithSelectedSgid'] =
4444
}
4545

4646
const user = await getOrCreateUser(workEmail)
47+
await updateLastLogin(user.id)
4748
setAuthCookie(context.res, { userId: user.id })
4849

4950
return {

packages/backend/src/graphql/mutations/login-with-sgid.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { type Response } from 'express'
33
import { sign as signJwt } from 'jsonwebtoken'
44

55
import appConfig from '@/config/app'
6-
import { getOrCreateUser, setAuthCookie } from '@/helpers/auth'
6+
import { getOrCreateUser, setAuthCookie, updateLastLogin } from '@/helpers/auth'
77
import { validateAndParseEmail } from '@/helpers/email-validator'
88
import logger from '@/helpers/logger'
99
import {
@@ -129,6 +129,7 @@ const loginWithSgid: MutationResolvers['loginWithSgid'] = async (
129129
// Log user in directly if there is only 1 employment.
130130
if (publicOfficerEmployments.length === 1) {
131131
const user = await getOrCreateUser(publicOfficerEmployments[0].workEmail)
132+
await updateLastLogin(user.id)
132133
setAuthCookie(context.res, { userId: user.id })
133134

134135
return {

packages/backend/src/graphql/mutations/verify-otp.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ const verifyOtp: MutationResolvers['verifyOtp'] = async (
5656
otpHash: null,
5757
otpAttempts: 0,
5858
otpSentAt: null,
59+
lastLoginAt: new Date(),
5960
})
6061

6162
// set auth jwt as cookie

packages/backend/src/helpers/__tests__/auth.test.ts

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,35 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
33

44
import appConfig from '@/config/app'
55

6-
import { getAdminTokenUser, parseAdminToken } from '../auth'
6+
import {
7+
getAdminTokenUser,
8+
getOrCreateUser,
9+
parseAdminToken,
10+
updateLastLogin,
11+
} from '../auth'
12+
13+
const mockPatchWhere = vi.fn()
714

815
const mocks = vi.hoisted(() => ({
916
whereUser: vi.fn(() => ({
1017
first: vi.fn(() => ({
1118
throwIfNotFound: vi.fn(() => ({ id: 'test-user-id' })),
1219
})),
1320
})),
21+
findOne: vi.fn(),
22+
insertAndFetch: vi.fn(),
23+
patch: vi.fn(() => ({
24+
where: mockPatchWhere,
25+
})),
1426
}))
1527

1628
vi.mock('@/models/user', () => ({
1729
default: {
1830
query: vi.fn(() => ({
1931
where: mocks.whereUser,
32+
findOne: mocks.findOne,
33+
insertAndFetch: mocks.insertAndFetch,
34+
patch: mocks.patch,
2035
})),
2136
},
2237
}))
@@ -69,4 +84,122 @@ describe('Auth helpers', () => {
6984
expect(result.id).toEqual('test-user-id')
7085
})
7186
})
87+
88+
describe('getOrCreateUser', () => {
89+
afterEach(() => {
90+
vi.clearAllMocks() // Clear mocks after each test
91+
})
92+
93+
it('should return an existing user if found', async () => {
94+
const email = '[email protected]'
95+
const existingUser = { id: 'test-user-id', email }
96+
97+
mocks.findOne.mockResolvedValueOnce(existingUser)
98+
99+
const result = await getOrCreateUser(email)
100+
101+
expect(mocks.findOne).toHaveBeenCalledOnce()
102+
expect(mocks.findOne).toHaveBeenCalledWith({ email: email.toLowerCase() })
103+
104+
expect(mocks.insertAndFetch).not.toHaveBeenCalled() // Ensure no new user was created
105+
106+
expect(result).toEqual(existingUser)
107+
})
108+
109+
it('should create a new user if none exists', async () => {
110+
const email = '[email protected]'
111+
const newUser = { id: 'new-user-id', email }
112+
113+
mocks.findOne.mockResolvedValueOnce(null) // Simulate no user found
114+
mocks.insertAndFetch.mockResolvedValueOnce(newUser) // Simulate new user creation
115+
116+
const user = await getOrCreateUser(email)
117+
118+
expect(mocks.findOne).toHaveBeenCalledOnce()
119+
expect(mocks.findOne).toHaveBeenCalledWith({ email: email.toLowerCase() })
120+
121+
expect(mocks.insertAndFetch).toHaveBeenCalledOnce()
122+
expect(mocks.insertAndFetch).toHaveBeenCalledWith({
123+
email: email.toLowerCase(),
124+
})
125+
126+
expect(user).toEqual(newUser)
127+
})
128+
129+
it('should trim and lowercase the email before querying', async () => {
130+
const email = ' [email protected] '
131+
const formattedEmail = '[email protected]'
132+
const user = { id: 'test-user-id', email: formattedEmail }
133+
134+
mocks.findOne.mockResolvedValueOnce(user)
135+
136+
const result = await getOrCreateUser(email)
137+
138+
expect(mocks.findOne).toHaveBeenCalledOnce()
139+
expect(mocks.findOne).toHaveBeenCalledWith({ email: formattedEmail })
140+
141+
expect(result).toEqual(user)
142+
})
143+
144+
it('should handle errors from User.query().findOne', async () => {
145+
const email = '[email protected]'
146+
147+
mocks.findOne.mockRejectedValueOnce(new Error('Database error'))
148+
149+
await expect(getOrCreateUser(email)).rejects.toThrowError(
150+
'Database error',
151+
)
152+
153+
expect(mocks.findOne).toHaveBeenCalledOnce()
154+
expect(mocks.findOne).toHaveBeenCalledWith({ email: email.toLowerCase() })
155+
156+
expect(mocks.insertAndFetch).not.toHaveBeenCalled() // Ensure no insert attempt was made
157+
})
158+
159+
it('should handle errors from User.query().insertAndFetch', async () => {
160+
const email = '[email protected]'
161+
162+
mocks.findOne.mockResolvedValueOnce(null) // Simulate no user found
163+
mocks.insertAndFetch.mockRejectedValueOnce(new Error('Insert error'))
164+
165+
await expect(getOrCreateUser(email)).rejects.toThrowError('Insert error')
166+
167+
expect(mocks.findOne).toHaveBeenCalledOnce()
168+
expect(mocks.findOne).toHaveBeenCalledWith({ email: email.toLowerCase() })
169+
170+
expect(mocks.insertAndFetch).toHaveBeenCalledOnce()
171+
expect(mocks.insertAndFetch).toHaveBeenCalledWith({
172+
email: email.toLowerCase(),
173+
})
174+
})
175+
})
176+
177+
describe('updateLastLogin', () => {
178+
afterEach(() => {
179+
vi.clearAllMocks() // Clear mocks after each test
180+
})
181+
182+
it('patch with correct id and date', async () => {
183+
const userId = 'test-user-id'
184+
mocks.patch().where.mockResolvedValueOnce(1)
185+
186+
await updateLastLogin(userId)
187+
188+
expect(mocks.patch).toHaveBeenCalledWith({
189+
lastLoginAt: expect.any(Date),
190+
})
191+
expect(mocks.patch().where).toHaveBeenCalledWith({ id: userId })
192+
})
193+
194+
it('throws error with no user id', async () => {
195+
await expect(updateLastLogin('')).rejects.toThrowError('User id required')
196+
})
197+
198+
it('throws error with non-existent user id', async () => {
199+
mocks.patch().where.mockReturnValueOnce(Promise.resolve(0))
200+
await expect(updateLastLogin('non-existent-id')).rejects.toThrowError(
201+
'No user found',
202+
)
203+
})
204+
})
72205
})

packages/backend/src/helpers/auth.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,22 @@ export async function getOrCreateUser(email: string): Promise<User> {
6161
return user
6262
}
6363

64+
export async function updateLastLogin(id: string) {
65+
if (!id) {
66+
throw new Error('User id required!')
67+
}
68+
69+
const updatedRows = await User.query()
70+
.patch({
71+
lastLoginAt: new Date(),
72+
})
73+
.where({ id })
74+
75+
if (!updatedRows) {
76+
throw new Error('No user found')
77+
}
78+
}
79+
6480
// Admin tokens are more sensitive so we set a low max age of 5 min
6581
const ADMIN_TOKEN_MAX_AGE_SEC = 5 * 60
6682

packages/backend/src/models/user.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ class User extends Base {
2525
tables?: TableMetadata[]
2626
sentFlowTransfers?: FlowTransfer[]
2727
receivedFlowTransfers?: FlowTransfer[]
28+
lastLoginAt?: Date
2829

2930
// for typescript support when creating TableCollaborator row in insertGraph
3031
role?: ITableCollabRole

0 commit comments

Comments
 (0)