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:
3
backend/migrations/083_user_invite.sql
Normal file
3
backend/migrations/083_user_invite.sql
Normal file
@@ -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;
|
||||||
@@ -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 {
|
function fmtDate(d: string): string {
|
||||||
const [y, m, day] = d.slice(0, 10).split('-')
|
const [y, m, day] = d.slice(0, 10).split('-')
|
||||||
return `${day}.${m}.${y}`
|
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 ───────────────────────────────────────────────────────
|
// ── GET /api/auth/me ───────────────────────────────────────────────────────
|
||||||
fastify.get(
|
fastify.get(
|
||||||
'/api/auth/me',
|
'/api/auth/me',
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { FastifyPluginAsync } from 'fastify'
|
import { FastifyPluginAsync } from 'fastify'
|
||||||
import bcrypt from 'bcryptjs'
|
import bcrypt from 'bcryptjs'
|
||||||
|
import crypto from 'crypto'
|
||||||
import { db } from '../db'
|
import { db } from '../db'
|
||||||
|
import { sendInviteEmail } from '../email'
|
||||||
|
|
||||||
type SlugParam = { Params: { slug: string } }
|
type SlugParam = { Params: { slug: string } }
|
||||||
type SlugIdParam = { Params: { slug: string; id: string } }
|
type SlugIdParam = { Params: { slug: string; id: string } }
|
||||||
@@ -41,7 +43,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; phone?: string; position?: 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] },
|
||||||
@@ -69,15 +71,46 @@ const users: FastifyPluginAsync = async (fastify) => {
|
|||||||
return reply.code(403).send({ error: 'Недостаточно прав для создания этой роли' })
|
return reply.code(403).send({ error: 'Недостаточно прав для создания этой роли' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(password, 12)
|
|
||||||
try {
|
try {
|
||||||
const { rows } = await db.query(
|
let row: Record<string, unknown>
|
||||||
`INSERT INTO users (hotel_id, email, password_hash, name, role, phone, position)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
if (password) {
|
||||||
RETURNING ${USER_FIELDS}`,
|
// Direct creation with password — mark email as confirmed immediately
|
||||||
[hotelId, email.toLowerCase(), passwordHash, name, role, phone ?? null, position ?? null],
|
const passwordHash = await bcrypt.hash(password, 12)
|
||||||
)
|
const { rows } = await db.query(
|
||||||
return reply.code(201).send(rows[0])
|
`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) {
|
} catch (err: unknown) {
|
||||||
if ((err as { code?: string }).code === '23505') {
|
if ((err as { code?: string }).code === '23505') {
|
||||||
return reply.code(409).send({ error: 'Email already in use' })
|
return reply.code(409).send({ error: 'Email already in use' })
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { DiscountsPage } from './pages/DiscountsPage'
|
|||||||
import { GuestReviewPage } from './pages/GuestReviewPage'
|
import { GuestReviewPage } from './pages/GuestReviewPage'
|
||||||
import { GuestRoomServicePage } from './pages/GuestRoomServicePage'
|
import { GuestRoomServicePage } from './pages/GuestRoomServicePage'
|
||||||
import { ResetPasswordPage } from './pages/ResetPasswordPage'
|
import { ResetPasswordPage } from './pages/ResetPasswordPage'
|
||||||
|
import { AcceptInvitePage } from './pages/AcceptInvitePage'
|
||||||
import { TvWelcomePage } from './pages/TvWelcomePage'
|
import { TvWelcomePage } from './pages/TvWelcomePage'
|
||||||
import { TechnicalPage } from './pages/TechnicalPage'
|
import { TechnicalPage } from './pages/TechnicalPage'
|
||||||
import { SchedulePage } from './pages/SchedulePage'
|
import { SchedulePage } from './pages/SchedulePage'
|
||||||
@@ -86,6 +87,7 @@ export default function App() {
|
|||||||
{/* Public */}
|
{/* Public */}
|
||||||
<Route path="/login" element={<LoginPage />} />
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
<Route path="/reset-password" element={<ResetPasswordPage />} />
|
||||||
|
<Route path="/invite/:token" element={<AcceptInvitePage />} />
|
||||||
<Route path="/review/:slug" element={<GuestReviewPage />} />
|
<Route path="/review/:slug" element={<GuestReviewPage />} />
|
||||||
<Route path="/room-service/:slug" element={<GuestRoomServicePage />} />
|
<Route path="/room-service/:slug" element={<GuestRoomServicePage />} />
|
||||||
<Route path="/:slug/pay" element={<PayDepositPage />} />
|
<Route path="/:slug/pay" element={<PayDepositPage />} />
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ interface AuthContextValue {
|
|||||||
session: AuthSession | null
|
session: AuthSession | null
|
||||||
user: User | null
|
user: User | null
|
||||||
login: (email: string, password: string) => Promise<User | null>
|
login: (email: string, password: string) => Promise<User | null>
|
||||||
|
loginWithToken: (token: string, user: User) => void
|
||||||
logout: () => 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 () => {
|
const logout = async () => {
|
||||||
try { await api.auth.logout() } catch { /* ignore */ }
|
try { await api.auth.logout() } catch { /* ignore */ }
|
||||||
setSession(null)
|
setSession(null)
|
||||||
@@ -98,7 +105,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ session, user: session?.user ?? null, login, logout }}>
|
<AuthContext.Provider value={{ session, user: session?.user ?? null, login, loginWithToken, logout }}>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -201,6 +201,10 @@ export const api = {
|
|||||||
resetPassword: (token: string, password: string) =>
|
resetPassword: (token: string, password: string) =>
|
||||||
req<{ ok: boolean }>('POST', '/api/auth/reset-password', { token, password }),
|
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) =>
|
resendConfirmation: (email: string) =>
|
||||||
req<{ ok: boolean }>('POST', '/api/auth/resend-confirmation', { email }),
|
req<{ ok: boolean }>('POST', '/api/auth/resend-confirmation', { email }),
|
||||||
},
|
},
|
||||||
|
|||||||
136
src/pages/AcceptInvitePage.tsx
Normal file
136
src/pages/AcceptInvitePage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -308,7 +308,7 @@ function UserModal({
|
|||||||
if (!form.lastName.trim()) e.lastName = 'Введите фамилию'
|
if (!form.lastName.trim()) e.lastName = 'Введите фамилию'
|
||||||
if (!form.email.trim()) e.email = 'Введите email'
|
if (!form.email.trim()) e.email = 'Введите email'
|
||||||
if (!form.position.trim()) e.position = 'Введите должность'
|
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)
|
setErrors(e)
|
||||||
return Object.keys(e).length === 0
|
return Object.keys(e).length === 0
|
||||||
}
|
}
|
||||||
@@ -477,13 +477,18 @@ function UserModal({
|
|||||||
{/* Password */}
|
{/* Password */}
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
<label className="block text-sm font-medium text-slate-700 dark:text-slate-300 mb-1.5">
|
||||||
{isNew ? 'Пароль *' : 'Новый пароль (оставьте пустым, чтобы не менять)'}
|
{isNew ? 'Пароль' : 'Новый пароль (оставьте пустым, чтобы не менять)'}
|
||||||
</label>
|
</label>
|
||||||
|
{isNew && (
|
||||||
|
<p className="text-xs text-slate-500 dark:text-slate-400 mb-2">
|
||||||
|
Оставьте пустым — сотрудник получит приглашение на email и сам задаст пароль
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<input
|
<input
|
||||||
type={showPass ? 'text' : 'password'}
|
type={showPass ? 'text' : 'password'}
|
||||||
className={cn('input pr-10', errors.password && 'border-red-400')}
|
className={cn('input pr-10', errors.password && 'border-red-400')}
|
||||||
placeholder="••••••••"
|
placeholder={isNew ? 'Оставьте пустым для отправки приглашения' : '••••••••'}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={e => setPassword(e.target.value)}
|
onChange={e => setPassword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in New Issue
Block a user