Skip to content

Commit d604818

Browse files
committed
Feature getodk/central#1104: Added lastLoginAt field
1 parent 39041f5 commit d604818

File tree

12 files changed

+170
-12
lines changed

12 files changed

+170
-12
lines changed

docs/api.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,9 @@ info:
4545
**Added**:
4646
- New endpoint [GET /projects/:id/datasets/:name/entities/creators](/central-api-entity-management/#entities-creators) to retrieve a list of all Actors who have created Entities in a Dataset, sorted by display name.
4747

48+
**Changed**:
49+
- [GET /users](/central-api-accounts-and-users/#listing-users) and [GET /users/:actorId](/central-api-accounts-and-users/#getting-user-details) now include `lastLoginAt` field.
50+
4851
## ODK Central v2025.2
4952

5053
**Added**:
@@ -12183,6 +12186,12 @@ components:
1218312186
type: string
1218412187
description: The email address of the user
1218512188
example:
12189+
lastLoginAt:
12190+
type: string
12191+
format: date-time
12192+
nullable: true
12193+
description: The timestamp of when the user last logged in. Will be null if the user has never logged in.
12194+
example: "2025-01-15T10:30:00.000Z"
1218612195
AppUser:
1218712196
allOf:
1218812197
- $ref: '#/components/schemas/Actor'

lib/http/sessions.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,15 @@ const HTTPS_ENABLED = config.get('default.env.domain').startsWith('https://');
1616
// See: https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#__host-
1717
const SESSION_COOKIE = HTTPS_ENABLED ? '__Host-session' : 'session';
1818

19-
const createUserSession = ({ Audits, Sessions }, headers, user) => Promise.all([
19+
const createUserSession = ({ Audits, Sessions, Users }, headers, user) => Promise.all([
2020
Sessions.create(user.actor),
2121
// Logging here rather than defining Sessions.create.audit, because
2222
// Sessions.create.audit would require auth. Logging here also makes
2323
// it easy to access `headers`.
2424
Audits.log(user.actor, 'user.session.create', user.actor, {
2525
userAgent: headers['user-agent']
26-
})
26+
}),
27+
Users.updateLastLoginAt(user)
2728
])
2829
.then(([ session ]) => (_, response) => {
2930
response.cookie(SESSION_COOKIE, session.token, {

lib/model/frames.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,7 @@ Submission.Attachment = class extends Frame.define(
198198
class User extends Frame.define(
199199
table('users'), aux(Actor),
200200
'actorId', 'password',
201-
'email', readable, writable,
201+
'email', readable, writable, 'lastLoginAt', readable,
202202
species('user')
203203
) {
204204
forV1OnlyCopyEmailToDisplayName() {
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
// Copyright 2025 ODK Central Developers
2+
// See the NOTICE file at the top-level directory of this distribution and at
3+
// https://github.com/getodk/central-backend/blob/master/NOTICE.
4+
// This file is part of ODK Central. It is subject to the license terms in
5+
// the LICENSE file found in the top-level directory of this distribution and at
6+
// https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central,
7+
// including this file, may be copied, modified, propagated, or distributed
8+
// except according to the terms contained in the LICENSE file.
9+
//
10+
const up = (db) =>
11+
db.raw('ALTER TABLE users ADD COLUMN "lastLoginAt" TIMESTAMP(3)');
12+
13+
const down = (db) =>
14+
db.raw('ALTER TABLE users DROP COLUMN "lastLoginAt"');
15+
16+
module.exports = { up, down };
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Copyright 2025 ODK Central Developers
2+
// See the NOTICE file at the top-level directory of this distribution and at
3+
// https://github.com/getodk/central-backend/blob/master/NOTICE.
4+
// This file is part of ODK Central. It is subject to the license terms in
5+
// the LICENSE file found in the top-level directory of this distribution and at
6+
// https://www.apache.org/licenses/LICENSE-2.0. No part of ODK Central,
7+
// including this file, may be copied, modified, propagated, or distributed
8+
// except according to the terms contained in the LICENSE file.
9+
//
10+
const up = (db) =>
11+
db.raw(`
12+
UPDATE users
13+
SET "lastLoginAt" = latest_login."loggedAt"
14+
FROM (
15+
SELECT
16+
"actorId",
17+
MAX("loggedAt") as "loggedAt"
18+
FROM audits
19+
WHERE action = 'user.session.create'
20+
AND "actorId" IS NOT NULL
21+
GROUP BY "actorId"
22+
) AS latest_login
23+
WHERE users."actorId" = latest_login."actorId";
24+
`);
25+
26+
const down = (db) =>
27+
db.raw(`UPDATE users SET "lastLoginAt" = NULL;`);
28+
29+
module.exports = { up, down };

lib/model/query/users.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,9 @@ invalidatePassword.audit = (user) => (log) => log('user.update', user.actor, {
4646
data: { password: null }
4747
});
4848

49+
const updateLastLoginAt = (user) => ({ run }) =>
50+
run(sql`UPDATE users SET "lastLoginAt"=clock_timestamp() WHERE "actorId"=${user.actor.id}`);
51+
4952
const provisionPasswordResetToken = (user) => ({ Actors, Assignments, Sessions }) => {
5053
const expiresAt = new Date();
5154
expiresAt.setDate(expiresAt.getDate() + 1);
@@ -89,6 +92,6 @@ module.exports = {
8992
create, update,
9093
updatePassword, invalidatePassword, provisionPasswordResetToken,
9194
getAll, getByEmail, getByActorId,
92-
emailEverExisted
95+
emailEverExisted, updateLastLoginAt
9396
};
9497

lib/resources/sessions.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ module.exports = (service, endpoint, anonymousEndpoint) => {
3838
(verified) => (verified !== true),
3939
noargs(Problem.user.authenticationFailed)
4040
))
41-
.then(() => createUserSession({ Audits, Sessions }, headers, user)));
41+
.then(() => createUserSession({ Audits, Sessions, Users }, headers, user)));
4242
}));
4343
}
4444

test/assertions.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ should.Assertion.add('User', function() {
9797
this.params = { operator: 'to be a User' };
9898

9999
this.obj.should.be.an.Actor();
100-
Object.keys(this.obj).should.containDeep([ 'email' ]);
100+
Object.keys(this.obj).should.containDeep([ 'email', 'lastLoginAt' ]);
101101
this.obj.email.should.be.a.String();
102102
});
103103

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
const { assertTableContents, describeMigration, rowsExistFor } = require('./utils');
2+
3+
describeMigration('20250910-02-backfill-last-login-at', ({ runMigrationBeingTested }) => {
4+
before(async () => {
5+
// Set up test data: create actors, users, and audit records
6+
await rowsExistFor('actees',
7+
{ id: 'actee1', species: 'user' },
8+
{ id: 'actee2', species: 'user' },
9+
{ id: 'actee3', species: 'user' }
10+
);
11+
12+
await rowsExistFor('actors',
13+
{ id: 1, type: 'user', acteeId: 'actee1', displayName: 'Alice', createdAt: new Date('2025-01-01T10:00:00Z') },
14+
{ id: 2, type: 'user', acteeId: 'actee2', displayName: 'Bob', createdAt: new Date('2025-01-01T11:00:00Z') },
15+
{ id: 3, type: 'user', acteeId: 'actee3', displayName: 'Charlie', createdAt: new Date('2025-01-01T12:00:00Z') }
16+
);
17+
18+
await rowsExistFor('users',
19+
{ actorId: 1, email: '[email protected]', lastLoginAt: null },
20+
{ actorId: 2, email: '[email protected]', lastLoginAt: null },
21+
{ actorId: 3, email: '[email protected]', lastLoginAt: null }
22+
);
23+
24+
// Create audit records - Alice has multiple logins, Bob has one, Charlie has none
25+
await rowsExistFor('audits',
26+
// Alice's login sessions (most recent should be picked)
27+
{ actorId: 1, action: 'user.session.create', acteeId: 'actee1', loggedAt: new Date('2025-01-10T10:00:00Z') },
28+
{ actorId: 1, action: 'user.session.create', acteeId: 'actee1', loggedAt: new Date('2025-01-15T14:30:00Z') },
29+
{ actorId: 1, action: 'user.session.create', acteeId: 'actee1', loggedAt: new Date('2025-01-12T09:15:00Z') },
30+
31+
// Bob's single login session
32+
{ actorId: 2, action: 'user.session.create', acteeId: 'actee2', loggedAt: new Date('2025-01-08T16:45:00Z') },
33+
);
34+
35+
await runMigrationBeingTested();
36+
});
37+
38+
it('should backfill lastLoginAt with most recent login timestamp for users with login history', async () => {
39+
await assertTableContents('users',
40+
// Alice should get her most recent login time (2025-01-15T14:30:00Z)
41+
{ actorId: 1, email: '[email protected]', lastLoginAt: 1736969400000 },
42+
43+
// Bob should get his only login time (2025-01-08T16:45:00Z)
44+
{ actorId: 2, email: '[email protected]', lastLoginAt: 1736372700000 },
45+
46+
// Charlie should remain null (never logged in)
47+
{ actorId: 3, email: '[email protected]', lastLoginAt: null }
48+
);
49+
});
50+
});

test/integration/api/audits.js

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,8 @@ describe('/audits', () => {
6464
audits[0].details.should.eql({ data: {
6565
actorId: david.actor.id,
6666
67-
password: null
67+
password: null,
68+
lastLoginAt: null
6869
} });
6970
audits[0].loggedAt.should.be.a.recentIsoDate();
7071

@@ -127,7 +128,8 @@ describe('/audits', () => {
127128
audits[0].details.should.eql({ data: {
128129
actorId: david.actor.id,
129130
130-
password: null
131+
password: null,
132+
lastLoginAt: null
131133
} });
132134
audits[0].loggedAt.should.be.a.recentIsoDate();
133135

0 commit comments

Comments
 (0)