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:
2026-04-20 20:11:53 +03:00
parent 153e1d72d6
commit a05bc3c48e
9 changed files with 331 additions and 13 deletions

View File

@@ -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 */}
<Route path="/login" element={<LoginPage />} />
<Route path="/reset-password" element={<ResetPasswordPage />} />
<Route path="/invite/:token" element={<AcceptInvitePage />} />
<Route path="/review/:slug" element={<GuestReviewPage />} />
<Route path="/room-service/:slug" element={<GuestRoomServicePage />} />
<Route path="/:slug/pay" element={<PayDepositPage />} />

View File

@@ -8,6 +8,7 @@ interface AuthContextValue {
session: AuthSession | null
user: User | null
login: (email: string, password: string) => Promise<User | null>
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 (
<AuthContext.Provider value={{ session, user: session?.user ?? null, login, logout }}>
<AuthContext.Provider value={{ session, user: session?.user ?? null, login, loginWithToken, logout }}>
{children}
</AuthContext.Provider>
)

View File

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

View File

@@ -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 (
<div className="min-h-screen bg-gradient-to-br from-brand-50 via-white to-slate-100 dark:from-slate-900 dark:via-slate-900 dark:to-brand-950 flex flex-col">
<div className="flex justify-end p-4">
<button onClick={toggle} className="btn-ghost p-2">
{theme === 'light' ? <Moon size={18} /> : <Sun size={18} />}
</button>
</div>
<div className="flex-1 flex items-center justify-center px-6">
<div className="w-full max-w-sm">
{/* Logo */}
<div className="flex items-center gap-2.5 mb-8 justify-center">
<div className="w-9 h-9 rounded-xl bg-brand-600 flex items-center justify-center">
<Hotel size={18} className="text-white" />
</div>
<span className="text-xl font-bold text-slate-900 dark:text-slate-100">
Hotel<span className="text-brand-600">Sync</span>
</span>
</div>
{!token ? (
<div className="text-center py-6">
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm mb-6">
<AlertCircle size={15} /> Ссылка недействительна.
</div>
<button onClick={() => navigate('/login')} className="btn-primary w-full justify-center py-2.5">
На страницу входа
</button>
</div>
) : done ? (
<div className="text-center py-6">
<div className="w-16 h-16 rounded-full bg-emerald-100 dark:bg-emerald-900/40 flex items-center justify-center mx-auto mb-5">
<CheckCircle2 size={32} className="text-emerald-600 dark:text-emerald-400" />
</div>
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-2">Добро пожаловать!</h2>
<p className="text-slate-500 dark:text-slate-400">Вход выполнен, переходим в систему</p>
</div>
) : (
<>
<h2 className="text-2xl font-bold text-slate-900 dark:text-slate-100 mb-1">Принять приглашение</h2>
<p className="text-slate-500 dark:text-slate-400 mb-8">Задайте пароль для входа в HotelSync</p>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Пароль</label>
<div className="relative">
<input
type={showPass ? 'text' : 'password'}
className="input pr-10"
placeholder="Минимум 6 символов"
value={password}
onChange={e => { setPassword(e.target.value); setError('') }}
required
autoFocus
/>
<button type="button" onClick={() => setShowPass(v => !v)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-slate-400 hover:text-slate-600">
{showPass ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">Повторите пароль</label>
<input
type={showPass ? 'text' : 'password'}
className={cn('input', password2 && password !== password2 && 'border-red-400 focus:ring-red-400')}
placeholder="Повторите пароль"
value={password2}
onChange={e => { setPassword2(e.target.value); setError('') }}
required
/>
{password2 && password !== password2 && (
<p className="mt-1 text-xs text-red-600 dark:text-red-400">Пароли не совпадают</p>
)}
</div>
{error && (
<div className="flex items-center gap-2 p-3 rounded-lg bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-300 text-sm">
<AlertCircle size={15} /> {error}
</div>
)}
<button type="submit" disabled={loading} className="btn-primary w-full justify-center py-2.5">
{loading
? <span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
: 'Войти в систему'}
</button>
</form>
</>
)}
</div>
</div>
</div>
)
}

View File

@@ -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 */}
<div>
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
{isNew ? 'Пароль *' : 'Новый пароль (оставьте пустым, чтобы не менять)'}
{isNew ? 'Пароль' : 'Новый пароль (оставьте пустым, чтобы не менять)'}
</label>
{isNew && (
<p className="text-xs text-slate-500 dark:text-slate-400 mb-2">
Оставьте пустым сотрудник получит приглашение на email и сам задаст пароль
</p>
)}
<div className="relative">
<input
type={showPass ? 'text' : 'password'}
className={cn('input pr-10', errors.password && 'border-red-400')}
placeholder="••••••••"
placeholder={isNew ? 'Оставьте пустым для отправки приглашения' : '••••••••'}
value={password}
onChange={e => setPassword(e.target.value)}
/>