From 56686dd4d68994554fcd69c6c274f00fd92c306e Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Sun, 19 Jul 2026 18:17:41 +0530 Subject: [PATCH] feat: show + download SMS logins; install details in Module data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- apps/hq-web/src/api.ts | 4 +++ apps/hq-web/src/pages/ModuleDirectory.tsx | 6 ++++ apps/hq-web/src/pages/SmsBalances.tsx | 39 +++++++++++++++++++++-- apps/hq/src/api.ts | 4 ++- apps/hq/src/repos-modules.ts | 19 ++++++++--- apps/hq/src/repos-sms.ts | 26 +++++++++++++-- 6 files changed, 87 insertions(+), 11 deletions(-) diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index e9697bd..b78c56c 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -268,6 +268,8 @@ export const bulkUpdateClients = (ids: string[], patch: ClientWrite): Promise<{ export interface ModuleDirectoryRow { cmId: string; clientId: string; clientName: string; clientCode: string 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 } export interface ModuleDirectory { @@ -955,6 +957,8 @@ export interface SmsBalanceRow { balanceText: string | null; balance: number | null; low: boolean reseller: string | null; status: string 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 } export interface SmsBalances { diff --git a/apps/hq-web/src/pages/ModuleDirectory.tsx b/apps/hq-web/src/pages/ModuleDirectory.tsx index 48b0cd3..344c030 100644 --- a/apps/hq-web/src/pages/ModuleDirectory.tsx +++ b/apps/hq-web/src/pages/ModuleDirectory.tsx @@ -80,12 +80,18 @@ export function ModuleDirectory() { ({ key: f.key, label: f.label })), + { key: 'renewal', label: 'Next renewal' }, { key: 'login', label: 'Login' }, ]} rows={shown.map((r) => ({ client: {r.clientCode} · {r.clientName}, + status: {r.status}, + installed: r.installedOn ?? , ...Object.fromEntries(cols.map((f) => [f.key, fieldCell(f, r.fieldValues[f.key])])), + renewal: r.nextRenewal ?? , login: r.username !== null && r.username !== '' ? {r.username}{r.hasPassword ? ' 🔑' : ''} : , diff --git a/apps/hq-web/src/pages/SmsBalances.tsx b/apps/hq-web/src/pages/SmsBalances.tsx index bf58f56..883317a 100644 --- a/apps/hq-web/src/pages/SmsBalances.tsx +++ b/apps/hq-web/src/pages/SmsBalances.tsx @@ -36,7 +36,29 @@ export function SmsBalances() { const [scope, setScope] = useState('low') const [busy, setBusy] = useState() const [refreshing, setRefreshing] = useState(false) + const [showCreds, setShowCreds] = useState(true) // managerial: show username/password columns 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 = () => { if (refreshing) return @@ -71,8 +93,14 @@ export function SmsBalances() { {refreshing ? 'Checking…' : 'Refresh from gateway'} + actions={managerial + ? ( + + + + + + ) : undefined} /> {data !== undefined && data.lowCount > 0 && scope !== 'low' && ( @@ -106,6 +134,9 @@ export function SmsBalances() { columns={[ { key: 'client', label: 'Client' }, { 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: 'checked', label: 'Checked' }, { key: 'actions', label: '' }, @@ -120,6 +151,10 @@ export function SmsBalances() { {r.balance.toLocaleString('en-IN')} ), + username: r.username ?? , + password: r.password !== null && r.password !== '' + ? r.password + : {r.hasCreds ? '(set)' : '—'}, provider: r.provider ?? '—', checked: r.checkStatus === 'error' ? ⚠ {ago(r.checkedAt)} diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index ad3d545..d14eccb 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -1567,7 +1567,9 @@ export function apiRouter( // ---------- SMS credit review (D28) ---------- r.get('/reports/sms-balances', requireAuth, async (_req, res) => { 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) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } diff --git a/apps/hq/src/repos-modules.ts b/apps/hq/src/repos-modules.ts index a902728..aa6b7a3 100644 --- a/apps/hq/src/repos-modules.ts +++ b/apps/hq/src/repos-modules.ts @@ -296,6 +296,9 @@ export async function listClientModules(db: DB, clientId: string): Promise } @@ -316,11 +319,15 @@ export async function moduleDirectory(db: DB, moduleId: string): Promise( `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 WHERE cm.module_id=? AND cm.active=1 ORDER BY c.name, cm.id`, @@ -331,8 +338,10 @@ export async function moduleDirectory(db: DB, moduleId: string): Promise } catch { fv = {} } return { 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, - hasPassword: r.password_enc !== null, fieldValues: fv, + status: r.status, provider: r.provider, username: r.username, hasPassword: r.password_enc !== null, + 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 } diff --git a/apps/hq/src/repos-sms.ts b/apps/hq/src/repos-sms.ts index a45b15e..50d78c4 100644 --- a/apps/hq/src/repos-sms.ts +++ b/apps/hq/src/repos-sms.ts @@ -19,6 +19,10 @@ export interface SmsBalanceRow { balanceText: string | null; balance: number | null; low: boolean reseller: string | null; status: string 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). */ 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 } } -export async function smsBalances(db: DB): Promise { +/** + * 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 { const threshold = await getNumberSetting(db, 'sms.low_balance_threshold', 10000) 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 } const raw = await db.all<{ cm_id: string; client_id: string; client_name: string; client_code: string 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 }>( `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 FROM client_module cm JOIN client c ON c.id = cm.client_id @@ -63,13 +74,22 @@ export async function smsBalances(db: DB): Promise { try { fv = JSON.parse(r.field_values) as Record } catch { fv = {} } const balanceText = typeof fv.sms_balance === 'string' && fv.sms_balance.trim() !== '' ? fv.sms_balance.trim() : null 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 { cmId: r.cm_id, clientId: r.client_id, clientName: r.client_name, clientCode: r.client_code, 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, provider: r.provider !== null && r.provider !== '' ? r.provider : null, 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, } })