From 809976fd8bcc943417bc97dc521aaf8da2d6c61e Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Sun, 19 Jul 2026 21:20:01 +0530 Subject: [PATCH] feat(d36): login history (IP + device) + first-login change nudge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- apps/hq-web/src/Login.tsx | 5 +- apps/hq-web/src/api.ts | 19 +++++-- apps/hq-web/src/main.tsx | 2 + apps/hq-web/src/nav.ts | 3 +- apps/hq-web/src/pages/LoginHistory.tsx | 70 ++++++++++++++++++++++++++ apps/hq/src/api.ts | 24 ++++++++- apps/hq/src/auth.ts | 8 ++- apps/hq/src/db.ts | 9 ++++ apps/hq/src/migrations-pg.ts | 11 ++++ apps/hq/src/repos-login-events.ts | 60 ++++++++++++++++++++++ 10 files changed, 202 insertions(+), 9 deletions(-) create mode 100644 apps/hq-web/src/pages/LoginHistory.tsx create mode 100644 apps/hq/src/repos-login-events.ts diff --git a/apps/hq-web/src/Login.tsx b/apps/hq-web/src/Login.tsx index 65557e6..937c726 100644 --- a/apps/hq-web/src/Login.tsx +++ b/apps/hq-web/src/Login.tsx @@ -16,8 +16,9 @@ export function Login() { setBusy(true) setError(undefined) try { - await login(email.trim(), pw) - nav('/') + const r = await login(email.trim(), pw) + // D34/D36: invited users on their default password land on Profile to change it. + nav(r.mustChangePassword ? '/profile?changepw=1' : '/') } catch (err) { setError(err instanceof Error ? err.message : String(err)) setBusy(false) diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index 82ce4fb..9698f42 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -61,8 +61,8 @@ export async function apiFetch(path: string, opts?: RequestInit): Promise return body } -export async function login(email: string, password: string): Promise<{ displayName: string; role: string }> { - const out = await apiFetch<{ token: string; staff: { id: string; displayName: string; role: string } }>( +export async function login(email: string, password: string): Promise<{ displayName: string; role: string; mustChangePassword: boolean }> { + const out = await apiFetch<{ token: string; staff: { id: string; displayName: string; role: string }; mustChangePassword?: boolean }>( '/auth/login', { method: 'POST', body: JSON.stringify({ email, password }) }, ) @@ -70,7 +70,20 @@ export async function login(email: string, password: string): Promise<{ displayN localStorage.setItem(NAME_KEY, out.staff.displayName) localStorage.setItem(ROLE_KEY, out.staff.role) localStorage.setItem(ID_KEY, out.staff.id) - return { displayName: out.staff.displayName, role: out.staff.role } + return { displayName: out.staff.displayName, role: out.staff.role, mustChangePassword: out.mustChangePassword === true } +} + +// D36: login history (owner) — who logged in / failed, from where. +export interface LoginEventRow { + id: string; atWall: string; staffId: string | null; usernameTried: string + outcome: 'success' | 'failed' | 'locked'; ip: string; userAgent: string; staffName: string | null +} +export interface LoginEventPage { rows: LoginEventRow[]; total: number; page: number; pageSize: number } +export const getLoginEvents = (params: Record = {}): Promise => { + const p = new URLSearchParams() + for (const [k, v] of Object.entries(params)) if (v !== '' && v !== undefined) p.set(k, String(v)) + const qs = p.toString() + return apiFetch(`/login-events${qs !== '' ? `?${qs}` : ''}`) } export interface EmailStatus { connected: boolean; address?: string; dead: boolean } diff --git a/apps/hq-web/src/main.tsx b/apps/hq-web/src/main.tsx index f48608f..92d43cc 100644 --- a/apps/hq-web/src/main.tsx +++ b/apps/hq-web/src/main.tsx @@ -28,6 +28,7 @@ import { Projects } from './pages/Projects' import { Renewals } from './pages/Renewals' import { Mis } from './pages/Mis' import { Audit } from './pages/Audit' +import { LoginHistory } from './pages/LoginHistory' import { SmsBalances } from './pages/SmsBalances' import { ModuleDirectory } from './pages/ModuleDirectory' import { Portals } from './pages/Portals' @@ -46,6 +47,7 @@ function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/apps/hq-web/src/nav.ts b/apps/hq-web/src/nav.ts index d2ce3fb..d81ad01 100644 --- a/apps/hq-web/src/nav.ts +++ b/apps/hq-web/src/nav.ts @@ -1,6 +1,6 @@ import { BarChart3, BellRing, Boxes, CalendarClock, Files, Filter, Gauge, LayoutDashboard, LayoutGrid, - KeyRound, ListChecks, MessageSquare, ScrollText, Settings as SettingsIcon, Table2, Upload, UserCog, Users, Wrench, + KeyRound, ListChecks, MessageSquare, ScrollText, Settings as SettingsIcon, ShieldCheck, Table2, Upload, UserCog, Users, Wrench, type LucideIcon, } from 'lucide-react' @@ -63,6 +63,7 @@ export const NAV_GROUPS: NavGroup[] = [ { to: '/employees', label: 'Employees', icon: UserCog, ownerOnly: true }, { to: '/import', label: 'APEX import', icon: Upload, ownerOnly: true }, { to: '/audit', label: 'Audit log', icon: ScrollText, ownerOnly: true }, + { to: '/login-history', label: 'Login history', icon: ShieldCheck, ownerOnly: true }, { to: '/settings', label: 'Settings', icon: SettingsIcon }, ], }, diff --git a/apps/hq-web/src/pages/LoginHistory.tsx b/apps/hq-web/src/pages/LoginHistory.tsx new file mode 100644 index 0000000..4b2c928 --- /dev/null +++ b/apps/hq-web/src/pages/LoginHistory.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react' +import { Badge, DataTable, EmptyState, ErrorState, PageHeader, Skeleton, Toolbar } from '@sims/ui' +import { getLoginEvents, type LoginEventPage } from '../api' +import { useData } from './Clients' + +const PAGE_SIZE = 50 +const OUTCOMES = [ + { key: '', label: 'All' }, { key: 'success', label: 'Success' }, + { key: 'failed', label: 'Failed' }, { key: 'locked', label: 'Locked' }, +] +const TONE: Record = { success: 'ok', failed: 'err', locked: 'warn' } + +/** Compact browser/OS from a user-agent string (best-effort, for the "device" column). */ +function device(ua: string): string { + if (ua === '') return '—' + const os = /Windows/i.test(ua) ? 'Windows' : /Android/i.test(ua) ? 'Android' + : /iPhone|iPad|iOS/i.test(ua) ? 'iOS' : /Mac OS X|Macintosh/i.test(ua) ? 'macOS' + : /Linux/i.test(ua) ? 'Linux' : '' + const br = /Edg\//i.test(ua) ? 'Edge' : /Chrome\//i.test(ua) ? 'Chrome' + : /Firefox\//i.test(ua) ? 'Firefox' : /Safari\//i.test(ua) ? 'Safari' : '' + return [br, os].filter(Boolean).join(' · ') || ua.slice(0, 40) +} + +/** Login history (D36, owner-only): who signed in / failed, when, from which IP + device. */ +export function LoginHistory() { + const [outcome, setOutcome] = useState('') + const [page, setPage] = useState(1) + const list = useData(() => getLoginEvents({ outcome, page, pageSize: PAGE_SIZE }), [outcome, page]) + const data = list.data + const totalPages = data !== undefined ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1 + + return ( +
+ + + + + {data !== undefined && {data.total} events} + + {list.error !== undefined ? + : data === undefined ? + : data.rows.length === 0 ? No login events yet. : ( + <> + ({ + when: r.atWall.replace('T', ' ').slice(0, 19), + who: r.staffName ?? {r.usernameTried}, + outcome: {r.outcome}, + ip: r.ip || '—', + device: {device(r.userAgent)}, + }))} + /> +
+ Page {data.page} / {totalPages} + + +
+ + )} +
+ ) +} diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 03077a2..19c6136 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -64,6 +64,7 @@ import { renewalsDue, generateModuleRenewalQuote } from './repos-renewals' import { misCockpit } from './repos-mis' import { smsBalances, refreshSmsBalance, refreshAllSmsBalances, DEFAULT_SMS_BALANCE_API } from './repos-sms' import { globalSearch } from './repos-search' +import { recordLoginEvent, listLoginEvents } from './repos-login-events' import { clearLegacyCiphertext } from './migrate-plaintext' import { assertSafeGatewayUrl } from './sms-gateway' import { makeRateLimiter } from './rate-limit' @@ -142,14 +143,35 @@ export function apiRouter( if (!loginEmailLimit(email.toLowerCase())) { res.status(429).json({ ok: false, error: 'Too many attempts for this account. Please try again later.' }); return } + const ua = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : '' const out = await login(db, email, password) if (!out.ok) { + await recordLoginEvent(db, { + staffId: null, usernameTried: email, ip, userAgent: ua, + outcome: out.reason === 'locked' ? 'locked' : 'failed', + }) const msg = out.reason === 'locked' ? 'This account is temporarily locked after too many failed attempts. Try again in 15 minutes.' : 'Invalid email or password' res.status(401).json({ ok: false, error: msg }); return } - res.json({ ok: true, token: out.token, staff: out.staff }) + await recordLoginEvent(db, { staffId: out.staff.id, usernameTried: email, ip, userAgent: ua, outcome: 'success' }) + res.json({ ok: true, token: out.token, staff: out.staff, mustChangePassword: out.staff.mustChangePassword }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + + // ---------- login history (D36, owner): who logged in / failed, from where ---------- + r.get('/login-events', requireAuth, requireOwner, async (req, res) => { + try { + const q = req.query + const opts: { outcome?: string; staffId?: string; page?: number; pageSize?: number } = {} + if (typeof q['outcome'] === 'string' && q['outcome'] !== '') opts.outcome = q['outcome'] + if (typeof q['staffId'] === 'string' && q['staffId'] !== '') opts.staffId = q['staffId'] + const page = Number(q['page']); if (Number.isInteger(page) && page >= 1) opts.page = page + const pageSize = Number(q['pageSize']); if (Number.isInteger(pageSize) && pageSize >= 1) opts.pageSize = pageSize + res.json({ ok: true, ...await listLoginEvents(db, opts) }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } diff --git a/apps/hq/src/auth.ts b/apps/hq/src/auth.ts index 86fb73f..d62425e 100644 --- a/apps/hq/src/auth.ts +++ b/apps/hq/src/auth.ts @@ -35,10 +35,11 @@ interface StaffRow { id: string; email: string; display_name: string; role: string pw_salt: string; pw_hash: string; active: number failed_count: number; locked_until: string | null + must_change_password: number } export type LoginResult = - | { ok: true; token: string; staff: { id: string; displayName: string; role: string } } + | { ok: true; token: string; staff: { id: string; displayName: string; role: string; mustChangePassword: boolean } } | { ok: false; reason: 'invalid' | 'locked' } /** @@ -83,7 +84,10 @@ export async function login(db: DB, identifier: string, password: string): Promi await db.run(`INSERT INTO session (token, staff_id, expires_at) VALUES (?, ?, ?)`, token, row.id, expires) await writeAudit(db, row.id, 'login', 'staff_user', row.id) }) - return { ok: true, token, staff: { id: row.id, displayName: row.display_name, role: row.role } } + return { + ok: true, token, + staff: { id: row.id, displayName: row.display_name, role: row.role, mustChangePassword: row.must_change_password === 1 }, + } } export async function verifySession(db: DB, token: string): Promise<{ id: string; role: string } | null> { diff --git a/apps/hq/src/db.ts b/apps/hq/src/db.ts index c606173..7f3503c 100644 --- a/apps/hq/src/db.ts +++ b/apps/hq/src/db.ts @@ -159,6 +159,15 @@ CREATE TABLE IF NOT EXISTS staff_user ( CREATE TABLE IF NOT EXISTS session ( token TEXT PRIMARY KEY, staff_id TEXT NOT NULL, expires_at TEXT NOT NULL ); +-- D36: login attempt log — who/when/from-where, success and failure. Security telemetry +-- (not the domain audit): every /auth/login attempt records IP + user-agent + outcome. +CREATE TABLE IF NOT EXISTS login_event ( + id TEXT PRIMARY KEY, at_wall TEXT NOT NULL, + staff_id TEXT, -- NULL when the username didn't match a user + username_tried TEXT NOT NULL, + outcome TEXT NOT NULL, -- 'success' | 'failed' | 'locked' + ip TEXT NOT NULL DEFAULT '', user_agent TEXT NOT NULL DEFAULT '' +); CREATE TABLE IF NOT EXISTS client ( id TEXT PRIMARY KEY, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, gstin TEXT, state_code TEXT NOT NULL DEFAULT '32', address TEXT NOT NULL DEFAULT '', diff --git a/apps/hq/src/migrations-pg.ts b/apps/hq/src/migrations-pg.ts index 4fdfdfa..1fcfd69 100644 --- a/apps/hq/src/migrations-pg.ts +++ b/apps/hq/src/migrations-pg.ts @@ -319,6 +319,17 @@ CREATE TABLE IF NOT EXISTS sms_balance_check ( ALTER TABLE client ADD COLUMN IF NOT EXISTS client_type text NOT NULL DEFAULT 'PACS'; UPDATE client SET client_type='Store' WHERE UPPER(TRIM(name)) LIKE '%STORE' OR UPPER(TRIM(name)) LIKE '%STORES'; +`, + }, + { + // D36: login attempt log (IP + user-agent + outcome). + id: '013-login-event', + sql: ` +CREATE TABLE IF NOT EXISTS login_event ( + id text PRIMARY KEY, at_wall text NOT NULL, + staff_id text, username_tried text NOT NULL, outcome text NOT NULL, + ip text NOT NULL DEFAULT '', user_agent text NOT NULL DEFAULT '' +); `, }, ] diff --git a/apps/hq/src/repos-login-events.ts b/apps/hq/src/repos-login-events.ts new file mode 100644 index 0000000..39b2435 --- /dev/null +++ b/apps/hq/src/repos-login-events.ts @@ -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 { + 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 { + 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, + } +}