feat: staff invite flow — email invitation instead of manual password
- POST /api/hotels/:slug/users: password now optional - with password → create active user (email_confirmed=true) - without password → create inactive user, send invite email with 7-day token - POST /api/auth/accept-invite: validates token, sets password, activates account, returns JWT for auto-login - Migration 083: invite_token + invite_expires columns on users - email.ts: sendInviteEmail() with branded HTML template - AcceptInvitePage at /invite/:token — set password form, auto-login on success - AuthContext: loginWithToken() for programmatic session set - UserModal: password field optional for new users, hint about invite email Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -126,6 +126,57 @@ export async function sendConfirmationEmail(to: string, name: string, token: str
|
||||
})
|
||||
}
|
||||
|
||||
export async function sendInviteEmail(params: {
|
||||
to: string
|
||||
name: string
|
||||
hotelName: string
|
||||
invitedBy: string
|
||||
token: string
|
||||
}): Promise<void> {
|
||||
const { to, name, hotelName, invitedBy, token } = params
|
||||
const inviteUrl = `${appUrl()}/invite/${token}`
|
||||
const firstName = name.split(' ')[0] ?? name
|
||||
|
||||
const html = baseHtml(
|
||||
'linear-gradient(135deg,#059669,#10b981)',
|
||||
'🏨',
|
||||
'Приглашение в HotelSync',
|
||||
`<h2 style="color:#1e293b;font-size:20px;margin:0 0 12px;font-weight:600;">Вас приглашают в HotelSync!</h2>
|
||||
<p style="color:#334155;line-height:1.7;margin:0 0 8px;font-size:15px;">Привет, ${firstName}!</p>
|
||||
<p style="color:#334155;line-height:1.7;margin:0 0 24px;font-size:15px;">
|
||||
<strong>${invitedBy}</strong> добавил вас в команду отеля <strong>${hotelName}</strong> в системе HotelSync.
|
||||
Нажмите на кнопку ниже, чтобы задать пароль и начать работу.
|
||||
</p>
|
||||
<div style="text-align:center;margin:0 0 28px;">
|
||||
<a href="${inviteUrl}" style="display:inline-block;background:#059669;color:#ffffff;text-decoration:none;padding:14px 36px;border-radius:10px;font-weight:600;font-size:16px;letter-spacing:0.2px;">
|
||||
Принять приглашение
|
||||
</a>
|
||||
</div>
|
||||
<p style="color:#475569;font-size:13px;margin:0 0 16px;line-height:1.6;background:#f8fafc;padding:12px 16px;border-radius:8px;border-left:3px solid #e2e8f0;">
|
||||
Ссылка действительна 7 дней.<br>
|
||||
Если вы не ожидали это письмо — просто проигнорируйте его.
|
||||
</p>
|
||||
<p style="color:#475569;font-size:12px;margin:0;line-height:1.6;">
|
||||
Кнопка не работает? Скопируйте ссылку в браузер:<br>
|
||||
<a href="${inviteUrl}" style="color:#059669;word-break:break-all;font-size:11px;">${inviteUrl}</a>
|
||||
</p>`,
|
||||
)
|
||||
|
||||
const text = `Приглашение в HotelSync\n\nПривет, ${firstName}!\n\n${invitedBy} добавил вас в команду отеля ${hotelName}.\n\nПерейдите по ссылке чтобы задать пароль (ссылка действительна 7 дней):\n${inviteUrl}\n\n© 2026 HotelSync`
|
||||
|
||||
await transporter.sendMail({
|
||||
from: `"HotelSync" <${fromAddr()}>`,
|
||||
to,
|
||||
subject: `Приглашение в ${hotelName} — HotelSync`,
|
||||
html,
|
||||
text,
|
||||
headers: {
|
||||
'List-Unsubscribe': unsubscribeHeader,
|
||||
'Content-Language': 'ru',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function fmtDate(d: string): string {
|
||||
const [y, m, day] = d.slice(0, 10).split('-')
|
||||
return `${day}.${m}.${y}`
|
||||
|
||||
@@ -392,6 +392,83 @@ const auth: FastifyPluginAsync = async (fastify) => {
|
||||
},
|
||||
)
|
||||
|
||||
// ── POST /api/auth/accept-invite ──────────────────────────────────────────
|
||||
fastify.post<{ Body: { token: string; password: string } }>(
|
||||
'/api/auth/accept-invite',
|
||||
{
|
||||
schema: {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['token', 'password'],
|
||||
properties: {
|
||||
token: { type: 'string' },
|
||||
password: { type: 'string', minLength: 6 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { token, password } = request.body
|
||||
|
||||
const { rows } = await db.query(
|
||||
`SELECT u.*, h.slug AS hotel_slug
|
||||
FROM users u
|
||||
LEFT JOIN hotels h ON h.id = u.hotel_id
|
||||
WHERE u.invite_token = $1 AND u.invite_expires > NOW()`,
|
||||
[token],
|
||||
)
|
||||
if (rows.length === 0) {
|
||||
return reply.code(400).send({ error: 'Ссылка недействительна или истекла' })
|
||||
}
|
||||
const user = rows[0]
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12)
|
||||
await db.query(
|
||||
`UPDATE users
|
||||
SET password_hash = $1,
|
||||
email_confirmed = true,
|
||||
active = true,
|
||||
invite_token = NULL,
|
||||
invite_expires = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2`,
|
||||
[passwordHash, user.id],
|
||||
)
|
||||
|
||||
// Auto-login
|
||||
const payload: JwtPayload = {
|
||||
sub: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
hotelId: user.hotel_id ?? null,
|
||||
hotelSlug: user.hotel_slug ?? null,
|
||||
}
|
||||
const accessToken = fastify.jwt.sign(payload, { expiresIn: config.jwt.accessExpiry })
|
||||
const refreshToken = crypto.randomBytes(40).toString('hex')
|
||||
await redis.set(`refresh:${refreshToken}`, JSON.stringify(payload), 'EX', config.jwt.refreshExpiry)
|
||||
reply.setCookie('refresh_token', refreshToken, {
|
||||
httpOnly: true,
|
||||
secure: config.nodeEnv === 'production',
|
||||
sameSite: 'strict',
|
||||
path: '/api/auth',
|
||||
maxAge: config.jwt.refreshExpiry,
|
||||
})
|
||||
|
||||
return {
|
||||
access_token: accessToken,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
hotelId: user.hotel_id ?? null,
|
||||
hotelSlug: user.hotel_slug ?? null,
|
||||
},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// ── GET /api/auth/me ───────────────────────────────────────────────────────
|
||||
fastify.get(
|
||||
'/api/auth/me',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { FastifyPluginAsync } from 'fastify'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import crypto from 'crypto'
|
||||
import { db } from '../db'
|
||||
import { sendInviteEmail } from '../email'
|
||||
|
||||
type SlugParam = { Params: { slug: string } }
|
||||
type SlugIdParam = { Params: { slug: string; id: string } }
|
||||
@@ -41,7 +43,7 @@ const users: FastifyPluginAsync = async (fastify) => {
|
||||
|
||||
// ── POST /api/hotels/:slug/users ───────────────────────────────────────────
|
||||
fastify.post<SlugParam & { Body: {
|
||||
email: string; password: string; name: string; role?: string; phone?: string; position?: string
|
||||
email: string; password?: string; name: string; role?: string; phone?: string; position?: string
|
||||
} }>(
|
||||
'/api/hotels/:slug/users',
|
||||
{ onRequest: [fastify.authenticate] },
|
||||
@@ -69,15 +71,46 @@ const users: FastifyPluginAsync = async (fastify) => {
|
||||
return reply.code(403).send({ error: 'Недостаточно прав для создания этой роли' })
|
||||
}
|
||||
|
||||
const passwordHash = await bcrypt.hash(password, 12)
|
||||
try {
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO users (hotel_id, email, password_hash, name, role, phone, position)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||
RETURNING ${USER_FIELDS}`,
|
||||
[hotelId, email.toLowerCase(), passwordHash, name, role, phone ?? null, position ?? null],
|
||||
)
|
||||
return reply.code(201).send(rows[0])
|
||||
let row: Record<string, unknown>
|
||||
|
||||
if (password) {
|
||||
// Direct creation with password — mark email as confirmed immediately
|
||||
const passwordHash = await bcrypt.hash(password, 12)
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO users (hotel_id, email, password_hash, name, role, phone, position, email_confirmed)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, true)
|
||||
RETURNING ${USER_FIELDS}`,
|
||||
[hotelId, email.toLowerCase(), passwordHash, name, role, phone ?? null, position ?? null],
|
||||
)
|
||||
row = rows[0]
|
||||
} else {
|
||||
// Invite flow — create inactive user, send email with token
|
||||
const inviteToken = crypto.randomBytes(32).toString('hex')
|
||||
const inviteExpires = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
|
||||
const placeholderHash = await bcrypt.hash(crypto.randomBytes(16).toString('hex'), 12)
|
||||
|
||||
const { rows } = await db.query(
|
||||
`INSERT INTO users (hotel_id, email, password_hash, name, role, phone, position,
|
||||
email_confirmed, active, invite_token, invite_expires)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, false, false, $8, $9)
|
||||
RETURNING ${USER_FIELDS}`,
|
||||
[hotelId, email.toLowerCase(), placeholderHash, name, role,
|
||||
phone ?? null, position ?? null, inviteToken, inviteExpires],
|
||||
)
|
||||
row = rows[0]
|
||||
|
||||
// Look up hotel name and inviter name for the email
|
||||
const { rows: hotelRows } = await db.query('SELECT name FROM hotels WHERE id = $1', [hotelId])
|
||||
const { rows: inviterRows } = await db.query('SELECT name FROM users WHERE id = $1', [request.user.id])
|
||||
const hotelName = hotelRows[0]?.name ?? 'отель'
|
||||
const invitedBy = inviterRows[0]?.name ?? 'Администратор'
|
||||
|
||||
sendInviteEmail({ to: email.toLowerCase(), name, hotelName, invitedBy, token: inviteToken })
|
||||
.catch(err => console.error('Failed to send invite email', err))
|
||||
}
|
||||
|
||||
return reply.code(201).send(row)
|
||||
} catch (err: unknown) {
|
||||
if ((err as { code?: string }).code === '23505') {
|
||||
return reply.code(409).send({ error: 'Email already in use' })
|
||||
|
||||
Reference in New Issue
Block a user