152 lines
4.5 KiB
TypeScript
152 lines
4.5 KiB
TypeScript
import _ from 'lodash'
|
|
import * as z from 'zod'
|
|
import type { RouteHandler } from 'fastify'
|
|
import config from '../../../config.ts'
|
|
import emitter from '../../../lib/emitter.ts'
|
|
import knex from '../../../lib/knex.ts'
|
|
import AdmissionQueries from '../../../services/admissions/queries.ts'
|
|
import InviteQueries from '../../../services/invites/queries.ts'
|
|
import UserQueries from '../../../services/users/queries.ts'
|
|
import verifyEmailTemplate from '../../../templates/emails/verify_email.ts'
|
|
import sendMail from '../../../lib/send_mail.ts'
|
|
import StatusError from '../../../lib/status_error.ts'
|
|
import { generateToken, hashPassword } from '../helpers.ts'
|
|
import type { Invite } from '../../../services/invites/types.ts'
|
|
import type { Role } from '../../../services/roles/types.ts'
|
|
|
|
const admissionQueries = AdmissionQueries({ knex, emitter })
|
|
const inviteQueries = InviteQueries({ knex, emitter })
|
|
const userQueries = UserQueries({ knex, emitter })
|
|
|
|
const { errors, timeouts } = config.auth
|
|
|
|
const BodySchema = z.object({
|
|
email: z.email(),
|
|
password: z.string(),
|
|
inviteEmail: z.email().optional(),
|
|
inviteToken: z.string().optional(),
|
|
// required: config.auth.requireInvite ? ['email', 'password', 'inviteEmail', 'inviteToken'] : ['email', 'password'],
|
|
})
|
|
|
|
const ResponseSchema = {
|
|
201: {
|
|
type: 'object',
|
|
properties: {
|
|
id: { type: 'integer' },
|
|
email: { type: 'string' },
|
|
password: { type: 'string' },
|
|
emailVerifiedAt: { type: 'string' },
|
|
bannedAt: { type: 'string' },
|
|
blockedAt: { type: 'string' },
|
|
loginAttempts: { type: 'integer' },
|
|
},
|
|
},
|
|
'4XX': { $ref: 'status-error' },
|
|
}
|
|
|
|
const register: RouteHandler<{ Body: z.infer<typeof BodySchema> }> = async function (request, reply) {
|
|
try {
|
|
// TODO validate and ensure same as confirm
|
|
const email = request.body.email.trim().toLowerCase()
|
|
|
|
let invite: Invite | null = null
|
|
|
|
if (request.body.inviteEmail && request.body.inviteToken) {
|
|
invite = await inviteQueries.findOne({ email: request.body.inviteEmail, token: request.body.inviteToken })
|
|
const latestInvite = await inviteQueries.findOne({
|
|
email: request.body.inviteEmail,
|
|
sort: [{ column: 'createdAt', order: 'desc' }],
|
|
})
|
|
|
|
if (!invite) {
|
|
throw new StatusError(...errors.tokenNotFound)
|
|
} else if (invite.id !== latestInvite.id) {
|
|
throw new StatusError(...errors.tokenNotLatest)
|
|
} else if (Date.now() > new Date(invite.createdAt).getTime() + timeouts.invite) {
|
|
throw new StatusError(...errors.tokenExpired)
|
|
} else if (invite.consumedAt) {
|
|
throw new StatusError(...errors.tokenConsumed)
|
|
}
|
|
}
|
|
|
|
const admissions = await admissionQueries.findMatches(email)
|
|
|
|
const roles = Array.from(
|
|
new Set(
|
|
[
|
|
invite?.roles ? invite.roles.map((role) => role.id) : [],
|
|
...admissions.map((admission) => (admission.roles ? admission.roles.map((role: Role) => role.id) : [])),
|
|
].flat(),
|
|
),
|
|
)
|
|
|
|
if (!roles.length) {
|
|
throw new StatusError(...errors.notAuthorized)
|
|
}
|
|
|
|
const user = await knex.transaction(async (trx) => {
|
|
const user = await userQueries.create(
|
|
{
|
|
email,
|
|
password: await hashPassword(request.body.password),
|
|
roles,
|
|
emailVerifiedAt: invite && invite.email === email ? (knex.fn.now() as unknown as string) : null,
|
|
},
|
|
trx,
|
|
)
|
|
|
|
if (invite) {
|
|
await inviteQueries.consume(invite.id, user.id, trx)
|
|
}
|
|
|
|
if (!user.emailVerifiedAt) {
|
|
const token = generateToken()
|
|
|
|
await trx('email_token').insert({
|
|
user_id: user.id,
|
|
email: user.email,
|
|
token,
|
|
})
|
|
|
|
const link = `${new URL('/auth/verify-email', config.site.url)}?email=${user.email}&token=${token}`
|
|
|
|
await sendMail({
|
|
to: user.email,
|
|
subject: `Verify ${config.site.title} account`,
|
|
html: await verifyEmailTemplate({ link }).text(),
|
|
})
|
|
}
|
|
|
|
return userQueries.findById(user.id)
|
|
})
|
|
|
|
reply.type('application/json')
|
|
|
|
return reply.status(201).send(_.omit(user, 'password'))
|
|
} catch (err) {
|
|
this.log.error(err)
|
|
|
|
// @ts-ignore
|
|
if (err.code == 23505) {
|
|
err = new StatusError(...errors.duplicateEmail)
|
|
}
|
|
|
|
if (err instanceof StatusError) {
|
|
return reply
|
|
.status(err.status || 500)
|
|
.type('application/json')
|
|
.send(err.toJSON())
|
|
} else {
|
|
throw err
|
|
}
|
|
}
|
|
}
|
|
|
|
export default {
|
|
handler: register,
|
|
schema: {
|
|
body: BodySchema,
|
|
resopnse: ResponseSchema,
|
|
},
|
|
}
|