Skip to content

Commit 3c3babb

Browse files
committed
lesson(11.3): sending, accepting, and listing pending organizations
1 parent 243d52a commit 3c3babb

17 files changed

+471
-12
lines changed

app/actions/auth/http/web_login.ts

+16
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import AcceptOrganizationInvite from '#actions/organizations/accept_organization_invite'
12
import User from '#models/user'
23
import { loginValidator } from '#validators/auth'
34
import { inject } from '@adonisjs/core'
@@ -16,7 +17,22 @@ export default class WebLogin {
1617
const user = await User.verifyCredentials(data.email, data.password)
1718

1819
await this.ctx.auth.use('web').login(user, data.remember)
20+
await this.#checkForOrganizationInvite(user)
1921

2022
return user
2123
}
24+
25+
async #checkForOrganizationInvite(user: User) {
26+
const inviteId = this.ctx.session.get('invite_id')
27+
28+
if (!inviteId) return
29+
30+
const result = await AcceptOrganizationInvite.handle({
31+
inviteId,
32+
user,
33+
})
34+
35+
this.ctx.session.forget('invite_id')
36+
this.ctx.session.flash('success', result.message)
37+
}
2238
}

app/actions/auth/http/web_register.ts

+20-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import AcceptOrganizationInvite from '#actions/organizations/accept_organization_invite'
12
import User from '#models/user'
23
import { registerValidator } from '#validators/auth'
34
import { inject } from '@adonisjs/core'
@@ -17,6 +18,24 @@ export default class WebRegister {
1718

1819
await this.ctx.auth.use('web').login(user)
1920

20-
return { user }
21+
const invite = await this.#checkForOrganizationInvite(user)
22+
23+
return { user, invite }
24+
}
25+
26+
async #checkForOrganizationInvite(user: User) {
27+
const inviteId = this.ctx.session.get('invite_id')
28+
29+
if (!inviteId) return
30+
31+
const result = await AcceptOrganizationInvite.handle({
32+
inviteId,
33+
user,
34+
})
35+
36+
this.ctx.session.forget('invite_id')
37+
this.ctx.session.flash('success', result.message)
38+
39+
return result.invite
2140
}
2241
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import UnauthorizedException from '#exceptions/unauthorized_exception'
2+
import OrganizationInvite from '#models/organization_invite'
3+
import User from '#models/user'
4+
import db from '@adonisjs/lucid/services/db'
5+
import { DateTime } from 'luxon'
6+
7+
type Params = {
8+
inviteId: number
9+
user: User
10+
}
11+
12+
export default class AcceptOrganizationInvite {
13+
static async handle({ inviteId, user }: Params) {
14+
const invite = await OrganizationInvite.findOrFail(inviteId)
15+
16+
if (invite.email !== user.email) {
17+
throw new UnauthorizedException('Your email does not match the invitation')
18+
}
19+
20+
if (invite.acceptedAt || invite.canceledAt) {
21+
throw new UnauthorizedException('This invitation is no longer valid')
22+
}
23+
24+
await db.transaction(async (trx) => {
25+
invite.useTransaction(trx)
26+
27+
const organization = await invite.related('organization').query().firstOrFail()
28+
29+
await organization.related('users').attach({
30+
[user.id]: {
31+
role_id: invite.roleId,
32+
},
33+
})
34+
35+
invite.acceptedAt = DateTime.now()
36+
37+
await invite.save()
38+
})
39+
40+
return {
41+
invite,
42+
message: 'Invitation Accepted!',
43+
}
44+
}
45+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import Organization from '#models/organization'
2+
3+
type Params = {
4+
organization: Organization
5+
}
6+
7+
export default class GetOrganizationPendingInvites {
8+
static async handle({ organization }: Params) {
9+
return organization
10+
.related('invites')
11+
.query()
12+
.whereNull('acceptedAt')
13+
.whereNull('canceledAt')
14+
.orderBy('createdAt', 'desc')
15+
}
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import Organization from '#models/organization'
2+
import User from '#models/user'
3+
import env from '#start/env'
4+
import { organizationInviteValidator } from '#validators/organization'
5+
import router from '@adonisjs/core/services/router'
6+
import mail from '@adonisjs/mail/services/main'
7+
import { Infer } from '@vinejs/vine/types'
8+
9+
type Params = {
10+
organization: Organization
11+
invitedByUserId: number
12+
data: Infer<typeof organizationInviteValidator>
13+
}
14+
15+
export default class SendOrganizationInvite {
16+
static async handle({ organization, invitedByUserId, data }: Params) {
17+
const invite = await organization.related('invites').create({
18+
invitedByUserId,
19+
...data,
20+
})
21+
22+
const invitedUser = await User.findBy('email', invite.email)
23+
24+
const inviteUrl = router
25+
.builder()
26+
.params({ id: invite.id })
27+
.prefixUrl(env.get('APP_URL'))
28+
.makeSigned('organizations.invites.accept')
29+
30+
await mail.sendLater((message) => {
31+
message
32+
.to(invite.email)
33+
.subject(`You have been invited to join ${organization.name}`)
34+
.htmlView('emails/organization_invite', { organization, invitedUser, inviteUrl })
35+
})
36+
}
37+
}

app/controllers/auth/register_controller.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,14 @@ export default class RegisterController {
1313
const data = await request.validateUsing(registerValidator)
1414

1515
// register the user
16-
await webRegister.handle({ data })
16+
const { invite } = await webRegister.handle({ data })
1717

1818
session.flash('success', 'Welcome to PlotMyCourse')
1919

20+
if (invite) {
21+
return response.redirect().toRoute('courses.index')
22+
}
23+
2024
return response.redirect().toRoute('organizations.create')
2125
}
2226
}

app/controllers/organizations_controller.ts

+34
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
import AcceptOrganizationInvite from '#actions/organizations/accept_organization_invite'
12
import DestroyOrganization from '#actions/organizations/destroy_organization'
23
import SetActiveOrganization from '#actions/organizations/http/set_active_organization'
34
import StoreOrganization from '#actions/organizations/store_organization'
45
import UpdateOrganization from '#actions/organizations/update_organization'
6+
import OrganizationInvite from '#models/organization_invite'
7+
import User from '#models/user'
58
import { organizationValidator } from '#validators/organization'
69
import { inject } from '@adonisjs/core'
710
import type { HttpContext } from '@adonisjs/core/http'
@@ -55,6 +58,37 @@ export default class OrganizationsController {
5558
return response.redirect().back()
5659
}
5760

61+
async acceptInvite({ request, response, auth, params, session }: HttpContext) {
62+
await auth.use('web').check()
63+
64+
if (!request.hasValidSignature()) {
65+
session.flash('errorBag', 'An invalid invitation URL was provided')
66+
return auth.user
67+
? response.redirect().toRoute('courses.index')
68+
: response.redirect().toRoute('login.show')
69+
}
70+
71+
if (!auth.use('web').user) {
72+
const invite = await OrganizationInvite.findOrFail(params.id)
73+
const isUser = await User.query().where('email', invite.email).first()
74+
75+
session.put('invite_id', invite.id)
76+
77+
return isUser
78+
? response.redirect().toRoute('login.show')
79+
: response.redirect().toRoute('register.show')
80+
}
81+
82+
const result = await AcceptOrganizationInvite.handle({
83+
inviteId: params.id,
84+
user: auth.use('web').user!,
85+
})
86+
87+
session.flash('success', result.message)
88+
89+
return response.redirect().toRoute('courses.index')
90+
}
91+
5892
/**
5993
* Delete record
6094
*/

app/controllers/settings/organizations_controller.ts

+25-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
import GetOrganizationPendingInvites from '#actions/organizations/get_organization_pending_invites'
12
import GetOrganizationUsers from '#actions/organizations/get_organization_users'
3+
import SendOrganizationInvite from '#actions/organizations/send_organization_invite'
4+
import OrganizationInviteDto from '#dtos/organization_invite'
25
import RoleDto from '#dtos/role'
36
import UserDto from '#dtos/user'
47
import Role from '#models/role'
8+
import { withOrganizationMetaData } from '#validators/helpers/organizations'
9+
import { organizationInviteValidator } from '#validators/organization'
510
import type { HttpContext } from '@adonisjs/core/http'
611

712
export default class OrganizationsController {
@@ -11,14 +16,33 @@ export default class OrganizationsController {
1116
const users = await GetOrganizationUsers.handle({ organization })
1217
return UserDto.fromArray(users)
1318
},
19+
invites: async () => {
20+
const pendingInvites = await GetOrganizationPendingInvites.handle({ organization })
21+
return OrganizationInviteDto.fromArray(pendingInvites)
22+
},
1423
roles: async () => {
1524
const roles = await Role.query().orderBy('name')
1625
return RoleDto.fromArray(roles)
1726
},
1827
})
1928
}
2029

