217 lines
8.1 KiB
TypeScript
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;
|
|
}
|