feat: show + download SMS logins; install details in Module data

- SMS credits screen (managerial): Username + Password columns shown plainly (D32
  plaintext), a Show/Hide toggle, and 'Download logins (CSV)' (client, code, username,
  password, provider, balance, install date, phone). Password decrypted server-side only
  for owner/manager viewers (smsBalances includeSecrets); staff still see balances only.
- Module data directory: rows now carry the lifecycle install details — Status, Installed
  date, Next renewal columns alongside the module's own fields, so the whole book shows
  when each client went live plus all their service data. typecheck + tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 days ago
parent 5679a94d3c
commit 56686dd4d6

@ -268,6 +268,8 @@ export const bulkUpdateClients = (ids: string[], patch: ClientWrite): Promise<{
export interface ModuleDirectoryRow { export interface ModuleDirectoryRow {
cmId: string; clientId: string; clientName: string; clientCode: string cmId: string; clientId: string; clientName: string; clientCode: string
status: string; provider: string | null; username: string | null; hasPassword: boolean status: string; provider: string | null; username: string | null; hasPassword: boolean
kind: string; edition: string
installedOn: string | null; completedOn: string | null; trainedOn: string | null; nextRenewal: string | null
fieldValues: Record<string, string> fieldValues: Record<string, string>
} }
export interface ModuleDirectory { export interface ModuleDirectory {
@ -955,6 +957,8 @@ export interface SmsBalanceRow {
balanceText: string | null; balance: number | null; low: boolean balanceText: string | null; balance: number | null; low: boolean
reseller: string | null; status: string reseller: string | null; status: string
provider: string | null; hasCreds: boolean provider: string | null; hasCreds: boolean
username: string | null; password: string | null
installationDate: string | null; phone: string | null
checkStatus: string | null; checkMessage: string | null; checkedAt: string | null checkStatus: string | null; checkMessage: string | null; checkedAt: string | null
} }
export interface SmsBalances { export interface SmsBalances {

@ -80,12 +80,18 @@ export function ModuleDirectory() {
<DataTable <DataTable
columns={[ columns={[
{ key: 'client', label: 'Client' }, { key: 'client', label: 'Client' },
{ key: 'status', label: 'Status' },
{ key: 'installed', label: 'Installed' },
...cols.map((f) => ({ key: f.key, label: f.label })), ...cols.map((f) => ({ key: f.key, label: f.label })),
{ key: 'renewal', label: 'Next renewal' },
{ key: 'login', label: 'Login' }, { key: 'login', label: 'Login' },
]} ]}
rows={shown.map((r) => ({ rows={shown.map((r) => ({
client: <Link to={`/clients/${r.clientId}?tab=modules`}>{r.clientCode} · {r.clientName}</Link>, client: <Link to={`/clients/${r.clientId}?tab=modules`}>{r.clientCode} · {r.clientName}</Link>,
status: <Badge tone={r.status === 'live' ? 'green' : undefined}>{r.status}</Badge>,
installed: r.installedOn ?? <span style={{ opacity: 0.4 }}></span>,
...Object.fromEntries(cols.map((f) => [f.key, fieldCell(f, r.fieldValues[f.key])])), ...Object.fromEntries(cols.map((f) => [f.key, fieldCell(f, r.fieldValues[f.key])])),
renewal: r.nextRenewal ?? <span style={{ opacity: 0.4 }}></span>,
login: r.username !== null && r.username !== '' login: r.username !== null && r.username !== ''
? <span className="mono" style={{ fontSize: 12 }}>{r.username}{r.hasPassword ? ' 🔑' : ''}</span> ? <span className="mono" style={{ fontSize: 12 }}>{r.username}{r.hasPassword ? ' 🔑' : ''}</span>
: <span style={{ opacity: 0.4 }}></span>, : <span style={{ opacity: 0.4 }}></span>,

@ -36,7 +36,29 @@ export function SmsBalances() {
const [scope, setScope] = useState<Scope>('low') const [scope, setScope] = useState<Scope>('low')
const [busy, setBusy] = useState<string | undefined>() const [busy, setBusy] = useState<string | undefined>()
const [refreshing, setRefreshing] = useState(false) const [refreshing, setRefreshing] = useState(false)
const [showCreds, setShowCreds] = useState(true) // managerial: show username/password columns
const list = useData(getSmsBalances, []) const list = useData(getSmsBalances, [])
const managerial = isManagerial()
/** Download the SMS logins as a CSV (managerial): client, code, username, password, provider, balance. */
const downloadCreds = () => {
const rows = data?.rows ?? []
const esc = (v: string | number | null) => {
const s = v === null ? '' : String(v)
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
}
const header = ['Code', 'Client', 'Username', 'Password', 'Provider', 'Balance', 'Installation date', 'Phone']
const lines = [header.join(',')]
for (const r of rows) {
lines.push([esc(r.clientCode), esc(r.clientName), esc(r.username), esc(r.password),
esc(r.provider), esc(r.balance), esc(r.installationDate), esc(r.phone)].join(','))
}
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = 'sms-credentials.csv'; a.click()
URL.revokeObjectURL(url)
}
const refreshAll = () => { const refreshAll = () => {
if (refreshing) return if (refreshing) return
@ -71,8 +93,14 @@ export function SmsBalances() {
<PageHeader <PageHeader
title="SMS balances" title="SMS balances"
desc="SMS credits across every client, lowest first — spot who is about to run dry and raise a top-up before they do." desc="SMS credits across every client, lowest first — spot who is about to run dry and raise a top-up before they do."
actions={isManagerial() actions={managerial
? <Button tone="primary" disabled={refreshing} onClick={refreshAll}>{refreshing ? 'Checking…' : 'Refresh from gateway'}</Button> ? (
<span style={{ display: 'inline-flex', gap: 8 }}>
<Button onClick={() => setShowCreds((v) => !v)}>{showCreds ? 'Hide logins' : 'Show logins'}</Button>
<Button onClick={downloadCreds}>Download logins (CSV)</Button>
<Button tone="primary" disabled={refreshing} onClick={refreshAll}>{refreshing ? 'Checking…' : 'Refresh from gateway'}</Button>
</span>
)
: undefined} : undefined}
/> />
{data !== undefined && data.lowCount > 0 && scope !== 'low' && ( {data !== undefined && data.lowCount > 0 && scope !== 'low' && (
@ -106,6 +134,9 @@ export function SmsBalances() {
columns={[ columns={[
{ key: 'client', label: 'Client' }, { key: 'client', label: 'Client' },
{ key: 'balance', label: 'Balance', numeric: true }, { key: 'balance', label: 'Balance', numeric: true },
...(managerial && showCreds
? [{ key: 'username', label: 'Username', mono: true }, { key: 'password', label: 'Password', mono: true }]
: []),
{ key: 'provider', label: 'Provider' }, { key: 'provider', label: 'Provider' },
{ key: 'checked', label: 'Checked' }, { key: 'checked', label: 'Checked' },
{ key: 'actions', label: '' }, { key: 'actions', label: '' },
@ -120,6 +151,10 @@ export function SmsBalances() {
{r.balance.toLocaleString('en-IN')} {r.balance.toLocaleString('en-IN')}
</Badge> </Badge>
), ),
username: r.username ?? <span style={{ opacity: 0.4 }}></span>,
password: r.password !== null && r.password !== ''
? r.password
: <span style={{ opacity: 0.4 }}>{r.hasCreds ? '(set)' : '—'}</span>,
provider: r.provider ?? '—', provider: r.provider ?? '—',
checked: r.checkStatus === 'error' checked: r.checkStatus === 'error'
? <span title={r.checkMessage ?? ''} style={{ color: 'var(--err)', fontSize: 12.5 }}> {ago(r.checkedAt)}</span> ? <span title={r.checkMessage ?? ''} style={{ color: 'var(--err)', fontSize: 12.5 }}> {ago(r.checkedAt)}</span>

@ -1567,7 +1567,9 @@ export function apiRouter(
// ---------- SMS credit review (D28) ---------- // ---------- SMS credit review (D28) ----------
r.get('/reports/sms-balances', requireAuth, async (_req, res) => { r.get('/reports/sms-balances', requireAuth, async (_req, res) => {
try { try {
res.json({ ok: true, ...await smsBalances(db) }) // Managerial viewers get the gateway passwords (D32 plaintext) for display + download.
const viewer = res.locals['staff'] as { role: string }
res.json({ ok: true, ...await smsBalances(db, isManagerial(viewer.role)) })
} 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) })
} }

@ -296,6 +296,9 @@ export async function listClientModules(db: DB, clientId: string): Promise<Clien
export interface ModuleDirectoryRow { export interface ModuleDirectoryRow {
cmId: string; clientId: string; clientName: string; clientCode: string cmId: string; clientId: string; clientName: string; clientCode: string
status: string; provider: string | null; username: string | null; hasPassword: boolean status: string; provider: string | null; username: string | null; hasPassword: boolean
/** Lifecycle / install details (D20) so the roster shows when each client went live. */
kind: string; edition: string
installedOn: string | null; completedOn: string | null; trainedOn: string | null; nextRenewal: string | null
/** The module's declared field values for this client (non-secret; secrets stay server-side). */ /** The module's declared field values for this client (non-secret; secrets stay server-side). */
fieldValues: Record<string, string> fieldValues: Record<string, string>
} }
@ -316,11 +319,15 @@ export async function moduleDirectory(db: DB, moduleId: string): Promise<ModuleD
if (mod === null) return null if (mod === null) return null
const rows = await db.all<{ const rows = await db.all<{
cm_id: string; client_id: string; client_name: string; client_code: string cm_id: string; client_id: string; client_name: string; client_code: string
status: string; provider: string | null; username: string | null status: string; provider: string | null; username: string | null; password_enc: string | null
password_enc: string | null; field_values: string kind: string; edition: string
installed_on: string | null; completed_on: string | null; trained_on: string | null; next_renewal: string | null
field_values: string
}>( }>(
`SELECT cm.id AS cm_id, cm.client_id, c.name AS client_name, c.code AS client_code, `SELECT cm.id AS cm_id, cm.client_id, c.name AS client_name, c.code AS client_code,
cm.status, cm.provider, cm.username, cm.password_enc, cm.field_values cm.status, cm.provider, cm.username, cm.password_enc,
cm.kind, cm.edition, cm.installed_on, cm.completed_on, cm.trained_on, cm.next_renewal,
cm.field_values
FROM client_module cm JOIN client c ON c.id = cm.client_id FROM client_module cm JOIN client c ON c.id = cm.client_id
WHERE cm.module_id=? AND cm.active=1 WHERE cm.module_id=? AND cm.active=1
ORDER BY c.name, cm.id`, ORDER BY c.name, cm.id`,
@ -331,8 +338,10 @@ export async function moduleDirectory(db: DB, moduleId: string): Promise<ModuleD
try { fv = JSON.parse(r.field_values) as Record<string, string> } catch { fv = {} } try { fv = JSON.parse(r.field_values) as Record<string, string> } catch { fv = {} }
return { return {
cmId: r.cm_id, clientId: r.client_id, clientName: r.client_name, clientCode: r.client_code, cmId: r.cm_id, clientId: r.client_id, clientName: r.client_name, clientCode: r.client_code,
status: r.status, provider: r.provider, username: r.username, status: r.status, provider: r.provider, username: r.username, hasPassword: r.password_enc !== null,
hasPassword: r.password_enc !== null, fieldValues: fv, kind: r.kind, edition: r.edition,
installedOn: r.installed_on, completedOn: r.completed_on, trainedOn: r.trained_on, nextRenewal: r.next_renewal,
fieldValues: fv,
} }
}) })
return { moduleId, code: mod.code, name: mod.name, fieldSpec: mod.fieldSpec, rows: out, total: out.length } return { moduleId, code: mod.code, name: mod.name, fieldSpec: mod.fieldSpec, rows: out, total: out.length }

@ -19,6 +19,10 @@ export interface SmsBalanceRow {
balanceText: string | null; balance: number | null; low: boolean balanceText: string | null; balance: number | null; low: boolean
reseller: string | null; status: string reseller: string | null; status: string
provider: string | null; hasCreds: boolean provider: string | null; hasCreds: boolean
/** Gateway login — username always; password only when the viewer is managerial (D32 plaintext). */
username: string | null; password: string | null
/** Installation date from the SMS field values (D21), if recorded. */
installationDate: string | null; phone: string | null
/** Last gateway poll (D28): 'ok' | 'error' | null (never polled). */ /** Last gateway poll (D28): 'ok' | 'error' | null (never polled). */
checkStatus: string | null; checkMessage: string | null; checkedAt: string | null checkStatus: string | null; checkMessage: string | null; checkedAt: string | null
} }
@ -40,17 +44,24 @@ export async function smsLowCount(db: DB): Promise<{ low: number; threshold: num
return { low: b.lowCount, threshold: b.threshold } return { low: b.lowCount, threshold: b.threshold }
} }
export async function smsBalances(db: DB): Promise<SmsBalances> { /**
* SMS credit review. `includeSecrets` (set for managerial viewers at the route) decrypts and
* returns each account's gateway password (D32: plaintext) so it can be shown and downloaded;
* otherwise password stays null. Username, installation date and phone come along for the
* per-client detail the SMS screen shows.
*/
export async function smsBalances(db: DB, includeSecrets = false): Promise<SmsBalances> {
const threshold = await getNumberSetting(db, 'sms.low_balance_threshold', 10000) const threshold = await getNumberSetting(db, 'sms.low_balance_threshold', 10000)
const mod = await db.get<{ id: string }>(`SELECT id FROM module WHERE code='SMS'`) const mod = await db.get<{ id: string }>(`SELECT id FROM module WHERE code='SMS'`)
if (mod === undefined) return { rows: [], threshold, total: 0, lowCount: 0, unknownCount: 0 } if (mod === undefined) return { rows: [], threshold, total: 0, lowCount: 0, unknownCount: 0 }
const raw = await db.all<{ const raw = await db.all<{
cm_id: string; client_id: string; client_name: string; client_code: string cm_id: string; client_id: string; client_name: string; client_code: string
field_values: string; status: string; provider: string | null; username: string | null field_values: string; status: string; provider: string | null; username: string | null
password_enc: string | null
check_status: string | null; check_message: string | null; checked_at: string | null check_status: string | null; check_message: string | null; checked_at: string | null
}>( }>(
`SELECT cm.id AS cm_id, cm.client_id, c.name AS client_name, c.code AS client_code, `SELECT cm.id AS cm_id, cm.client_id, c.name AS client_name, c.code AS client_code,
cm.field_values, cm.status, cm.provider, cm.username, cm.field_values, cm.status, cm.provider, cm.username, cm.password_enc,
s.status AS check_status, s.message AS check_message, s.checked_at s.status AS check_status, s.message AS check_message, s.checked_at
FROM client_module cm FROM client_module cm
JOIN client c ON c.id = cm.client_id JOIN client c ON c.id = cm.client_id
@ -63,13 +74,22 @@ export async function smsBalances(db: DB): Promise<SmsBalances> {
try { fv = JSON.parse(r.field_values) as Record<string, unknown> } catch { fv = {} } try { fv = JSON.parse(r.field_values) as Record<string, unknown> } catch { fv = {} }
const balanceText = typeof fv.sms_balance === 'string' && fv.sms_balance.trim() !== '' ? fv.sms_balance.trim() : null const balanceText = typeof fv.sms_balance === 'string' && fv.sms_balance.trim() !== '' ? fv.sms_balance.trim() : null
const balance = parseBalance(balanceText) const balance = parseBalance(balanceText)
const strField = (k: string): string | null =>
typeof fv[k] === 'string' && (fv[k] as string).trim() !== '' ? (fv[k] as string).trim() : null
let password: string | null = null
if (includeSecrets && r.password_enc !== null) {
try { password = decrypt(r.password_enc, '') } catch { password = null }
}
return { return {
cmId: r.cm_id, clientId: r.client_id, clientName: r.client_name, clientCode: r.client_code, cmId: r.cm_id, clientId: r.client_id, clientName: r.client_name, clientCode: r.client_code,
balanceText, balance, low: balance !== null && balance < threshold, balanceText, balance, low: balance !== null && balance < threshold,
reseller: typeof fv.reseller === 'string' && fv.reseller.trim() !== '' ? fv.reseller.trim() : null, reseller: strField('reseller'),
status: r.status, status: r.status,
provider: r.provider !== null && r.provider !== '' ? r.provider : null, provider: r.provider !== null && r.provider !== '' ? r.provider : null,
hasCreds: r.username !== null && r.username !== '', hasCreds: r.username !== null && r.username !== '',
username: r.username !== null && r.username !== '' ? r.username : null,
password,
installationDate: strField('installation_date'), phone: strField('phone'),
checkStatus: r.check_status, checkMessage: r.check_message, checkedAt: r.checked_at, checkStatus: r.check_status, checkMessage: r.check_message, checkedAt: r.checked_at,
} }
}) })

Loading…
Cancel
Save