21-
async inviteUser({}: HttpContext) {}
30+
async inviteUser({ request, response, organization, session, auth }: HttpContext) {
31+
const data = await request.validateUsing(
32+
organizationInviteValidator,
33+
withOrganizationMetaData(organization.id)
34+
)
35+
36+
await SendOrganizationInvite.handle({
37+
organization,
38+
invitedByUserId: auth.use('web').user!.id,
39+
data,
40+
})
41+
42+
session.flash('success', 'Invitation has been sent')
43+
44+
return response.redirect().back()
45+
}
2246

2347
async cancelInvite({}: HttpContext) {}
2448

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { Exception } from '@adonisjs/core/exceptions'
2+
3+
export default class UnauthorizedException extends Exception {
4+
static status = 403
5+
static code = 'E_UNAUTHORIZED'
6+
}

app/validators/auth.ts

+5-9
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,12 @@ export const loginValidator = vine.compile(
77
remember: vine.boolean().optional(),
88
})
99
)
10+
export const emailRule = vine.string().maxLength(254).email().normalizeEmail()
1011

11-
export const newEmailRule = vine
12-
.string()
13-
.maxLength(254)
14-
.email()
15-
.normalizeEmail()
16-
.unique(async (db, value) => {
17-
const exists = await db.from('users').where('email', value).select('id').first()
18-
return !exists
19-
})
12+
export const newEmailRule = emailRule.clone().unique(async (db, value) => {
13+
const exists = await db.from('users').where('email', value).select('id').first()
14+
return !exists
15+
})
2016

