feat: connect UsersPage and SettingsPage SMTP to API + extend user roles/fields
- Migration 034: extend users table with phone, position, active columns; add new roles (receptionist, accountant, security) - Backend users.ts: accept phone/position on create, accept phone/position/active/role on update; managers can now assign all non-admin roles - types/index.ts + api.ts: add phone, position, active to User type and users create/update payload types - UsersPage: fix mapRole to handle all 5 roles, fix toStaffUser to use real phone/position/active from API, fix handleSave to pass all fields - SettingsPage: load SMTP settings from hotelSettings API on mount, save SMTP on handleSave when section === 'notify' Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
9
backend/migrations/034_users_extend.sql
Normal file
9
backend/migrations/034_users_extend.sql
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
-- Extend role constraint to support more roles
|
||||||
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
|
||||||
|
ALTER TABLE users ADD CONSTRAINT users_role_check
|
||||||
|
CHECK (role IN ('manager', 'housekeeper', 'super_admin', 'receptionist', 'accountant', 'security'));
|
||||||
|
|
||||||
|
-- Add new columns
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS phone VARCHAR(50);
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS position VARCHAR(100);
|
||||||
|
ALTER TABLE users ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT true;
|
||||||
@@ -14,7 +14,7 @@ const users: FastifyPluginAsync = async (fastify) => {
|
|||||||
const canAccess = (userSlug: string | null, role: string, slug: string) =>
|
const canAccess = (userSlug: string | null, role: string, slug: string) =>
|
||||||
role === 'super_admin' || userSlug === slug
|
role === 'super_admin' || userSlug === slug
|
||||||
|
|
||||||
const USER_FIELDS = 'id, hotel_id, email, name, role, created_at, updated_at'
|
const USER_FIELDS = 'id, hotel_id, email, name, role, phone, position, active, created_at, updated_at'
|
||||||
|
|
||||||
// ── GET /api/hotels/:slug/users ────────────────────────────────────────────
|
// ── GET /api/hotels/:slug/users ────────────────────────────────────────────
|
||||||
fastify.get<SlugParam>(
|
fastify.get<SlugParam>(
|
||||||
@@ -41,7 +41,7 @@ const users: FastifyPluginAsync = async (fastify) => {
|
|||||||
|
|
||||||
// ── POST /api/hotels/:slug/users ───────────────────────────────────────────
|
// ── POST /api/hotels/:slug/users ───────────────────────────────────────────
|
||||||
fastify.post<SlugParam & { Body: {
|
fastify.post<SlugParam & { Body: {
|
||||||
email: string; password: string; name: string; role?: string
|
email: string; password: string; name: string; role?: string; phone?: string; position?: string
|
||||||
} }>(
|
} }>(
|
||||||
'/api/hotels/:slug/users',
|
'/api/hotels/:slug/users',
|
||||||
{ onRequest: [fastify.authenticate] },
|
{ onRequest: [fastify.authenticate] },
|
||||||
@@ -56,20 +56,21 @@ const users: FastifyPluginAsync = async (fastify) => {
|
|||||||
const hotelId = await getHotelId(slug)
|
const hotelId = await getHotelId(slug)
|
||||||
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
if (!hotelId) return reply.code(404).send({ error: 'Hotel not found' })
|
||||||
|
|
||||||
const { email, password, name, role = 'housekeeper' } = request.body
|
const { email, password, name, role = 'housekeeper', phone, position } = request.body
|
||||||
|
|
||||||
// Managers cannot create other managers or super_admins
|
// Managers cannot create other managers or super_admins
|
||||||
if (request.user.role === 'manager' && role !== 'housekeeper') {
|
const managerAllowedRoles = ['housekeeper', 'receptionist', 'accountant', 'security']
|
||||||
return reply.code(403).send({ error: 'Managers can only create housekeepers' })
|
if (request.user.role === 'manager' && !managerAllowedRoles.includes(role)) {
|
||||||
|
return reply.code(403).send({ error: 'Managers cannot create managers or super_admins' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(password, 12)
|
const passwordHash = await bcrypt.hash(password, 12)
|
||||||
try {
|
try {
|
||||||
const { rows } = await db.query(
|
const { rows } = await db.query(
|
||||||
`INSERT INTO users (hotel_id, email, password_hash, name, role)
|
`INSERT INTO users (hotel_id, email, password_hash, name, role, phone, position)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
RETURNING ${USER_FIELDS}`,
|
RETURNING ${USER_FIELDS}`,
|
||||||
[hotelId, email.toLowerCase(), passwordHash, name, role],
|
[hotelId, email.toLowerCase(), passwordHash, name, role, phone ?? null, position ?? null],
|
||||||
)
|
)
|
||||||
return reply.code(201).send(rows[0])
|
return reply.code(201).send(rows[0])
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -84,6 +85,7 @@ const users: FastifyPluginAsync = async (fastify) => {
|
|||||||
// ── PATCH /api/hotels/:slug/users/:id ─────────────────────────────────────
|
// ── PATCH /api/hotels/:slug/users/:id ─────────────────────────────────────
|
||||||
fastify.patch<SlugIdParam & { Body: {
|
fastify.patch<SlugIdParam & { Body: {
|
||||||
name?: string; email?: string; role?: string; password?: string
|
name?: string; email?: string; role?: string; password?: string
|
||||||
|
phone?: string; position?: string; active?: boolean
|
||||||
} }>(
|
} }>(
|
||||||
'/api/hotels/:slug/users/:id',
|
'/api/hotels/:slug/users/:id',
|
||||||
{ onRequest: [fastify.authenticate] },
|
{ onRequest: [fastify.authenticate] },
|
||||||
@@ -108,13 +110,27 @@ const users: FastifyPluginAsync = async (fastify) => {
|
|||||||
if (request.body.email) {
|
if (request.body.email) {
|
||||||
updates.push(`email = $${idx}`); values.push(request.body.email.toLowerCase()); idx++
|
updates.push(`email = $${idx}`); values.push(request.body.email.toLowerCase()); idx++
|
||||||
}
|
}
|
||||||
if (request.body.role && request.user.role === 'super_admin') {
|
if (request.body.role) {
|
||||||
updates.push(`role = $${idx}`); values.push(request.body.role); idx++
|
// Managers can change role but not to manager/super_admin
|
||||||
|
const managerAllowedRoles = ['housekeeper', 'receptionist', 'accountant', 'security']
|
||||||
|
if (request.user.role === 'super_admin' ||
|
||||||
|
(request.user.role === 'manager' && managerAllowedRoles.includes(request.body.role))) {
|
||||||
|
updates.push(`role = $${idx}`); values.push(request.body.role); idx++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (request.body.password) {
|
if (request.body.password) {
|
||||||
const hash = await bcrypt.hash(request.body.password, 12)
|
const hash = await bcrypt.hash(request.body.password, 12)
|
||||||
updates.push(`password_hash = $${idx}`); values.push(hash); idx++
|
updates.push(`password_hash = $${idx}`); values.push(hash); idx++
|
||||||
}
|
}
|
||||||
|
if (request.body.phone !== undefined) {
|
||||||
|
updates.push(`phone = $${idx}`); values.push(request.body.phone || null); idx++
|
||||||
|
}
|
||||||
|
if (request.body.position !== undefined) {
|
||||||
|
updates.push(`position = $${idx}`); values.push(request.body.position || null); idx++
|
||||||
|
}
|
||||||
|
if (request.body.active !== undefined) {
|
||||||
|
updates.push(`active = $${idx}`); values.push(request.body.active); idx++
|
||||||
|
}
|
||||||
|
|
||||||
if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
|
if (updates.length === 0) return reply.code(400).send({ error: 'Nothing to update' })
|
||||||
updates.push(`updated_at = NOW()`)
|
updates.push(`updated_at = NOW()`)
|
||||||
|
|||||||
@@ -264,10 +264,10 @@ export const api = {
|
|||||||
list: (slug: string) =>
|
list: (slug: string) =>
|
||||||
req<User[]>('GET', `/api/hotels/${slug}/users`),
|
req<User[]>('GET', `/api/hotels/${slug}/users`),
|
||||||
|
|
||||||
create: (slug: string, data: { email: string; password: string; name: string; role: string }) =>
|
create: (slug: string, data: { email: string; password: string; name: string; role: string; phone?: string; position?: string }) =>
|
||||||
req<User>('POST', `/api/hotels/${slug}/users`, data),
|
req<User>('POST', `/api/hotels/${slug}/users`, data),
|
||||||
|
|
||||||
update: (slug: string, id: string, data: Partial<{ name: string; email: string; password: string }>) =>
|
update: (slug: string, id: string, data: Partial<{ name: string; email: string; password: string; role: string; phone: string; position: string; active: boolean }>) =>
|
||||||
req<User>('PATCH', `/api/hotels/${slug}/users/${id}`, data),
|
req<User>('PATCH', `/api/hotels/${slug}/users/${id}`, data),
|
||||||
|
|
||||||
delete: (slug: string, id: string) =>
|
delete: (slug: string, id: string) =>
|
||||||
|
|||||||
@@ -97,6 +97,14 @@ export function SettingsPage() {
|
|||||||
if (s.auto_cancel_noshow_hours) setAutoCancelNoShowHours(Number(s.auto_cancel_noshow_hours))
|
if (s.auto_cancel_noshow_hours) setAutoCancelNoShowHours(Number(s.auto_cancel_noshow_hours))
|
||||||
setAutoCheckout(Boolean(s.auto_checkout_enabled))
|
setAutoCheckout(Boolean(s.auto_checkout_enabled))
|
||||||
if (s.auto_checkout_hours) setAutoCheckoutHours(Number(s.auto_checkout_hours))
|
if (s.auto_checkout_hours) setAutoCheckoutHours(Number(s.auto_checkout_hours))
|
||||||
|
setSmtp({
|
||||||
|
host: String(s.smtp_host ?? ''),
|
||||||
|
port: String(s.smtp_port ?? '587'),
|
||||||
|
user: String(s.smtp_user ?? ''),
|
||||||
|
password: String(s.smtp_password ?? ''),
|
||||||
|
fromEmail: String(s.smtp_from_email ?? ''),
|
||||||
|
fromName: String(s.smtp_from_name ?? ''),
|
||||||
|
})
|
||||||
setHotelSettingsLoaded(true)
|
setHotelSettingsLoaded(true)
|
||||||
})
|
})
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
@@ -249,6 +257,20 @@ export function SettingsPage() {
|
|||||||
console.error('Failed to save hotel settings', err)
|
console.error('Failed to save hotel settings', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (section === 'notify') {
|
||||||
|
try {
|
||||||
|
await api.hotelSettings.update(slug, {
|
||||||
|
smtp_host: smtp.host,
|
||||||
|
smtp_port: smtp.port,
|
||||||
|
smtp_user: smtp.user,
|
||||||
|
smtp_password: smtp.password,
|
||||||
|
smtp_from_email: smtp.fromEmail,
|
||||||
|
smtp_from_name: smtp.fromName,
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to save SMTP settings', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
setSaved(true)
|
setSaved(true)
|
||||||
setTimeout(() => setSaved(false), 2000)
|
setTimeout(() => setSaved(false), 2000)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -174,7 +174,11 @@ const INITIAL_ROLE_PERMISSIONS: RolePermissions[] = [
|
|||||||
// ── Helpers ────────────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function mapRole(r: string): StaffRole {
|
function mapRole(r: string): StaffRole {
|
||||||
return r === 'housekeeper' ? 'housekeeper' : 'hotel_manager'
|
if (r === 'housekeeper') return 'housekeeper'
|
||||||
|
if (r === 'receptionist') return 'receptionist'
|
||||||
|
if (r === 'accountant') return 'accountant'
|
||||||
|
if (r === 'security') return 'security'
|
||||||
|
return 'hotel_manager'
|
||||||
}
|
}
|
||||||
|
|
||||||
function toStaffUser(u: User): StaffUser {
|
function toStaffUser(u: User): StaffUser {
|
||||||
@@ -188,9 +192,10 @@ function toStaffUser(u: User): StaffUser {
|
|||||||
firstName,
|
firstName,
|
||||||
lastName,
|
lastName,
|
||||||
email: u.email,
|
email: u.email,
|
||||||
|
phone: u.phone ?? '',
|
||||||
role,
|
role,
|
||||||
position: DEFAULT_POSITIONS[role]?.[0] ?? '',
|
position: u.position ?? DEFAULT_POSITIONS[role]?.[0] ?? '',
|
||||||
isActive: true,
|
isActive: u.active ?? true,
|
||||||
createdAt: u.createdAt?.slice(0, 10) ?? '',
|
createdAt: u.createdAt?.slice(0, 10) ?? '',
|
||||||
avatarColor: AVATAR_COLORS[colorIndex],
|
avatarColor: AVATAR_COLORS[colorIndex],
|
||||||
}
|
}
|
||||||
@@ -701,15 +706,23 @@ export function UsersPage() {
|
|||||||
|
|
||||||
const handleSave = async (u: StaffUser, password?: string) => {
|
const handleSave = async (u: StaffUser, password?: string) => {
|
||||||
const fullName = `${u.firstName} ${u.lastName}`.trim()
|
const fullName = `${u.firstName} ${u.lastName}`.trim()
|
||||||
const backendRole = u.role === 'housekeeper' ? 'housekeeper' : 'manager'
|
const backendRole = u.role === 'housekeeper' ? 'housekeeper'
|
||||||
|
: u.role === 'receptionist' ? 'receptionist'
|
||||||
|
: u.role === 'accountant' ? 'accountant'
|
||||||
|
: u.role === 'security' ? 'security'
|
||||||
|
: 'manager'
|
||||||
try {
|
try {
|
||||||
if (!u.id) {
|
if (!u.id) {
|
||||||
const created = await api.users.create(slug, {
|
const created = await api.users.create(slug, {
|
||||||
email: u.email, name: fullName, password: password ?? '', role: backendRole,
|
email: u.email, name: fullName, password: password ?? '', role: backendRole,
|
||||||
|
phone: u.phone || undefined, position: u.position || undefined,
|
||||||
})
|
})
|
||||||
setUsers(prev => [...prev, toStaffUser(created)])
|
setUsers(prev => [...prev, toStaffUser(created)])
|
||||||
} else {
|
} else {
|
||||||
const upd: Partial<{ name: string; email: string; password: string }> = { name: fullName, email: u.email }
|
const upd: Partial<{ name: string; email: string; password: string; role: string; phone: string; position: string; active: boolean }> = {
|
||||||
|
name: fullName, email: u.email, role: backendRole,
|
||||||
|
phone: u.phone || undefined, position: u.position || undefined, active: u.isActive,
|
||||||
|
}
|
||||||
if (password) upd.password = password
|
if (password) upd.password = password
|
||||||
const updated = await api.users.update(slug, u.id, upd)
|
const updated = await api.users.update(slug, u.id, upd)
|
||||||
setUsers(prev => prev.map(x => x.id === u.id ? { ...toStaffUser(updated), lastLogin: x.lastLogin } : x))
|
setUsers(prev => prev.map(x => x.id === u.id ? { ...toStaffUser(updated), lastLogin: x.lastLogin } : x))
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export interface User {
|
|||||||
hotelName?: string
|
hotelName?: string
|
||||||
hotelSlug?: string
|
hotelSlug?: string
|
||||||
avatarUrl?: string
|
avatarUrl?: string
|
||||||
|
phone?: string | null
|
||||||
|
position?: string | null
|
||||||
|
active?: boolean
|
||||||
createdAt?: string
|
createdAt?: string
|
||||||
updatedAt?: string
|
updatedAt?: string
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user