srs-api/src/cli/userWizard.ts
Aswin Biju 61514fb82f
All checks were successful
deploy / test (push) Successful in 1m33s
deploy / deploy (push) Successful in 18s
feat(users): added a wizard to reset a password and supporting services for the srs PWA
2026-11-04 20:44:36 +05:30

217 lines
8.1 KiB
TypeScript

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<string>;
askExact?(question: string): Promise<string>;
say(line?: string): void;
}
export type Parsed<T> = { ok: true; value: T } | { ok: false; error: string };
const MAX_TRIES = 5;
export async function askUntil<T>(io: Io, question: string, parse: (answer: string) => Parsed<T>): Promise<T> {
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<string> =>
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<string> => {
const phone = normalizePhone(a, cc);
return phone ? { ok: true, value: phone } : { ok: false, error: phoneError };
};
const parseOptionalPhone =
(cc: string) =>
(a: string): Parsed<string | null> => {
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<number[]> => {
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<string> => {
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<string> {
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<boolean> {
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<number> {
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<number> {
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<User> => {
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;
}