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
Thomas Joise 2 days ago
parent 47e17644de
commit 809976fd8b

@ -16,8 +16,9 @@ export function Login() {
setBusy(true) setBusy(true)
setError(undefined) setError(undefined)
try { try {
await login(email.trim(), pw) const r = await login(email.trim(), pw)
nav('/') // D34/D36: invited users on their default password land on Profile to change it.
nav(r.mustChangePassword ? '/profile?changepw=1' : '/')
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : String(err)) setError(err instanceof Error ? err.message : String(err))
setBusy(false) setBusy(false)

@ -61,8 +61,8 @@ export async function apiFetch<T>(path: string, opts?: RequestInit): Promise<T>
return body return body
} }
export async function login(email: string, password: string): Promise<{ 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 } }>( const out = await apiFetch<{ token: string; staff: { id: string; displayName: string; role: string }; mustChangePassword?: boolean }>(
'/auth/login', '/auth/login',
{ method: 'POST', body: JSON.stringify({ email, password }) }, { 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(NAME_KEY, out.staff.displayName)
localStorage.setItem(ROLE_KEY, out.staff.role) localStorage.setItem(ROLE_KEY, out.staff.role)
localStorage.setItem(ID_KEY, out.staff.id) 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<string, string | number> = {}): Promise<LoginEventPage> => {
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<LoginEventPage & { ok: boolean }>(`/login-events${qs !== '' ? `?${qs}` : ''}`)
} }
export interface EmailStatus { connected: boolean; address?: string; dead: boolean } export interface EmailStatus { connected: boolean; address?: string; dead: boolean }

@ -28,6 +28,7 @@ import { Projects } from './pages/Projects'
import { Renewals } from './pages/Renewals' import { Renewals } from './pages/Renewals'
import { Mis } from './pages/Mis' import { Mis } from './pages/Mis'
import { Audit } from './pages/Audit' import { Audit } from './pages/Audit'
import { LoginHistory } from './pages/LoginHistory'
import { SmsBalances } from './pages/SmsBalances' import { SmsBalances } from './pages/SmsBalances'
import { ModuleDirectory } from './pages/ModuleDirectory' import { ModuleDirectory } from './pages/ModuleDirectory'
import { Portals } from './pages/Portals' import { Portals } from './pages/Portals'
@ -46,6 +47,7 @@ function App() {
<Route path="/renewals" element={<Renewals />} /> <Route path="/renewals" element={<Renewals />} />
<Route path="/mis" element={<Mis />} /> <Route path="/mis" element={<Mis />} />
<Route path="/audit" element={<Audit />} /> <Route path="/audit" element={<Audit />} />
<Route path="/login-history" element={<LoginHistory />} />
<Route path="/sms-balances" element={<SmsBalances />} /> <Route path="/sms-balances" element={<SmsBalances />} />
<Route path="/module-data" element={<ModuleDirectory />} /> <Route path="/module-data" element={<ModuleDirectory />} />
<Route path="/portals" element={<Portals />} /> <Route path="/portals" element={<Portals />} />

@ -1,6 +1,6 @@
import { import {
BarChart3, BellRing, Boxes, CalendarClock, Files, Filter, Gauge, LayoutDashboard, LayoutGrid, 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, type LucideIcon,
} from 'lucide-react' } from 'lucide-react'
@ -63,6 +63,7 @@ export const NAV_GROUPS: NavGroup[] = [
{ to: '/employees', label: 'Employees', icon: UserCog, ownerOnly: true }, { to: '/employees', label: 'Employees', icon: UserCog, ownerOnly: true },
{ to: '/import', label: 'APEX import', icon: Upload, ownerOnly: true }, { to: '/import', label: 'APEX import', icon: Upload, ownerOnly: true },
{ to: '/audit', label: 'Audit log', icon: ScrollText, 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 }, { to: '/settings', label: 'Settings', icon: SettingsIcon },
], ],
}, },

