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)
})