Skip to content

Commit

Permalink
Count active browser sessions per user (#8736)
Browse files Browse the repository at this point in the history
Show info on how many devices a user is logged in to an admin.
  • Loading branch information
Tymek authored Nov 13, 2024
1 parent bcbbd5c commit 60fb647
Show file tree
Hide file tree
Showing 9 changed files with 80 additions and 0 deletions.
12 changes: 12 additions & 0 deletions src/lib/db/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,18 @@ export default class SessionStore implements ISessionStore {
expired: row.expired,
};
}

async getSessionsCount(): Promise<{ userId: number; count: number }[]> {
const rows = await this.db(TABLE)
.select(this.db.raw("sess->'user'->>'id' AS user_id"))
.count('* as count')
.groupBy('user_id');

return rows.map((row) => ({
userId: Number(row.user_id),
count: Number(row.count),
}));
}
}

module.exports = SessionStore;
6 changes: 6 additions & 0 deletions src/lib/openapi/spec/user-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,12 @@ export const userSchema = {
nullable: true,
example: '01HTMEXAMPLESCIMID7SWWGHN6',
},
activeSessions: {
description: 'Count of active browser sessions for this user',
type: 'integer',
nullable: true,
example: 2,
},
},
components: {},
} as const;
Expand Down
8 changes: 8 additions & 0 deletions src/lib/services/session-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ export default class SessionService {
}: Pick<ISession, 'sid' | 'sess'>): Promise<ISession> {
return this.sessionStore.insertSession({ sid, sess });
}

async getSessionsCount() {
return Object.fromEntries(
(await this.sessionStore.getSessionsCount()).map(
({ userId, count }) => [userId, count],
),
);
}
}

module.exports = SessionService;
9 changes: 9 additions & 0 deletions src/lib/services/user-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,15 @@ class UserService {
const roleId = rootRole ? rootRole.roleId : defaultRole.id;
return { ...u, rootRole: roleId };
});
if (this.flagResolver.isEnabled('showUserDeviceCount')) {
const sessionCounts = await this.sessionService.getSessionsCount();
const usersWithSessionCounts = usersWithRootRole.map((u) => ({
...u,
activeSessions: sessionCounts[u.id] || 0,
}));
return usersWithSessionCounts;
}

return usersWithRootRole;
}

Expand Down
5 changes: 5 additions & 0 deletions src/lib/types/experimental.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ export type IFlagKey =
| 'enterprise-payg'
| 'simplifyProjectOverview'
| 'flagOverviewRedesign'
| 'showUserDeviceCount'
| 'deleteStaleUserSessions';

export type IFlags = Partial<{ [key in IFlagKey]: boolean | Variant }>;
Expand Down Expand Up @@ -283,6 +284,10 @@ const flags: IFlags = {
process.env.UNLEASH_EXPERIMENTAL_FLAG_OVERVIEW_REDESIGN,
false,
),
showUserDeviceCount: parseEnvVarBoolean(
process.env.UNLEASH_EXPERIMENTAL_SHOW_USER_DEVICE_COUNT,
false,
),
};

export const defaultExperimentalOptions: IExperimentalOptions = {
Expand Down
1 change: 1 addition & 0 deletions src/lib/types/stores/session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ export interface ISessionStore extends Store<ISession, string> {
getSessionsForUser(userId: number): Promise<ISession[]>;
deleteSessionsForUser(userId: number): Promise<void>;
insertSession(data: Omit<ISession, 'createdAt'>): Promise<ISession>;
getSessionsCount(): Promise<{ userId: number; count: number }[]>;
}
1 change: 1 addition & 0 deletions src/server-dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ process.nextTick(async () => {
webhookDomainLogging: true,
releasePlans: false,
simplifyProjectOverview: true,
showUserDeviceCount: true,
flagOverviewRedesign: true,
},
},
Expand Down
34 changes: 34 additions & 0 deletions src/test/e2e/api/admin/user-admin.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ beforeAll(async () => {
experimental: {
flags: {
strictSchemaValidation: true,
showUserDeviceCount: true,
},
},
});
Expand Down Expand Up @@ -428,3 +429,36 @@ test('creates user with email md5 hash', async () => {

expect(user.email_hash).toBe(expectedHash);
});

test('should return number of sessions per user', async () => {
const user = await userStore.insert({ email: '[email protected]' });
await sessionStore.insertSession({
sid: '1',
sess: { user: { id: user.id } },
});
await sessionStore.insertSession({
sid: '2',
sess: { user: { id: user.id } },
});

const user2 = await userStore.insert({ email: '[email protected]' });
await sessionStore.insertSession({
sid: '3',
sess: { user: { id: user2.id } },
});

const response = await app.request.get(`/api/admin/user-admin`).expect(200);

expect(response.body).toMatchObject({
users: expect.arrayContaining([
expect.objectContaining({
email: '[email protected]',
activeSessions: 2,
}),
expect.objectContaining({
email: '[email protected]',
activeSessions: 1,
}),
]),
});
});
4 changes: 4 additions & 0 deletions src/test/fixtures/fake-session-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,8 @@ export default class FakeSessionStore implements ISessionStore {
this.sessions.push(session);
return session;
}

async getSessionsCount(): Promise<{ userId: number; count: number }[]> {
return [];
}
}

0 comments on commit 60fb647

Please sign in to comment.