From a05bc3c48e0cc039768c72b8fbf8bcb73212e913 Mon Sep 17 00:00:00 2001 From: HotelSync Date: Mon, 20 Apr 2026 20:11:53 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20staff=20invite=20flow=20=E2=80=94=20ema?= =?UTF-8?q?il=20invitation=20instead=20of=20manual=20password?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- backend/migrations/083_user_invite.sql | 3 + backend/src/email.ts | 51 ++++++++++ backend/src/routes/auth.ts | 77 ++++++++++++++ backend/src/routes/users.ts | 51 ++++++++-- src/App.tsx | 2 + src/contexts/AuthContext.tsx | 9 +- src/lib/api.ts | 4 + src/pages/AcceptInvitePage.tsx | 136 +++++++++++++++++++++++++ src/pages/UsersPage.tsx | 11 +- 9 files changed, 331 insertions(+), 13 deletions(-) create mode 100644 backend/migrations/083_user_invite.sql create mode 100644 src/pages/AcceptInvitePage.tsx diff --git a/backend/migrations/083_user_invite.sql b/backend/migrations/083_user_invite.sql new file mode 100644 index 0000000..12525e1 --- /dev/null +++ b/backend/migrations/083_user_invite.sql @@ -0,0 +1,3 @@ +-- Staff invite tokens +ALTER TABLE users ADD COLUMN IF NOT EXISTS invite_token VARCHAR(64); +ALTER TABLE users ADD COLUMN IF NOT EXISTS invite_expires TIMESTAMPTZ; diff --git a/backend/src/email.ts b/backend/src/email.ts index 481612d..47eba03 100644 --- a/backend/src/email.ts +++ b/backend/src/email.ts @@ -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 { + 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', + `

Вас приглашают в HotelSync!

+

Привет, ${firstName}!

+

+ ${invitedBy} добавил вас в команду отеля ${hotelName} в системе HotelSync. + Нажмите на кнопку ниже, чтобы задать пароль и начать работу. +

+
+ + Принять приглашение + +
+

+ Ссылка действительна 7 дней.
+ Если вы не ожидали это письмо — просто проигнорируйте его. +

+

+ Кнопка не работает? Скопируйте ссылку в браузер:
+ ${inviteUrl} +

`, + ) + + 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}` diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 6aa6ad1..f8cf671 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -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', diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index e336dbf..bcdfeb4 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -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( '/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 + + 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' }) diff --git a/src/App.tsx b/src/App.tsx index 8af23c3..83c2ed9 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -37,6 +37,7 @@ import { DiscountsPage } from './pages/DiscountsPage' import { GuestReviewPage } from './pages/GuestReviewPage' import { GuestRoomServicePage } from './pages/GuestRoomServicePage' import { ResetPasswordPage } from './pages/ResetPasswordPage' +import { AcceptInvitePage } from './pages/AcceptInvitePage' import { TvWelcomePage } from './pages/TvWelcomePage' import { TechnicalPage } from './pages/TechnicalPage' import { SchedulePage } from './pages/SchedulePage' @@ -86,6 +87,7 @@ export default function App() { {/* Public */} } /> } /> + } /> } /> } /> } /> diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 4dd931c..3b44707 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -8,6 +8,7 @@ interface AuthContextValue { session: AuthSession | null user: User | null login: (email: string, password: string) => Promise + loginWithToken: (token: string, user: User) => void logout: () => void } @@ -91,6 +92,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } } + const loginWithToken = (token: string, user: User) => { + const s: AuthSession = { user, token } + setSession(s) + localStorage.setItem('hotelsync-session', JSON.stringify(s)) + } + const logout = async () => { try { await api.auth.logout() } catch { /* ignore */ } setSession(null) @@ -98,7 +105,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } return ( - + {children} ) diff --git a/src/lib/api.ts b/src/lib/api.ts index b59259c..33234ac 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -201,6 +201,10 @@ export const api = { resetPassword: (token: string, password: string) => req<{ ok: boolean }>('POST', '/api/auth/reset-password', { token, password }), + acceptInvite: (token: string, password: string) => + req<{ access_token: string; user: import('../types').User }>( + 'POST', '/api/auth/accept-invite', { token, password }), + resendConfirmation: (email: string) => req<{ ok: boolean }>('POST', '/api/auth/resend-confirmation', { email }), }, diff --git a/src/pages/AcceptInvitePage.tsx b/src/pages/AcceptInvitePage.tsx new file mode 100644 index 0000000..24c5c32 --- /dev/null +++ b/src/pages/AcceptInvitePage.tsx @@ -0,0 +1,136 @@ +import { useState } from 'react' +import { useParams, useNavigate } from 'react-router-dom' +import { Hotel, Eye, EyeOff, AlertCircle, CheckCircle2, Moon, Sun } from 'lucide-react' +import { useTheme } from '../contexts/ThemeContext' +import { useAuth } from '../contexts/AuthContext' +import { api } from '../lib/api' +import { cn } from '../lib/utils' + +export function AcceptInvitePage() { + const { token } = useParams<{ token: string }>() + const navigate = useNavigate() + const { theme, toggle } = useTheme() + const { loginWithToken } = useAuth() + + const [password, setPassword] = useState('') + const [password2, setPassword2] = useState('') + const [showPass, setShowPass] = useState(false) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [done, setDone] = useState(false) + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (password.length < 6) { setError('Минимум 6 символов'); return } + if (password !== password2) { setError('Пароли не совпадают'); return } + setError('') + setLoading(true) + try { + const data = await api.auth.acceptInvite(token ?? '', password) + loginWithToken(data.access_token, data.user) + setDone(true) + setTimeout(() => navigate('/'), 1500) + } catch (err) { + const msg = err instanceof Error ? err.message : 'Ошибка' + setError(msg) + } finally { + setLoading(false) + } + } + + return ( +
+
+ +
+ +
+
+ {/* Logo */} +
+
+ +
+ + HotelSync + +
+ + {!token ? ( +
+
+ Ссылка недействительна. +
+ +
+ ) : done ? ( +
+
+ +
+

Добро пожаловать!

+

Вход выполнен, переходим в систему…

+
+ ) : ( + <> +

Принять приглашение

+

Задайте пароль для входа в HotelSync

+ +
+
+ +
+ { setPassword(e.target.value); setError('') }} + required + autoFocus + /> + +
+
+ +
+ + { setPassword2(e.target.value); setError('') }} + required + /> + {password2 && password !== password2 && ( +

Пароли не совпадают

+ )} +
+ + {error && ( +
+ {error} +
+ )} + + +
+ + )} +
+
+
+ ) +} diff --git a/src/pages/UsersPage.tsx b/src/pages/UsersPage.tsx index ee6836d..a962dc8 100644 --- a/src/pages/UsersPage.tsx +++ b/src/pages/UsersPage.tsx @@ -308,7 +308,7 @@ function UserModal({ if (!form.lastName.trim()) e.lastName = 'Введите фамилию' if (!form.email.trim()) e.email = 'Введите email' if (!form.position.trim()) e.position = 'Введите должность' - if (isNew && password.length < 6) e.password = 'Минимум 6 символов' + if (isNew && password.length > 0 && password.length < 6) e.password = 'Минимум 6 символов' setErrors(e) return Object.keys(e).length === 0 } @@ -477,13 +477,18 @@ function UserModal({ {/* Password */}
+ {isNew && ( +

+ Оставьте пустым — сотрудник получит приглашение на email и сам задаст пароль +

+ )}
setPassword(e.target.value)} />