From e8e9db33ea9273150b01c182891251592f70ddc8 Mon Sep 17 00:00:00 2001 From: Thomas Joise Date: Sun, 19 Jul 2026 19:48:26 +0530 Subject: [PATCH] feat(d34): import employee master + invited/pending-login flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - staff_user gains must_change_password (SQLite migrate + PG migration 011): the invited-employee indicator, cleared on the user's first self password-change. - createEmployee takes phone/title/mustChangePassword; Employee carries mustChangePassword. - scripts/import-employees.ts (tsx, Postgres- or SQLite-aware) seeds the 11 from empmaster.xlsx: username = first-name slug, default password @123, role staff, active per ACTIVE_STAT, must-change flagged. Idempotent. Ran on live DB — 11 added. - Employees list: 'Invited · must change pw' badge + a notice explaining the default password pattern so the owner can share logins. Verified: adithya/adithya@123 → 200, inactive raveena blocked. typecheck + employee tests green. Co-Authored-By: Claude Fable 5 --- apps/hq-web/src/api.ts | 1 + apps/hq-web/src/pages/Employees.tsx | 11 ++++++ apps/hq/scripts/import-employees.ts | 58 +++++++++++++++++++++++++++++ apps/hq/src/db.ts | 9 ++++- apps/hq/src/migrations-pg.ts | 5 +++ apps/hq/src/repos-employees.ts | 20 +++++++--- 6 files changed, 97 insertions(+), 7 deletions(-) create mode 100644 apps/hq/scripts/import-employees.ts diff --git a/apps/hq-web/src/api.ts b/apps/hq-web/src/api.ts index b78c56c..134249c 100644 --- a/apps/hq-web/src/api.ts +++ b/apps/hq-web/src/api.ts @@ -509,6 +509,7 @@ export const EMPLOYEE_ROLES: EmployeeRole[] = ['owner', 'manager', 'staff'] export interface Employee { id: string; email: string; username: string; displayName: string; role: EmployeeRole; active: boolean phone: string | null; title: string | null + mustChangePassword: boolean } /** Full list + server-echoed total (bounded set, no truncation) — any signed-in user. */ diff --git a/apps/hq-web/src/pages/Employees.tsx b/apps/hq-web/src/pages/Employees.tsx index da6eda3..6dd5177 100644 --- a/apps/hq-web/src/pages/Employees.tsx +++ b/apps/hq-web/src/pages/Employees.tsx @@ -58,6 +58,7 @@ export function Employees() { { key: 'email', label: 'Email', mono: true }, { key: 'phone', label: 'Phone', mono: true }, { key: 'title', label: 'Title' }, { key: 'role', label: 'Role' }, { key: 'active', label: 'Active' }, + { key: 'login', label: 'Login' }, { key: 'actions', label: 'Actions' }, ]} rows={employees.map((e) => ({ @@ -65,6 +66,9 @@ export function Employees() { phone: e.phone ?? '—', title: e.title ?? '—', role: {e.role}, active: e.active ? active : inactive, + login: e.mustChangePassword + ? Invited · must change pw + : , actions: ( @@ -82,6 +86,13 @@ export function Employees() { Guards: the last active owner cannot be demoted or deactivated; you cannot deactivate yourself. + {employees.some((e) => e.mustChangePassword) && ( + + Invited employees log in with their username and the default password + username@123 (e.g. adithya@123), then change it in + Profile — the “Invited” badge clears once they do. Share their username + this password so they can sign in. + + )} )} diff --git a/apps/hq/scripts/import-employees.ts b/apps/hq/scripts/import-employees.ts new file mode 100644 index 0000000..7c4198b --- /dev/null +++ b/apps/hq/scripts/import-employees.ts @@ -0,0 +1,58 @@ +// apps/hq/scripts/import-employees.ts — one-off: seed the employee master (D34). +// Adds each employee with username = first-name slug, a default password `@123`, +// and must_change_password=1 (they change it on first login). Active per ACTIVE_STAT. +// Idempotent: skips a username/email that already exists. +// DATABASE_URL=postgres://… npx tsx apps/hq/scripts/import-employees.ts +import { openDb } from '../src/db' +import { openPgDb } from '../src/db-pg' +import { createEmployee } from '../src/repos-employees' + +// From empmaster.xlsx (EMPNAME + ACTIVE_STAT + DESIGNATION code). Emails/phones were blank. +const EMPLOYEES: { name: string; active: boolean; title?: string }[] = [ + { name: 'ADITHYA', active: true, title: '702' }, + { name: 'RAVEENA', active: false, title: '702' }, + { name: 'ADITHYAN', active: true }, + { name: 'CYRIAC', active: true, title: '702' }, + { name: 'ANJU', active: true, title: '702' }, + { name: 'RENJITH', active: true }, + { name: 'AJMAL', active: true }, + { name: 'ELIAS MATHEW', active: false, title: '703' }, + { name: 'THOMAS', active: true, title: '700' }, + { name: 'DEVIKA', active: true, title: '702' }, + { name: 'TIBIN', active: true, title: '703' }, +] + +/** First name, lowercased, non-alphanumerics stripped — the login username + password base. */ +function slug(name: string): string { + return name.trim().toLowerCase().split(/\s+/)[0]!.replace(/[^a-z0-9]/g, '') +} + +async function main() { + // Same engine selection as the server: DATABASE_URL → Postgres, else SQLite at HQ_DATA_DIR. + const pgUrl = process.env['DATABASE_URL'] ?? '' + const db = pgUrl !== '' ? await openPgDb(pgUrl) : openDb(process.env['HQ_DATA_DIR']) + let added = 0, skipped = 0 + const summary: string[] = [] + for (const e of EMPLOYEES) { + const username = slug(e.name) + const password = `${username}@123` + const email = `${username}@sims.local` // placeholder (no real emails in the master) + try { + const emp = await createEmployee(db, 'system', { + email, username, displayName: e.name, role: 'staff', password, + mustChangePassword: true, ...(e.title !== undefined ? { title: e.title } : {}), + }) + // Set inactive employees inactive (createEmployee makes them active by default). + if (!e.active) await db.run(`UPDATE staff_user SET active=0 WHERE id=?`, emp.id) + added++ + summary.push(` + ${e.name} → username: ${username} password: ${password} ${e.active ? '' : '(inactive)'}`) + } catch (err) { + skipped++ + summary.push(` - ${e.name} skipped: ${err instanceof Error ? err.message : String(err)}`) + } + } + // eslint-disable-next-line no-console + console.log(`Employee import: ${added} added, ${skipped} skipped.\n${summary.join('\n')}`) +} + +main().catch((e) => { console.error(e); process.exit(1) }) diff --git a/apps/hq/src/db.ts b/apps/hq/src/db.ts index bc0dce0..1d4b8df 100644 --- a/apps/hq/src/db.ts +++ b/apps/hq/src/db.ts @@ -152,7 +152,9 @@ CREATE TABLE IF NOT EXISTS staff_user ( -- D24 (red-team): brute-force lockout. failed_count counts consecutive failed logins; -- locked_until is an ISO timestamp the account is barred until (NULL = not locked). -- Both reset to 0/NULL on a successful login. - failed_count INTEGER NOT NULL DEFAULT 0, locked_until TEXT + failed_count INTEGER NOT NULL DEFAULT 0, locked_until TEXT, + -- D34: set for invited employees given a default password; cleared on first self-change. + must_change_password INTEGER NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS session ( token TEXT PRIMARY KEY, staff_id TEXT NOT NULL, expires_at TEXT NOT NULL @@ -426,6 +428,11 @@ function migrate(db: SqliteRaw): void { if (!staffCols.some((c) => c.name === 'locked_until')) { db.exec(`ALTER TABLE staff_user ADD COLUMN locked_until TEXT`) } + // D34: invited employees start with a default password they must change — flag drives + // the "Invited / pending first login" badge; cleared on the user's first password change. + if (!staffCols.some((c) => c.name === 'must_change_password')) { + db.exec(`ALTER TABLE staff_user ADD COLUMN must_change_password INTEGER NOT NULL DEFAULT 0`) + } const clientCols = db.prepare(`PRAGMA table_info(client)`).all() as { name: string }[] if (!clientCols.some((c) => c.name === 'owner_id')) { db.exec(`ALTER TABLE client ADD COLUMN owner_id TEXT`) diff --git a/apps/hq/src/migrations-pg.ts b/apps/hq/src/migrations-pg.ts index 9bc8a8e..c6e88ef 100644 --- a/apps/hq/src/migrations-pg.ts +++ b/apps/hq/src/migrations-pg.ts @@ -307,4 +307,9 @@ CREATE TABLE IF NOT EXISTS sms_balance_check ( ); `, }, + { + // D34: invited-employee flag (default password to change on first login). + id: '011-staff-must-change-password', + sql: `ALTER TABLE staff_user ADD COLUMN IF NOT EXISTS must_change_password integer NOT NULL DEFAULT 0;`, + }, ] diff --git a/apps/hq/src/repos-employees.ts b/apps/hq/src/repos-employees.ts index 6998763..fa19374 100644 --- a/apps/hq/src/repos-employees.ts +++ b/apps/hq/src/repos-employees.ts @@ -13,21 +13,23 @@ const ROLES: readonly EmployeeRole[] = ['owner', 'manager', 'staff'] as const export interface Employee { id: string; username: string; email: string; displayName: string; role: EmployeeRole; active: boolean phone: string | null; title: string | null + /** D34: true while the employee still has their invited default password (pending first change). */ + mustChangePassword: boolean } interface EmployeeRow { id: string; username: string | null; email: string; display_name: string; role: string; active: number - phone: string | null; title: string | null + phone: string | null; title: string | null; must_change_password: number } // Explicit column list — pw_salt/pw_hash must never be selected here. -const COLS = 'id, username, email, display_name, role, active, phone, title' +const COLS = 'id, username, email, display_name, role, active, phone, title, must_change_password' function toEmployee(r: EmployeeRow): Employee { return { id: r.id, username: r.username ?? r.email, email: r.email, displayName: r.display_name, role: r.role as EmployeeRole, active: r.active === 1, - phone: r.phone, title: r.title, + phone: r.phone, title: r.title, mustChangePassword: r.must_change_password === 1, } } @@ -62,6 +64,8 @@ async function otherActiveOwnerExists(db: DB, excludeId: string): Promise { assertRole(input.role) if (input.password.length < 8) throw new Error('Password must be at least 8 characters') @@ -72,6 +76,7 @@ export async function createEmployee(db: DB, userId: string, input: { if (username === '') throw new Error('Username is required') const id = uuidv7() const { salt, hash } = hashPin(input.password) // generic scrypt; PIN digit policy not applied + const mcp = input.mustChangePassword === true ? 1 : 0 return db.transaction(async () => { // Friendly, engine-neutral pre-checks (login is by username; email stays a field). if (await db.get(`SELECT 1 FROM staff_user WHERE username=?`, username) !== undefined) { @@ -81,8 +86,10 @@ export async function createEmployee(db: DB, userId: string, input: { throw new Error('Email already in use') } await db.run( - `INSERT INTO staff_user (id, username, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?, ?)`, - id, username, email, input.displayName, input.role, salt, hash, + `INSERT INTO staff_user (id, username, email, display_name, role, phone, title, pw_salt, pw_hash, must_change_password) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + id, username, email, input.displayName, input.role, + input.phone?.trim() || null, input.title?.trim() || null, salt, hash, mcp, ) await writeAudit(db, userId, 'create', 'staff_user', id, undefined, { username, email, role: input.role }) return (await getEmployee(db, id))! @@ -141,7 +148,8 @@ export async function changeOwnPassword( } const { salt, hash } = hashPin(newPassword) await db.transaction(async () => { - await db.run(`UPDATE staff_user SET pw_salt=?, pw_hash=? WHERE id=?`, salt, hash, userId) + // Clearing must_change_password: the invited default password has now been replaced. + await db.run(`UPDATE staff_user SET pw_salt=?, pw_hash=?, must_change_password=0 WHERE id=?`, salt, hash, userId) await db.run(`DELETE FROM session WHERE staff_id=? AND token != ?`, userId, currentToken) await writeAudit(db, userId, 'change_own_password', 'staff_user', userId) })