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
27 changes: 27 additions & 0 deletions components/features/users-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,20 @@ export function UsersTable({ users, currentUserId }: UsersTableProps) {
sortAccessor: (u) => u.createdAt,
cell: (u) => <LocalTime date={u.createdAt} precision="date" />,
},
{
key: 'lastSignIn',
header: 'Last sign-in',
sortAccessor: (u) => u.lastLoginAt,
cell: (u) =>
u.lastLoginAt ? (
<LocalTime date={u.lastLoginAt} precision="relative" />
) : (
<span className="text-muted-foreground">
<span aria-hidden>—</span>
<span className="sr-only">No sign-in recorded</span>
</span>
),
},
{
key: 'applications',
header: 'Applications',
Expand Down Expand Up @@ -457,6 +471,19 @@ export function UsersTable({ users, currentUserId }: UsersTableProps) {
Joined{' '}
<LocalTime date={user.createdAt} precision="date" />
</span>
<span className="text-muted-foreground">
{user.lastLoginAt ? (
<>
Last sign-in{' '}
<LocalTime
date={user.lastLoginAt}
precision="relative"
/>
</>
) : (
'No sign-in recorded'
)}
</span>
{appCount > 0 ? (
<Button
variant="link"
Expand Down
16 changes: 3 additions & 13 deletions components/ui/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -268,19 +268,9 @@ export function DataTable<T>({
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' {
Expand Down
4 changes: 2 additions & 2 deletions docs/WORKFLOWS.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ Anyone not signed in. The only routes they can use are `/positions`, `/positions
- **Use a different email** returns to the email step and clears the code.
- Invalid or expired code from the link → falls through to the ordinary OTP step with the email pre-captured and the inline error already set; **Send a new code** and **Use a different email** are both available.
- Malformed or partial link params (bad email, non-6-digit otp, missing param) → the ordinary email step, with the address prefilled if it was valid.
- **End state** — a session exists. A user row is created on first sign-in if the email was not already invited ([AD-7](#ad-7-create-a-user)). The user goes to the name form if they have no name, otherwise to the sanitized `redirectTo`.
- **End state** — a session exists, and `User.lastLoginAt` is stamped (`recordSignIn`, run from the session-create `after` hook — a blocked sign-in never reaches it). A user row is created on first sign-in if the email was not already invited ([AD-7](#ad-7-create-a-user)). The user goes to the name form if they have no name, otherwise to the sanitized `redirectTo`.

### AN-5 Request a new code

Expand Down Expand Up @@ -680,7 +680,7 @@ An admin is a **manager on every position**: every [Position manager](#position-
### AD-10 Find a user

- **Trigger** — `/users`.
- **Happy path** — rows default-sort by role: admins, then managers, then everyone else, alphabetical by name (email fallback) within each group. Roles is a sortable column; clicking it restores this order after another sort has replaced it. **Role** (All/Admin/Manager) and **Managed position** filters compose with the existing search box (name, email, or a managed position's title); the Managed position select is omitted entirely when no user manages anything. The count line shows the filtered total plus admin and manager counts — both always render, including zero — and updates live as filters change. Role is badge semantics: a user who is both admin and manager counts toward, and is matched by, both figures, even though they sort into the admin group. A user who signed in but never set a name (blank, not missing) sorts and searches by email, and their row shows the email alone in the name position — no placeholder caption, and never a blank cell. The desktop row shows a manager's first two positions plus a `+N more` badge for the rest; hovering, focusing, or tapping the badge discloses the hidden titles in a tooltip, and its accessible name already carries them for screen readers even with the tooltip closed. The mobile card has the vertical room to skip the truncation and lists every managed position.
- **Happy path** — rows default-sort by role: admins, then managers, then everyone else, alphabetical by name (email fallback) within each group. Roles is a sortable column; clicking it restores this order after another sort has replaced it. **Role** (All/Admin/Manager) and **Managed position** filters compose with the existing search box (name, email, or a managed position's title); the Managed position select is omitted entirely when no user manages anything. The count line shows the filtered total plus admin and manager counts — both always render, including zero — and updates live as filters change. Role is badge semantics: a user who is both admin and manager counts toward, and is matched by, both figures, even though they sort into the admin group. A user who signed in but never set a name (blank, not missing) sorts and searches by email, and their row shows the email alone in the name position — no placeholder caption, and never a blank cell. The desktop row shows a manager's first two positions plus a `+N more` badge for the rest; hovering, focusing, or tapping the badge discloses the hidden titles in a tooltip, and its accessible name already carries them for screen readers even with the tooltip closed. The mobile card has the vertical room to skip the truncation and lists every managed position. A **Last sign-in** column (relative time, exact datetime on hover) shows `—` (announced as "No sign-in recorded") for a user with no stamped `lastLoginAt` — sorting it puts those `—` rows last in both directions, ahead of a stable `createdAt desc` order among themselves. The mobile card shows the same value or "No sign-in recorded" in its meta row.
- **Failure / edge**
- No match → the table's "No users match your filters." row/card; **Clear filters** resets search, role and managed position together.
- **End state** — read-only; promote/deactivate ([AD-8](#ad-8-grant-or-revoke-admin), [AD-9](#ad-9-deactivate-a-user)) work unchanged from either the desktop row or the mobile card.
Expand Down
22 changes: 10 additions & 12 deletions lib/auth/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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);
Comment thread
cielbellerose marked this conversation as resolved.
},
},
},
Expand Down
28 changes: 28 additions & 0 deletions lib/auth/session-hooks.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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<void> {
await prisma.user.updateMany({
where: { id: userId, deletedAt: null },
data: { lastLoginAt: new Date() },
});
}
23 changes: 23 additions & 0 deletions lib/data-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(
rows: T[],
column: DataTableColumn<T>,
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;
Expand Down
1 change: 1 addition & 0 deletions lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,7 @@ export type AdminUserListItem = Prisma.UserGetPayload<{
email: true;
isAdmin: true;
createdAt: true;
lastLoginAt: true;
managedPositions: { select: { id: true; title: true } };
_count: {
select: {
Expand Down
1 change: 1 addition & 0 deletions prisma/data/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export async function getUsersForAdmin(): Promise<AdminUserListItem[]> {
email: true,
isAdmin: true,
createdAt: true,
lastLoginAt: true,
managedPositions: {
where: { deletedAt: null },
select: { id: true, title: true },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "lastLoginAt" TIMESTAMP(3);
1 change: 1 addition & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ model User {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime?
lastLoginAt DateTime?
createdById String?
updatedById String?
deletedById String?
Expand Down
84 changes: 84 additions & 0 deletions tests/db/last-sign-in.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
41 changes: 41 additions & 0 deletions tests/unit/data-table.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type DataTableColumn,
compareValues,
filterRows,
sortRows,
} from '@/lib/data-table';

describe('compareValues', () => {
Expand Down Expand Up @@ -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<DatedRow> = {
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<DatedRow> = {
key: 'lastLoginAt',
header: 'Last sign-in',
cell: (r) => r.lastLoginAt?.toISOString() ?? '',
};
expect(sortRows(datedRows, unsortable, 'asc')).toBe(datedRows);
});
});
Loading