@ -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<string, 'ok' | 'err' | 'warn' | undefined> = { 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<LoginEventPage | undefined>(() => 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 (
<div className="wf-page">
<PageHeader title="Login history" desc="Every sign-in attempt — who, when, from which IP and device. Owner-only." />
<Toolbar>
<select className="wf" style={{ maxWidth: 180 }} value={outcome} aria-label="Outcome"
onChange={(e) => { setOutcome(e.target.value); setPage(1) }}>
{OUTCOMES.map((o) => <option key={o.key} value={o.key}>{o.label}</option>)}
</select>
<span style={{ flex: 1 }} />
{data !== undefined && <Badge>{data.total} events</Badge>}
</Toolbar>
{list.error !== undefined ? <ErrorState message={list.error} onRetry={list.reload} />
: data === undefined ? <Skeleton rows={8} />
: data.rows.length === 0 ? <EmptyState>No login events yet.</EmptyState> : (
<>
<DataTable
columns={[
{ key: 'when', label: 'When' }, { key: 'who', label: 'User' },
{ key: 'outcome', label: 'Outcome' }, { key: 'ip', label: 'IP', mono: true },
{ key: 'device', label: 'Device' },
]}
rows={data.rows.map((r) => ({
when: r.atWall.replace('T', ' ').slice(0, 19),
who: r.staffName ?? <span className="mono" style={{ opacity: 0.7 }}>{r.usernameTried}</span>,
outcome: <Badge tone={TONE[r.outcome]}>{r.outcome}</Badge>,
ip: r.ip || '—',
device: <span title={r.userAgent} style={{ fontSize: 12.5 }}>{device(r.userAgent)}</span>,
}))}
/>
<div className="wf-pager">
<span>Page {data.page} / {totalPages}</span>
<button type="button" className="wf" disabled={data.page <= 1} onClick={() => setPage((p) => Math.max(1, p - 1))}> Prev</button>
<button type="button" className="wf" disabled={data.page >= totalPages} onClick={() => setPage((p) => p + 1)}>Next </button>
</div>
</>
)}
</div>
)
}

@ -64,6 +64,7 @@ import { renewalsDue, generateModuleRenewalQuote } from './repos-renewals'
import { misCockpit } from './repos-mis' import { misCockpit } from './repos-mis'
import { smsBalances, refreshSmsBalance, refreshAllSmsBalances, DEFAULT_SMS_BALANCE_API } from './repos-sms' import { smsBalances, refreshSmsBalance, refreshAllSmsBalances, DEFAULT_SMS_BALANCE_API } from './repos-sms'
import { globalSearch } from './repos-search' import { globalSearch } from './repos-search'
import { recordLoginEvent, listLoginEvents } from './repos-login-events'
import { clearLegacyCiphertext } from './migrate-plaintext' import { clearLegacyCiphertext } from './migrate-plaintext'
import { assertSafeGatewayUrl } from './sms-gateway' import { assertSafeGatewayUrl } from './sms-gateway'
import { makeRateLimiter } from './rate-limit' import { makeRateLimiter } from './rate-limit'
@ -142,14 +143,35 @@ export function apiRouter(
if (!loginEmailLimit(email.toLowerCase())) { if (!loginEmailLimit(email.toLowerCase())) {
res.status(429).json({ ok: false, error: 'Too many attempts for this account. Please try again later.' }); return 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) const out = await login(db, email, password)
if (!out.ok) { if (!out.ok) {
await recordLoginEvent(db, {
staffId: null, usernameTried: email, ip, userAgent: ua,
outcome: out.reason === 'locked' ? 'locked' : 'failed',
})
const msg = out.reason === 'locked' const msg = out.reason === 'locked'
? 'This account is temporarily locked after too many failed attempts. Try again in 15 minutes.' ? 'This account is temporarily locked after too many failed attempts. Try again in 15 minutes.'
: 'Invalid email or password' : 'Invalid email or password'
res.status(401).json({ ok: false, error: msg }); return 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) { } catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
} }

@ -35,10 +35,11 @@ interface StaffRow {
id: string; email: string; display_name: string; role: string id: string; email: string; display_name: string; role: string
pw_salt: string; pw_hash: string; active: number pw_salt: string; pw_hash: string; active: number
failed_count: number; locked_until: string | null failed_count: number; locked_until: string | null
must_change_password: number
} }
export type LoginResult = 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' } | { 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 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) 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> { export async function verifySession(db: DB, token: string): Promise<{ id: string; role: string } | null> {

@ -159,6 +159,15 @@ CREATE TABLE IF NOT EXISTS staff_user (
CREATE TABLE IF NOT EXISTS session ( CREATE TABLE IF NOT EXISTS session (
token TEXT PRIMARY KEY, staff_id TEXT NOT NULL, expires_at TEXT NOT NULL 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 ( CREATE TABLE IF NOT EXISTS client (
id TEXT PRIMARY KEY, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, 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 '', gstin TEXT, state_code TEXT NOT NULL DEFAULT '32', address TEXT NOT NULL DEFAULT '',

@ -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'; ALTER TABLE client ADD COLUMN IF NOT EXISTS client_type text NOT NULL DEFAULT 'PACS';
UPDATE client SET client_type='Store' UPDATE client SET client_type='Store'
WHERE UPPER(TRIM(name)) LIKE '%STORE' OR UPPER(TRIM(name)) LIKE '%STORES'; 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 ''
);
`, `,
}, },
] ]

@ -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…
Cancel
Save