Skip to content

Commit 79d70e0

Browse files
authored
feat: add user invitation
2 parents 830b75e + 2e91dbb commit 79d70e0

40 files changed

+1001
-17
lines changed

.env.example

+3-1
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,6 @@ SMTP_USERNAME=username
1717
SMTP_PASSWORD=password
1818
SMTP_SECURE=false
1919
SMTP_FROM_EMAIL=dev@example.fr
20-
SMTP_FROM_NAME="Dev Aquatracking"
20+
SMTP_FROM_NAME="Dev Aquatracking"
21+
INITIAL_ADMIN_EMAIL=admin@example.fr
22+
INITIAL_ADMIN_PASSWORD=password
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import User from '#models/user'
2+
import UserInvitation from '#models/user_invitation'
3+
import { userInviteValidator } from '#validators/user_invite_validator'
4+
import type { HttpContext } from '@adonisjs/core/http'
5+
6+
export default class AdminUsersInvitationController {
7+
async store({ request, session, i18n, response }: HttpContext) {
8+
const { email } = await request.validateUsing(userInviteValidator)
9+
10+
const existingUser = await User.query().where('email', email).first()
11+
const existingInvitation = await UserInvitation.query().where('email', email).first()
12+
13+
if (existingUser) {
14+
session.flashErrors({
15+
E_EMAIL_ALREADY_USED: i18n.t('errors.E_EMAIL_ALREADY_USED'),
16+
})
17+
} else if (existingInvitation) {
18+
await existingInvitation.delete()
19+
20+
const invitation = await UserInvitation.generateInvite(email, i18n.locale)
21+
22+
session.flash('notification', {
23+
type: 'success',
24+
message: i18n.t('notifications.invitationRenewed', { email: invitation.email }),
25+
})
26+
} else {
27+
const invitation = await UserInvitation.generateInvite(email, i18n.locale)
28+
29+
session.flash('notification', {
30+
type: 'success',
31+
message: i18n.t('notifications.invitationSent', { email: invitation.email }),
32+
})
33+
}
34+
35+
return response.redirect().toRoute('admin.users.index')
36+
}
37+
38+
async destroy({ params, session, i18n, response }: HttpContext) {
39+
const invitation = await UserInvitation.findOrFail(params.id)
40+
41+
await invitation?.delete()
42+
43+
session.flash('notification', {
44+
type: 'success',
45+
message: i18n.t('notifications.invitationDeleted', { email: invitation.email }),
46+
})
47+
48+
return response.redirect().toRoute('admin.users.index')
49+
}
50+
}
+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import User from '#models/user'
2+
import UserInvitation from '#models/user_invitation'
3+
import type { HttpContext } from '@adonisjs/core/http'
4+
5+
export default class AdminUsersController {
6+
async index({ inertia }: HttpContext) {
7+
const users = await User.query().orderBy('createdAt', 'desc').exec()
8+
const invitations = await UserInvitation.query().orderBy('createdAt', 'desc').exec()
9+
10+
return inertia.render('admin/users/index', {
11+
users: users.map((user) => user.serialize()),
12+
invitations: invitations.map((invitation) => invitation.serialize()),
13+
})
14+
}
15+
}

app/controllers/auth/login_controller.ts

