import type { Db } from '../db'; import { isValidUsername, RuleError, USERNAME_HELP } from '../services/accountRules'; import { cleanPassword, hashPassword } from '../services/password'; import { normalizePhone } from '../services/phone'; import { countAdmins, createAdminAccount, createUserAccount, setAccountPassword } from '../services/provisioning'; import { findUserByUsername, listUsers, type User } from '../services/users'; export class Cancelled extends Error { constructor(message = 'cancelled') { super(message); this.name = 'Cancelled'; } } export interface Io { ask(question: string): Promise; askExact?(question: string): Promise; say(line?: string): void; } export type Parsed = { ok: true; value: T } | { ok: false; error: string }; const MAX_TRIES = 5; export async function askUntil(io: Io, question: string, parse: (answer: string) => Parsed): Promise { for (let tries = 0; tries < MAX_TRIES; tries++) { const answer = await io.ask(question); if (answer.toLowerCase() === 'q') throw new Cancelled(); const result = parse(answer); if (result.ok) return result.value; io.say(` ${result.error}`); } throw new Cancelled('too many invalid answers'); } const parseKind = (a: string): Parsed<'admin' | 'user'> => { const v = a.toLowerCase(); if (v === '1' || v === 'admin') return { ok: true, value: 'admin' }; if (v === '2' || v === 'user') return { ok: true, value: 'user' }; return { ok: false, error: 'Please type 1 for Admin or 2 for User (or q to quit).' }; }; const parseUsername = (a: string): Parsed => isValidUsername(a) ? { ok: true, value: a.trim() } : { ok: false, error: `Use ${USERNAME_HELP}.` }; const phoneError = 'That does not look like a phone number. Include the country code, for example 919876543210.'; const parseRequiredPhone = (cc: string) => (a: string): Parsed => { const phone = normalizePhone(a, cc); return phone ? { ok: true, value: phone } : { ok: false, error: phoneError }; }; const parseOptionalPhone = (cc: string) => (a: string): Parsed => { if (a === '') return { ok: true, value: null }; const phone = normalizePhone(a, cc); return phone ? { ok: true, value: phone } : { ok: false, error: phoneError }; }; const parseShopNumbers = (count: number) => (a: string): Parsed => { if (a === '') return { ok: true, value: [] }; const picks = a.split(/[,\s]+/).filter(Boolean); const numbers = picks.map((p) => (/^\d+$/.test(p) ? Number(p) : NaN)); if (numbers.some((n) => !Number.isInteger(n) || n < 1 || n > count)) { return { ok: false, error: `Type numbers from 1 to ${count} separated by commas, for example 1,3. Press Enter for none.` }; } return { ok: true, value: [...new Set(numbers)] }; }; const parsePassword = (a: string): Parsed => { try { return { ok: true, value: cleanPassword(a) }; } catch (e) { if (e instanceof RuleError) return { ok: false, error: `The ${e.message}.` }; throw e; } }; async function askPasswordHash(io: Io): Promise { const ask = (q: string) => (io.askExact ? io.askExact(q) : io.ask(q)); for (let tries = 0; tries < MAX_TRIES; tries++) { const password = await askUntil({ ...io, ask }, 'Password (at least 8 characters): ', parsePassword); if ((await ask('Type the password again: ')) === password) return hashPassword(password); io.say(' The two passwords are different. Try again.'); } throw new Cancelled('too many invalid answers'); } export async function confirm(io: Io, question: string): Promise { const a = (await io.ask(question)).toLowerCase(); return a === 'y' || a === 'yes'; } interface ShopChoice { id: number; name: string; code: string; } function activeShops(db: Db): ShopChoice[] { return db.prepare('SELECT id, name, code FROM shops WHERE active = 1 ORDER BY name, id').all() as ShopChoice[]; } export async function createWizard(db: Db, io: Io, defaultCountryCode = '91'): Promise { try { io.say('Create an account'); io.say(' 1) Admin - sees everything and manages users. Cannot be changed from the website.'); io.say(' 2) User - sees and adds data to the shops you pick.'); io.say(' (type q at any question to quit)'); io.say(); const kind = await askUntil(io, 'What do you want to create? [1/2]: ', parseKind); if (kind === 'admin') { const username = await askUntil(io, 'Admin username: ', parseUsername); const phone = await askUntil(io, 'Admin phone number (with country code): ', parseRequiredPhone(defaultCountryCode)); const passwordHash = await askPasswordHash(io); createAdminAccount(db, { username, phone, passwordHash }, defaultCountryCode); io.say(`Created admin "${username}". Admins now: ${countAdmins(db)}.`); return 0; } const username = await askUntil(io, 'Username: ', parseUsername); const phone = await askUntil(io, 'Phone number with country code (Enter to skip): ', parseOptionalPhone(defaultCountryCode)); const shops = activeShops(db); let shopIds: number[] = []; if (shops.length === 0) { io.say('There are no shops yet, so no shops can be assigned. Add shops first, then assign them to this user.'); } else { io.say('Shops this user can see and add data to:'); shops.forEach((s, i) => io.say(` ${i + 1}) ${s.name} (${s.code})`)); const picks = await askUntil(io, 'Type the numbers separated by commas (Enter for none): ', parseShopNumbers(shops.length)); shopIds = picks.map((n) => shops[n - 1]!.id); } const passwordHash = await askPasswordHash(io); createUserAccount(db, { username, phone, shopIds, passwordHash }, defaultCountryCode); io.say(`Created user "${username}".`); return 0; } catch (e) { if (e instanceof Cancelled) { io.say(e.message === 'cancelled' ? 'Cancelled. Nothing was created.' : `Stopped: ${e.message}. Nothing was created.`); return 0; } if (e instanceof RuleError) { io.say(`Could not create it: ${e.message}.`); return 1; } throw e; } } export async function passwordWizard(db: Db, io: Io): Promise { try { io.say('Reset a password'); io.say(' Works for admins and users. (type q at any question to quit)'); io.say(); const parseAccount = (a: string): Parsed => { const user = a.trim() === '' ? undefined : findUserByUsername(db, a.trim()); return user ? { ok: true, value: user } : { ok: false, error: 'No account with that username. See them with: npm run user list' }; }; const user = await askUntil(io, 'Username: ', parseAccount); const role = user.isAdmin ? 'ADMIN' : 'USER'; io.say(` ${role} "${user.username}"${user.active ? '' : ' (switched off: it stays switched off)'}`); const passwordHash = await askPasswordHash(io); setAccountPassword(db, user.id, passwordHash); io.say(`Password changed for "${user.username}". They can sign in with it now.`); return 0; } catch (e) { if (e instanceof Cancelled) { io.say(e.message === 'cancelled' ? 'Cancelled. The password was not changed.' : `Stopped: ${e.message}. The password was not changed.`); return 0; } if (e instanceof RuleError) { io.say(`Could not change it: ${e.message}.`); return 1; } throw e; } } export function listLines(db: Db): string[] { const users = listUsers(db); if (users.length === 0) return ['No accounts yet. Run: npm run user create']; const shopsOf = db.prepare( `SELECT s.name FROM user_shops us JOIN shops s ON s.id = us.shop_id WHERE us.user_id = ? ORDER BY s.name`, ); const lines = ['ROLE STATUS USERNAME PHONE SHOPS']; for (const u of users) { const shops = u.isAdmin ? 'all shops' : (shopsOf.all(u.id) as Array<{ name: string }>).map((r) => r.name).join(', ') || '(none)'; lines.push( [ (u.isAdmin ? 'admin' : 'user').padEnd(6), (u.active ? 'active' : 'disabled').padEnd(9), u.username.padEnd(20), (u.phone ?? '-').padEnd(15), shops, ].join(' '), ); } return lines; }