feat: POS PIN unlock with employee number + PIN auth
- Add employeeNumber and pinHash fields to users table - POST /auth/pin-login: takes combined code (4-digit employee# + 4-digit PIN) - POST /auth/set-pin: employee sets their own PIN (requires full auth) - DELETE /auth/pin: remove PIN - Lock screen with numpad, auto-submits on 8 digits, visual dot separator - POS uses its own auth token separate from admin session - Admin "POS" link clears admin session before navigating - /pos route has no auth guard — lock screen is the auth - API client uses POS token when available, admin token otherwise - Auto-lock timer reads pos_lock_timeout from app_config (default 15 min) - Lock button in POS top bar, shows current cashier name Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
16
packages/backend/src/db/migrations/0042_user-pin.sql
Normal file
16
packages/backend/src/db/migrations/0042_user-pin.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "pin_hash" varchar(255);
|
||||
ALTER TABLE "user" ADD COLUMN IF NOT EXISTS "employee_number" varchar(20) UNIQUE;
|
||||
|
||||
-- Auto-assign employee numbers to existing users
|
||||
DO $$ DECLARE r RECORD; num INT := 1001;
|
||||
BEGIN
|
||||
FOR r IN (SELECT id FROM "user" WHERE employee_number IS NULL ORDER BY created_at) LOOP
|
||||
UPDATE "user" SET employee_number = num::text WHERE id = r.id;
|
||||
num := num + 1;
|
||||
END LOOP;
|
||||
END $$;
|
||||
|
||||
-- Seed POS lock timeout config
|
||||
INSERT INTO "app_config" ("key", "value", "description")
|
||||
VALUES ('pos_lock_timeout', '15', 'POS auto-lock timeout in minutes (0 to disable)')
|
||||
ON CONFLICT ("key") DO NOTHING;
|
||||
@@ -302,6 +302,13 @@
|
||||
"when": 1775590000000,
|
||||
"tag": "0042_drawer-adjustments",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 42,
|
||||
"version": "7",
|
||||
"when": 1775590000000,
|
||||
"tag": "0042_user-pin",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,6 +15,8 @@ export const users = pgTable('user', {
|
||||
firstName: varchar('first_name', { length: 100 }).notNull(),
|
||||
lastName: varchar('last_name', { length: 100 }).notNull(),
|
||||
role: userRoleEnum('role').notNull().default('staff'),
|
||||
employeeNumber: varchar('employee_number', { length: 20 }).unique(),
|
||||
pinHash: varchar('pin_hash', { length: 255 }),
|
||||
isActive: boolean('is_active').notNull().default(true),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { FastifyPluginAsync } from 'fastify'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import { RegisterSchema, LoginSchema } from '@lunarfront/shared/schemas'
|
||||
import { RegisterSchema, LoginSchema, PinLoginSchema, SetPinSchema } from '@lunarfront/shared/schemas'
|
||||
import { users } from '../../db/schema/users.js'
|
||||
|
||||
const SALT_ROUNDS = 10
|
||||
@@ -226,4 +226,71 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
|
||||
|
||||
return reply.send(user)
|
||||
})
|
||||
|
||||
// PIN login — for POS unlock, no JWT required to call
|
||||
app.post('/auth/pin-login', rateLimitConfig, async (request, reply) => {
|
||||
const parsed = PinLoginSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
||||
}
|
||||
|
||||
const { code } = parsed.data
|
||||
// First 4 digits = employee number, rest = PIN
|
||||
const employeeNumber = code.slice(0, 4)
|
||||
const pin = code.slice(4)
|
||||
|
||||
if (!pin) {
|
||||
return reply.status(401).send({ error: { message: 'Invalid code', statusCode: 401 } })
|
||||
}
|
||||
|
||||
const [user] = await app.db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.employeeNumber, employeeNumber))
|
||||
.limit(1)
|
||||
|
||||
if (!user || !user.isActive || !user.pinHash) {
|
||||
return reply.status(401).send({ error: { message: 'Invalid code', statusCode: 401 } })
|
||||
}
|
||||
|
||||
const match = await bcrypt.compare(pin, user.pinHash)
|
||||
if (!match) {
|
||||
return reply.status(401).send({ error: { message: 'Invalid code', statusCode: 401 } })
|
||||
}
|
||||
|
||||
const token = app.jwt.sign({ id: user.id, role: user.role }, { expiresIn: '8h' })
|
||||
request.log.info({ userId: user.id }, 'PIN login')
|
||||
return reply.send({
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
role: user.role,
|
||||
},
|
||||
token,
|
||||
})
|
||||
})
|
||||
|
||||
// Set PIN — requires full auth
|
||||
app.post('/auth/set-pin', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||
const parsed = SetPinSchema.safeParse(request.body)
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: { message: 'Validation failed', details: parsed.error.flatten(), statusCode: 400 } })
|
||||
}
|
||||
|
||||
const pinHash = await bcrypt.hash(parsed.data.pin, SALT_ROUNDS)
|
||||
await app.db.update(users).set({ pinHash, updatedAt: new Date() }).where(eq(users.id, request.user.id))
|
||||
|
||||
request.log.info({ userId: request.user.id }, 'POS PIN set')
|
||||
return reply.send({ message: 'PIN set' })
|
||||
})
|
||||
|
||||
// Remove PIN — requires full auth
|
||||
app.delete('/auth/pin', { preHandler: [app.authenticate] }, async (request, reply) => {
|
||||
await app.db.update(users).set({ pinHash: null, updatedAt: new Date() }).where(eq(users.id, request.user.id))
|
||||
|
||||
request.log.info({ userId: request.user.id }, 'POS PIN removed')
|
||||
return reply.send({ message: 'PIN removed' })
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user