feat(d36): login history (IP + device) + first-login change nudge
Captures every /auth/login attempt in a login_event table (SQLite schema + PG migration 013): staff_id (or null for an unknown user), username tried, outcome (success/failed/ locked), IP, and user-agent — best-effort, never breaks login. New owner 'Login history' page (Admin): who signed in / failed, when, from which IP + parsed device, outcome filter, paginated. login() now returns mustChangePassword; an invited user on their default password is sent to Profile to change it on sign-in. typecheck + auth/employee tests green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>feat/client-detail-redesign
parent
47e17644de
commit
809976fd8b
@ -0,0 +1,60 @@
|
||||
import { uuidv7 } from '@sims/domain'
|
||||
import type { DB } from './db'
|
||||
|
||||
/**
|
||||
* Login attempt log (D36). Security telemetry — separate from the domain audit log:
|
||||
* every /auth/login attempt records who was tried, the outcome, and the IP + user-agent
|
||||
* it came from. Read-only for the owner (a "who logged in / failed attempts" view).
|
||||
* Recording never throws into the login path (best-effort).
|
||||
*/
|
||||
export interface LoginEvent {
|
||||
id: string; atWall: string; staffId: string | null; usernameTried: string
|
||||
outcome: 'success' | 'failed' | 'locked'; ip: string; userAgent: string
|
||||
/** Resolved display name of staffId (or null) — filled by the list query. */
|
||||
staffName: string | null
|
||||
}
|
||||
|
||||
export async function recordLoginEvent(
|
||||
db: DB,
|
||||
e: { staffId: string | null; usernameTried: string; outcome: 'success' | 'failed' | 'locked'; ip: string; userAgent: string },
|
||||
): Promise<void> {
|
||||
try {
|
||||
await db.run(
|
||||
`INSERT INTO login_event (id, at_wall, staff_id, username_tried, outcome, ip, user_agent)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
uuidv7(), new Date().toISOString(), e.staffId, e.usernameTried.slice(0, 120), e.outcome,
|
||||
e.ip.slice(0, 80), (e.userAgent ?? '').slice(0, 400),
|
||||
)
|
||||
} catch { /* telemetry is best-effort — never break login */ }
|
||||
}
|
||||
|
||||
export interface LoginEventPage { rows: LoginEvent[]; total: number; page: number; pageSize: number }
|
||||
|
||||
/** Recent login attempts, newest first, honestly paginated. `outcome`/`staffId` narrow it. */
|
||||
export async function listLoginEvents(
|
||||
db: DB, opts: { outcome?: string; staffId?: string; page?: number; pageSize?: number } = {},
|
||||
): Promise<LoginEventPage> {
|
||||
let where = ' WHERE 1=1'
|
||||
const args: unknown[] = []
|
||||
if (opts.outcome !== undefined && opts.outcome !== '') { where += ' AND e.outcome=?'; args.push(opts.outcome) }
|
||||
if (opts.staffId !== undefined && opts.staffId !== '') { where += ' AND e.staff_id=?'; args.push(opts.staffId) }
|
||||
const total = ((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM login_event e${where}`, ...args))!).n
|
||||
const page = Math.max(1, opts.page ?? 1)
|
||||
const pageSize = Math.min(200, Math.max(1, opts.pageSize ?? 50))
|
||||
const rows = await db.all<{
|
||||
id: string; at_wall: string; staff_id: string | null; username_tried: string
|
||||
outcome: string; ip: string; user_agent: string; staff_name: string | null
|
||||
}>(
|
||||
`SELECT e.*, u.display_name AS staff_name
|
||||
FROM login_event e LEFT JOIN staff_user u ON u.id = e.staff_id
|
||||
${where} ORDER BY e.id DESC LIMIT ? OFFSET ?`,
|
||||
...args, pageSize, (page - 1) * pageSize,
|
||||
)
|
||||
return {
|
||||
rows: rows.map((r) => ({
|
||||
id: r.id, atWall: r.at_wall, staffId: r.staff_id, usernameTried: r.username_tried,
|
||||
outcome: r.outcome as LoginEvent['outcome'], ip: r.ip, userAgent: r.user_agent, staffName: r.staff_name,
|
||||
})),
|
||||
total, page, pageSize,
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue