Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@
"node": ">=22.0.0"
},
"overrides": {
"lodash-es": "^4.18.1"
"lodash": "^4.18.1",
"lodash-es": "^4.18.1",
"brace-expansion": "^2.0.3"
}
}
7 changes: 7 additions & 0 deletions src/api/routers/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { z } from 'zod';
import {
createUser,
deleteUser,
deleteUserSessions,
getUserById,
listOrgUsers,
updateUser,
Expand Down Expand Up @@ -122,6 +123,12 @@ export const usersRouter = router({
}

await updateUser(input.id, updates);

// Invalidate all sessions for the target user when their password changes.
// This prevents stale sessions from remaining valid after a password reset.
if (updates.passwordHash !== undefined) {
await deleteUserSessions(input.id);
}
}),

delete: adminProcedure.input(z.object({ id: z.string() })).mutation(async ({ ctx, input }) => {
Expand Down
15 changes: 15 additions & 0 deletions src/db/repositories/usersRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,21 @@ export async function deleteExpiredSessions(): Promise<void> {
await db.delete(sessions).where(lt(sessions.expiresAt, new Date()));
}

/**
* Delete all sessions for a given user. Optionally exclude a specific token
* (e.g. to preserve the caller's own session when they change their own password).
*/
export async function deleteUserSessions(userId: string, excludeToken?: string): Promise<void> {
const db = getDb();
if (excludeToken !== undefined) {
await db
.delete(sessions)
.where(and(eq(sessions.userId, userId), ne(sessions.token, excludeToken)));
} else {
await db.delete(sessions).where(eq(sessions.userId, userId));
}
}

// ============================================================================
// CRUD for users (org-scoped)
// ============================================================================
Expand Down
34 changes: 34 additions & 0 deletions tests/unit/api/routers/users.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ const {
mockUpdateUser,
mockDeleteUser,
mockGetUserById,
mockDeleteUserSessions,
mockBcryptHash,
} = vi.hoisted(() => ({
mockListOrgUsers: vi.fn(),
mockCreateUser: vi.fn(),
mockUpdateUser: vi.fn(),
mockDeleteUser: vi.fn(),
mockGetUserById: vi.fn(),
mockDeleteUserSessions: vi.fn(),
mockBcryptHash: vi.fn(),
}));

Expand All @@ -24,6 +26,7 @@ vi.mock('../../../../src/db/repositories/usersRepository.js', () => ({
updateUser: mockUpdateUser,
deleteUser: mockDeleteUser,
getUserById: mockGetUserById,
deleteUserSessions: mockDeleteUserSessions,
}));

vi.mock('bcrypt', () => ({
Expand All @@ -43,6 +46,7 @@ const mockMember = createMockUser({ id: 'member-1', role: 'member' });
describe('usersRouter', () => {
beforeEach(() => {
mockBcryptHash.mockResolvedValue('hashed-password');
mockDeleteUserSessions.mockResolvedValue(undefined);
});

describe('list', () => {
Expand Down Expand Up @@ -406,6 +410,36 @@ describe('usersRouter', () => {
code: 'FORBIDDEN',
});
});

it('invalidates all sessions when password is changed', async () => {
mockGetUserById.mockResolvedValue({ id: 'user-2', orgId: 'org-1', role: 'member' });
mockUpdateUser.mockResolvedValue(undefined);
const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId });

await caller.update({ id: 'user-2', password: 'newpassword12' });

expect(mockDeleteUserSessions).toHaveBeenCalledWith('user-2');
});

it('does not invalidate sessions when password is not changed', async () => {
mockGetUserById.mockResolvedValue({ id: 'user-2', orgId: 'org-1', role: 'member' });
mockUpdateUser.mockResolvedValue(undefined);
const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId });

await caller.update({ id: 'user-2', name: 'New Name' });

expect(mockDeleteUserSessions).not.toHaveBeenCalled();
});

it('does not invalidate sessions when only role/email are changed', async () => {
mockGetUserById.mockResolvedValue({ id: 'user-2', orgId: 'org-1', role: 'member' });
mockUpdateUser.mockResolvedValue(undefined);
const caller = createCaller({ user: mockAdminUser, effectiveOrgId: mockAdminUser.orgId });

await caller.update({ id: 'user-2', email: 'new@example.com' });

expect(mockDeleteUserSessions).not.toHaveBeenCalled();
});
});

describe('delete', () => {
Expand Down
31 changes: 31 additions & 0 deletions tests/unit/db/repositories/usersRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
deleteExpiredSessions,
deleteSession,
deleteUser,
deleteUserSessions,
getSessionByToken,
getUserByEmail,
getUserById,
Expand Down Expand Up @@ -288,4 +289,34 @@ describe('usersRepository', () => {
expect(mockDb.db.delete).toHaveBeenCalledTimes(1);
});
});

describe('deleteUserSessions', () => {
it('deletes all sessions for a user', async () => {
mockDb.chain.where.mockResolvedValueOnce(undefined);

await deleteUserSessions('user-1');

expect(mockDb.db.delete).toHaveBeenCalledTimes(1);
});

it('deletes all sessions when excludeToken is not provided', async () => {
mockDb.chain.where.mockResolvedValueOnce(undefined);

await deleteUserSessions('user-1');

// Without excludeToken the where clause uses a single eq condition (no and/ne)
expect(mockDb.db.delete).toHaveBeenCalledTimes(1);
expect(mockDb.chain.where).toHaveBeenCalledTimes(1);
});

it('excludes a specific token when provided', async () => {
mockDb.chain.where.mockResolvedValueOnce(undefined);

await deleteUserSessions('user-1', 'keep-this-token');

// With excludeToken the where clause uses an and(eq, ne) condition
expect(mockDb.db.delete).toHaveBeenCalledTimes(1);
expect(mockDb.chain.where).toHaveBeenCalledTimes(1);
});
});
});
Loading