feat(d34): import employee master + invited/pending-login flag

- 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 <username>@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 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 days ago
parent 6e3a79adb8
commit e8e9db33ea

@ -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. */

@ -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: <Badge tone={e.role === 'owner' ? 'accent' : undefined}>{e.role}</Badge>,
active: e.active ? <Badge tone="ok">active</Badge> : <Badge tone="err">inactive</Badge>,
login: e.mustChangePassword
? <Badge tone="warn" >Invited · must change pw</Badge>
: <span style={{ opacity: 0.5 }}></span>,
actions: (
<span style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<Button onClick={() => setDialog({ kind: 'edit', employee: e })}>Edit</Button>
@ -82,6 +86,13 @@ export function Employees() {
Guards: the last active owner cannot be demoted or deactivated; you cannot deactivate yourself.
</span>
</Toolbar>
{employees.some((e) => e.mustChangePassword) && (
<Notice tone="warn">
Invited employees log in with their <strong>username</strong> and the default password
<strong> username@123</strong> (e.g. <span className="mono">adithya@123</span>), then change it in
Profile the Invited badge clears once they do. Share their username + this password so they can sign in.
</Notice>
)}
</>
)}

@ -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 `<username>@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) })

@ -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`)

@ -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;`,
},
]

@ -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<boolea
export async function createEmployee(db: DB, userId: string, input: {
email: string; displayName: string; role: EmployeeRole; password: string
username?: string // D25: login identifier; defaults to email when not given
phone?: string; title?: string
mustChangePassword?: boolean // D34: invited with a default password to change
}): Promise<Employee> {
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)
})

Loading…
Cancel
Save