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

@@ -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',