+3-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import User from '#models/user'
22
import UserToken from '#models/user_token'
33
import MailService from '#services/mail_service'
4+
import env from '#start/env'
45
import { loginValidator } from '#validators/login_validator'
56
import { inject } from '@adonisjs/core'
67
import type { HttpContext } from '@adonisjs/core/http'
@@ -13,7 +14,8 @@ export default class LoginController {
1314
* Display the login form
1415
*/
1516
async index({ inertia }: HttpContext) {
16-
return inertia.render('auth/login')
17+
const requireInvitation = env.get('REQUIRE_INVITATION')
18+
return inertia.render('auth/login', { requireInvitation: !!requireInvitation })
1719
}
1820

1921
/**

app/controllers/auth/register_controller.ts

+62-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import User from '#models/user'
2+
import UserInvitation from '#models/user_invitation'
23
import UserToken from '#models/user_token'
34
import MailService from '#services/mail_service'
5+
import env from '#start/env'
46
import { createRegisterValidator } from '#validators/register_validator'
57
import { inject } from '@adonisjs/core'
68
import type { HttpContext } from '@adonisjs/core/http'
9+
import vine from '@vinejs/vine'
710

811
@inject()
912
export default class RegisterController {
@@ -12,18 +15,74 @@ export default class RegisterController {
1215
/**
1316
* Display the registration form
1417
*/
15-
async index({ inertia }: HttpContext) {
16-
return inertia.render('auth/register')
18+
async index({ inertia, request, session, i18n, response }: HttpContext) {
19+
if (!env.get('REQUIRE_INVITATION')) {
20+
return inertia.render('auth/register')
21+
}
22+
23+
const { invitationToken } = await request.validateUsing(
24+
vine.compile(
25+
vine.object({
26+
invitationToken: vine.string().optional(),
27+
})
28+
)
29+
)
30+
31+
if (!invitationToken) {
32+
session.flashErrors({
33+
E_INVITATION_REQUIRED: i18n.t('errors.E_INVITATION_REQUIRED'),
34+
})
35+
36+
return response.redirect().toPath('/auth/login')
37+
}
38+
39+
const invitation = await UserInvitation.query().where('token', invitationToken).first()
40+
41+
if (!invitation || invitation.isExpired()) {
42+
session.flashErrors({
43+
E_INVITATION_INVALID: i18n.t('errors.E_INVITATION_INVALID'),
44+
})
45+
return response.redirect().toPath('/auth/login')
46+
}
47+
48+
return inertia.render('auth/register', {
49+
email: invitation.email,
50+
})
1751
}
1852

1953
/**
2054
* Handle form submission for the create action
2155
*/
2256
async store({ request, response, session, i18n }: HttpContext) {
23-
const { fullName, email, password } = await request.validateUsing(createRegisterValidator)
57+
const { fullName, email, password, invitationToken } =
58+
await request.validateUsing(createRegisterValidator)
59+
60+
if (env.get('REQUIRE_INVITATION')) {
61+
if (!invitationToken) {
62+
session.flashErrors({
63+
E_INVITATION_REQUIRED: i18n.t('errors.E_INVITATION_REQUIRED'),
64+
})
65+
66+
return response.redirect().toPath('/auth/login')
67+
}
68+
69+
const invitation = await UserInvitation.query()
70+
.where('email', email)
71+
.where('token', invitationToken)
72+
.first()
73+
74+
if (!invitation || invitation.isExpired()) {
75+
session.flashErrors({
76+
E_INVITATION_INVALID: i18n.t('errors.E_INVITATION_INVALID'),
77+
})
78+
return response.redirect().toPath('/auth/login')
79+
}
80+
}
2481

2582
const user = await User.create({ fullName, email, password })
2683

84+
await UserInvitation.query().where('email', email).delete()
85+
2786
const token = await UserToken.generateEmailVerificationToken(user)
2887
await this.mailService.sendEmailVerification(user, token, i18n.locale)
2988

app/dto/user_dto.ts

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ const userDtoSchema = vine.object({
88
verified: vine.boolean(),
99
createdAt: vine.date(),
1010
updatedAt: vine.date(),
11+
isAdmin: vine.boolean(),
1112
})
1213

1314
export const userDtoValidator = vine.compile(userDtoSchema)

app/dto/user_invitation_dto.ts

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export type UserInvitationDto = {
2+
id: string
3+
email: string
4+
createdAt: Date
5+
updatedAt: Date
6+
expiresAt: Date
7+
}

app/middleware/admin_middleware.ts

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { HttpContext } from '@adonisjs/core/http'
2+
import { NextFn } from '@adonisjs/core/types/http'
3+
4+
export default class AdminMiddleware {
5+
async handle(ctx: HttpContext, next: NextFn) {
6+
const user = ctx.auth.getUserOrFail()
7+
8+
if (!user.isAdmin) {
9+
return ctx.response.status(403).send('Forbidden')
10+
}
11+
12+
return next()
13+
}
14+
}

app/models/user.ts

+3
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,9 @@ export default class User extends compose(BaseModel, AuthFinder) {
3232
@column()
3333
declare verified: boolean
3434

35+
@column()
36+
declare isAdmin: boolean
37+
3538
@column.dateTime({ autoCreate: true })
3639
declare createdAt: DateTime
3740

app/models/user_invitation.ts

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import MailService from '#services/mail_service'
2+
import string from '@adonisjs/core/helpers/string'
3+
import app from '@adonisjs/core/services/app'
4+
import { BaseModel, beforeCreate, column } from '@adonisjs/lucid/orm'
5+
import { DateTime } from 'luxon'
6+
import { randomUUID } from 'node:crypto'
7+
8+
const TOKEN_SIZE = 64
9+
10+
export default class UserInvitation extends BaseModel {
11+
static selfAssignPrimaryKey = true
12+
13+
@column({ isPrimary: true })
14+
declare id: string
15+
16+
@column()
17+
declare email: string
18+
19+
@column()
20+
declare token: string
21+
22+
@column.dateTime()
23+
declare expiresAt: DateTime
24+
25+
@column.dateTime({ autoCreate: true })
26+
declare createdAt: DateTime
27+
28+
@column.dateTime({ autoCreate: true, autoUpdate: true })
29+
declare updatedAt: DateTime
30+
31+
isExpired() {
32+
return this.expiresAt < DateTime.now()
33+
}
34+
35+
@beforeCreate()
36+
static assignUuid(userInvitation: UserInvitation) {
37+
userInvitation.id = randomUUID()
38+
}
39+
40+
static async generateInvite(email: string, locale: string) {
41+
const mailService = await app.container.make(MailService)
42+
43+
const token = string.generateRandom(TOKEN_SIZE)
44+
const expiresAt = DateTime.now().plus({ days: 7 })
45+
46+
const invitation = await UserInvitation.create({
47+
email,
48+
token,
49+
expiresAt,
50+
})
51+
await mailService.sendUserInvitation(email, token, locale)
52+
53+
return invitation
54+
}
55+
}

app/services/mail_service.ts

+17
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,21 @@ export default class MailService {
4848
.text(i18n.t('email.passwordResetConfirmation.text', { fullName: user.fullName }))
4949
})
5050
}
51+
52+
async sendUserInvitation(email: string, token: string, locale: string = 'en') {
53+
const i18n = i18nManager.locale(locale)
54+
55+
const invitationLink = Router.makeUrl('auth_register.index', undefined, {
56+
qs: { invitationToken: token },
57+
prefixUrl: env.get('BASE_URL').replace(/\/$/, ''),
58+
})
59+
60+
await mail.sendLater((message) => {
61+
message.to(email).subject(i18n.t('email.invitation.subject')).text(
62+
i18n.t('email.invitation.text', {
63+
invitationLink,
64+
})
65+
)
66+
})
67+
}
5168
}

app/validators/register_validator.ts

+1
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,6 @@ export const createRegisterValidator = vine.compile(
1212
return !user
1313
}),
1414
password: vine.string().minLength(8),
15+
invitationToken: vine.string().optional(),
1516
})
1617
)
+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import vine from '@vinejs/vine'
2+
3+
export const userInviteValidator = vine.compile(
4+
vine.object({
5+
email: vine.string().email().normalizeEmail(),
6+
})
7+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import { BaseSchema } from '@adonisjs/lucid/schema'
2+
3+
export default class extends BaseSchema {
4+
protected tableName = 'users'
5+
6+
async up() {
7+
this.schema.table(this.tableName, (table) => {
8+
table.boolean('is_admin').defaultTo(false)
9+
})
10+
}
11+
12+
async down() {
13+
this.schema.table(this.tableName, (table) => {
14+
table.dropColumn('is_admin')
15+
})
16+
}
17+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import env from '#start/env'
2+
import hash from '@adonisjs/core/services/hash'
3+
import { BaseSchema } from '@adonisjs/lucid/schema'
4+
import { randomUUID } from 'node:crypto'
5+
6+
export default class extends BaseSchema {
7+
async up() {
8+
// Create an admin user if there are no users
9+
const users = await this.db.from('users')
10+
const adminEmail = env.get('INITIAL_ADMIN_EMAIL')
11+
const adminPassword = env.get('INITIAL_ADMIN_PASSWORD')
12+
if (users.length === 0 && adminEmail && adminPassword) {
13+
await this.db.table('users').insert({
14+
id: randomUUID(),
15+
full_name: 'Admin',
16+
verified: true,
17+
email: adminEmail,
18+
password: await hash.make(adminPassword),
19+
is_admin: true,
20+
created_at: this.now(),
21+
updated_at: this.now(),
22+
})
23+
}
24+
}
25+
26+
async down() {}
27+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { BaseSchema } from '@adonisjs/lucid/schema'
2+
3+
export default class extends BaseSchema {
4+
protected tableName = 'user_invitations'
5+
6+
async up() {
7+
this.schema.createTable(this.tableName, (table) => {
8+
table.uuid('id').notNullable().primary()
9+
table.string('email').notNullable()
10+
table.string('token').notNullable()
11+
table.timestamp('expires_at').notNullable()
12+
13+
table.timestamp('created_at')
14+
table.timestamp('updated_at')
15+
})
16+
}
17+
18+
async down() {
19+
this.schema.dropTable(this.tableName)
20+
}
21+
}

0 commit comments

Comments
 (0)