From 01b41ad5e73a7d887c12526f89b4cbcea04c8c8a Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Thu, 24 Sep 2026 19:29:54 -0400 Subject: [PATCH 1/4] #645 stamp User.lastLoginAt on sign-in Stored on User because sessions are hard-deleted on sign-out, expiry, and deactivation, so nothing derived from Session survives long enough to answer "who has gone quiet". The write moves into the session create `after` hook so a blocked (deactivated) sign-in never stamps it. Co-Authored-By: Claude Sonnet 5 --- lib/auth/config.ts | 22 +++-- lib/auth/session-hooks.ts | 28 +++++++ .../migration.sql | 2 + prisma/schema.prisma | 1 + tests/db/last-sign-in.test.ts | 84 +++++++++++++++++++ 5 files changed, 125 insertions(+), 12 deletions(-) create mode 100644 lib/auth/session-hooks.ts create mode 100644 prisma/migrations/20260924232626_add_user_last_login_at/migration.sql create mode 100644 tests/db/last-sign-in.test.ts diff --git a/lib/auth/config.ts b/lib/auth/config.ts index 64e37238..f02b0441 100644 --- a/lib/auth/config.ts +++ b/lib/auth/config.ts @@ -4,12 +4,14 @@ import { nextCookies } from 'better-auth/next-js'; import { prismaAdapter } from '@better-auth/prisma-adapter'; import { betterAuth } from 'better-auth'; -import { APIError } from 'better-auth/api'; import { emailOTP } from 'better-auth/plugins'; import { buildOtpSignInUrl } from '@/lib/auth/otp-link'; +import { + assertSessionUserActive, + recordSignIn, +} from '@/lib/auth/session-hooks'; import { getBaseUrl } from '@/lib/base-url'; -import { ACCOUNT_DEACTIVATED_ERROR_CODE } from '@/lib/constants'; import { sendEmail } from '@/lib/email/resend'; import { otpEmail } from '@/lib/email/templates'; import { prisma } from '@/lib/prisma'; @@ -62,17 +64,13 @@ export const auth = betterAuth({ }, session: { create: { - // Throws — returning false leaves callers dereferencing a null session's .token. before: async (session) => { - const user = await prisma.user.findUnique({ - where: { id: session.userId }, - select: { deletedAt: true }, - }); - if (user?.deletedAt) - throw APIError.from('FORBIDDEN', { - code: ACCOUNT_DEACTIVATED_ERROR_CODE, - message: 'This account has been deactivated.', - }); + await assertSessionUserActive(session.userId); + }, + // A blocked (before-thrown) sign-in never reaches after, so this only + // stamps committed sessions. + after: async (session) => { + await recordSignIn(session.userId); }, }, }, diff --git a/lib/auth/session-hooks.ts b/lib/auth/session-hooks.ts new file mode 100644 index 00000000..2e6528e2 --- /dev/null +++ b/lib/auth/session-hooks.ts @@ -0,0 +1,28 @@ +import 'server-only'; + +import { APIError } from 'better-auth/api'; + +import { ACCOUNT_DEACTIVATED_ERROR_CODE } from '@/lib/constants'; +import { prisma } from '@/lib/prisma'; + +// Throws — returning false leaves callers dereferencing a null session's .token. +export async function assertSessionUserActive(userId: string): Promise { + const user = await prisma.user.findUnique({ + where: { id: userId }, + select: { deletedAt: true }, + }); + if (user?.deletedAt) + throw APIError.from('FORBIDDEN', { + code: ACCOUNT_DEACTIVATED_ERROR_CODE, + message: 'This account has been deactivated.', + }); +} + +// deletedAt scope is defence in depth: Better Auth already skips `after` when +// `before` throws, but a deactivated row must never be stamped regardless. +export async function recordSignIn(userId: string): Promise { + await prisma.user.updateMany({ + where: { id: userId, deletedAt: null }, + data: { lastLoginAt: new Date() }, + }); +} diff --git a/prisma/migrations/20260924232626_add_user_last_login_at/migration.sql b/prisma/migrations/20260924232626_add_user_last_login_at/migration.sql new file mode 100644 index 00000000..2667ab27 --- /dev/null +++ b/prisma/migrations/20260924232626_add_user_last_login_at/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "lastLoginAt" TIMESTAMP(3); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index d8f89a88..ca7462df 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -70,6 +70,7 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt deletedAt DateTime? + lastLoginAt DateTime? createdById String? updatedById String? deletedById String? diff --git a/tests/db/last-sign-in.test.ts b/tests/db/last-sign-in.test.ts new file mode 100644 index 00000000..bdfca5cb --- /dev/null +++ b/tests/db/last-sign-in.test.ts @@ -0,0 +1,84 @@ +import { cleanupFixtures, createTestUser } from '@/tests/helpers/fixtures'; +import { afterAll, describe, expect, it } from 'vitest'; + +import { getUsersForAdmin } from '@/prisma/data/users'; + +import { + assertSessionUserActive, + recordSignIn, +} from '@/lib/auth/session-hooks'; +import { ACCOUNT_DEACTIVATED_ERROR_CODE } from '@/lib/constants'; +import { prisma } from '@/lib/prisma'; + +afterAll(async () => { + await cleanupFixtures(); +}); + +describe('recordSignIn', () => { + it('stamps a null lastLoginAt', async () => { + const user = await createTestUser(); + expect(user.lastLoginAt).toBeNull(); + + await recordSignIn(user.id); + + const stamped = await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + }); + expect(stamped.lastLoginAt).not.toBeNull(); + }); + + it('overwrites an earlier stamp on a second call', async () => { + const user = await createTestUser(); + + await recordSignIn(user.id); + const first = await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + }); + + await recordSignIn(user.id); + const second = await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + }); + + expect(second.lastLoginAt!.getTime()).toBeGreaterThanOrEqual( + first.lastLoginAt!.getTime(), + ); + }); + + it('leaves a deactivated user untouched', async () => { + const user = await createTestUser({ deletedAt: new Date() }); + + await recordSignIn(user.id); + + const unchanged = await prisma.user.findUniqueOrThrow({ + where: { id: user.id }, + }); + expect(unchanged.lastLoginAt).toBeNull(); + }); +}); + +describe('assertSessionUserActive', () => { + it('rejects a deactivated user with ACCOUNT_DEACTIVATED_ERROR_CODE', async () => { + const user = await createTestUser({ deletedAt: new Date() }); + + await expect(assertSessionUserActive(user.id)).rejects.toMatchObject({ + body: { code: ACCOUNT_DEACTIVATED_ERROR_CODE }, + }); + }); + + it('resolves for an active user', async () => { + const user = await createTestUser(); + await expect(assertSessionUserActive(user.id)).resolves.toBeUndefined(); + }); +}); + +describe('getUsersForAdmin', () => { + it('includes lastLoginAt', async () => { + const user = await createTestUser(); + await recordSignIn(user.id); + + const list = await getUsersForAdmin(); + const row = list.find((u) => u.id === user.id); + expect(row?.lastLoginAt).not.toBeNull(); + }); +}); From 9cac4c362afbf53bb42b73cb8948d78063d305e3 Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Thu, 24 Sep 2026 19:30:02 -0400 Subject: [PATCH 2/4] #645 extract DataTable's row comparator into sortRows Makes the nulls-last sort rule unit-testable independently of the DataTable component; behavior is unchanged. Co-Authored-By: Claude Sonnet 5 --- components/ui/data-table.tsx | 16 +++----------- lib/data-table.ts | 23 ++++++++++++++++++++ tests/unit/data-table.test.ts | 41 +++++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 13 deletions(-) diff --git a/components/ui/data-table.tsx b/components/ui/data-table.tsx index 495eefae..c5be5666 100644 --- a/components/ui/data-table.tsx +++ b/components/ui/data-table.tsx @@ -9,7 +9,7 @@ import { type DataTableColumn, type SortDirection, type SortState, - compareValues, + sortRows, } from '@/lib/data-table'; import { ACTION_ICONS } from '@/lib/icons'; import { cn } from '@/lib/utils'; @@ -268,19 +268,9 @@ export function DataTable({ const sortedRows = useMemo(() => { if (controlled || !sort.key) return rows; const column = columns.find((c) => c.key === sort.key && c.sortAccessor); - if (!column?.sortAccessor) return rows; + if (!column) return rows; - return [...rows].sort((a, b) => { - const valA = column.sortAccessor?.(a); - const valB = column.sortAccessor?.(b); - - if (valA == null && valB == null) return 0; - if (valA == null) return 1; - if (valB == null) return -1; - - const cmp = compareValues(valA, valB); - return sort.direction === 'desc' ? -cmp : cmp; - }); + return sortRows(rows, column, sort.direction); }, [controlled, rows, columns, sort.key, sort.direction]); function ariaSort(key: string): 'ascending' | 'descending' | 'none' { diff --git a/lib/data-table.ts b/lib/data-table.ts index ae1c814e..a62a60fe 100644 --- a/lib/data-table.ts +++ b/lib/data-table.ts @@ -64,6 +64,29 @@ export function compareValues( return arrA.length - arrB.length; } +// Nulls sort last regardless of direction — the null check runs before the +// direction negation below. +export function sortRows( + rows: T[], + column: DataTableColumn, + direction: SortDirection, +): T[] { + if (!column.sortAccessor) return rows; + const sortAccessor = column.sortAccessor; + + return [...rows].sort((a, b) => { + const valA = sortAccessor(a); + const valB = sortAccessor(b); + + if (valA == null && valB == null) return 0; + if (valA == null) return 1; + if (valB == null) return -1; + + const cmp = compareValues(valA, valB); + return direction === 'desc' ? -cmp : cmp; + }); +} + export interface DataTableFilter { key: string; value: string; diff --git a/tests/unit/data-table.test.ts b/tests/unit/data-table.test.ts index eff010d8..4ea4920d 100644 --- a/tests/unit/data-table.test.ts +++ b/tests/unit/data-table.test.ts @@ -4,6 +4,7 @@ import { type DataTableColumn, compareValues, filterRows, + sortRows, } from '@/lib/data-table'; describe('compareValues', () => { @@ -91,3 +92,43 @@ describe('filterRows', () => { ).toEqual([]); }); }); + +interface DatedRow { + id: string; + lastLoginAt: Date | null; +} + +const datedRows: DatedRow[] = [ + { id: 'null-1', lastLoginAt: null }, + { id: 'old', lastLoginAt: new Date('2026-01-01T00:00:00Z') }, + { id: 'new', lastLoginAt: new Date('2026-03-01T00:00:00Z') }, + { id: 'null-2', lastLoginAt: null }, +]; + +const lastLoginColumn: DataTableColumn = { + key: 'lastLoginAt', + header: 'Last sign-in', + cell: (r) => r.lastLoginAt?.toISOString() ?? '', + sortAccessor: (r) => r.lastLoginAt, +}; + +describe('sortRows', () => { + it('orders dated rows oldest-first ascending, with nulls last', () => { + const result = sortRows(datedRows, lastLoginColumn, 'asc'); + expect(result.map((r) => r.id)).toEqual(['old', 'new', 'null-1', 'null-2']); + }); + + it('orders dated rows newest-first descending, with nulls still last', () => { + const result = sortRows(datedRows, lastLoginColumn, 'desc'); + expect(result.map((r) => r.id)).toEqual(['new', 'old', 'null-1', 'null-2']); + }); + + it('returns the rows unchanged when the column has no sortAccessor', () => { + const unsortable: DataTableColumn = { + key: 'lastLoginAt', + header: 'Last sign-in', + cell: (r) => r.lastLoginAt?.toISOString() ?? '', + }; + expect(sortRows(datedRows, unsortable, 'asc')).toBe(datedRows); + }); +}); From 625073c02225c53b994ee85f234a2834f4042efc Mon Sep 17 00:00:00 2001 From: Ciel Bellerose Date: Thu, 24 Sep 2026 19:30:10 -0400 Subject: [PATCH 3/4] #645 add a Last sign-in column to the users page Sorts nulls (never-signed-in users) last in both directions; dash cells carry an sr-only "No sign-in recorded" label, and a footnote explains what the dash means since nothing is backfilled. Co-Authored-By: Claude Sonnet 5 --- components/features/users-table.tsx | 31 +++++++++++++++++++++++++++++ docs/WORKFLOWS.md | 4 ++-- lib/types.ts | 1 + prisma/data/users.ts | 1 + 4 files changed, 35 insertions(+), 2 deletions(-) diff --git a/components/features/users-table.tsx b/components/features/users-table.tsx index a0fbd99d..ea6c4a76 100644 --- a/components/features/users-table.tsx +++ b/components/features/users-table.tsx @@ -178,6 +178,20 @@ export function UsersTable({ users, currentUserId }: UsersTableProps) { sortAccessor: (u) => u.createdAt, cell: (u) => , }, + { + key: 'lastSignIn', + header: 'Last sign-in', + sortAccessor: (u) => u.lastLoginAt, + cell: (u) => + u.lastLoginAt ? ( + + ) : ( + + — + No sign-in recorded + + ), + }, { key: 'applications', header: 'Applications', @@ -457,6 +471,19 @@ export function UsersTable({ users, currentUserId }: UsersTableProps) { Joined{' '} + + {user.lastLoginAt ? ( + <> + Last sign-in{' '} + + + ) : ( + 'No sign-in recorded' + )} + {appCount > 0 ? (