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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user