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:
ryan
2026-04-04 20:59:09 +00:00
parent 6505b2dcb9
commit cf299ac1d2
13 changed files with 396 additions and 39 deletions

View File

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