2117
export const registerValidator = vine.compile(
2218
vine.object({

app/validators/organization.ts

+35
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,42 @@
11
import vine from '@vinejs/vine'
2+
import { OrganizationMetaData } from './helpers/organizations.js'
3+
import { emailRule } from './auth.js'
24

35
export const organizationValidator = vine.compile(
46
vine.object({
57
name: vine.string().maxLength(100),
68
})
79
)
10+
11+
export const organizationInviteValidator = vine.withMetaData<OrganizationMetaData>().compile(
12+
vine.object({
13+
email: emailRule.clone().unique(async (db, value, field) => {
14+
// 1. make sure there isn't already a pending invite
15+
const inviteMatch = await db
16+
.from('organization_invites')
17+
.where('organization_id', field.meta.organizationId)
18+
.where('email', value)
19+
.whereNull('accepted_at')
20+
.whereNull('canceled_at')
21+
.select('id')
22+
.first()
23+
24+
if (inviteMatch) return false
25+
26+
// 2. make sure the user isn't already an organization member
27+
const orgMatch = await db
28+
.from('organization_users')
29+
.join('users', 'organization_users.user_id', 'users.id')
30+
.where('organization_users.organization_id', field.meta.organizationId)
31+
.where('users.email', value)
32+
.select('users.id')
33+
.first()
34+
35+
return !orgMatch
36+
}),
37+
roleId: vine.number().exists(async (db, value) => {
38+
const match = await db.from('roles').where('id', value).select('id').first()
39+
return !!match
40+
}),
41+
})
42+
)

components.d.ts

+2
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@ declare module 'vue' {
6363
Navigation: typeof import('./inertia/components/Navigation.vue')['default']
6464
OrganizationEditCard: typeof import('./inertia/components/OrganizationEditCard.vue')['default']
6565
OrganizationSelect: typeof import('./inertia/components/OrganizationSelect.vue')['default']
66+
OrganizationuserInvitesCard: typeof import('./inertia/components/OrganizationuserInvitesCard.vue')['default']
67+
OrganizationUserInvitesCard: typeof import('./inertia/components/OrganizationUserInvitesCard.vue')['default']
6668
OrganizationUsersCard: typeof import('./inertia/components/OrganizationUsersCard.vue')['default']
6769
Select: typeof import('./inertia/components/ui/select/Select.vue')['default']
6870
SelectContent: typeof import('./inertia/components/ui/select/SelectContent.vue')['default']

0 commit comments

Comments
 (0)