Skip to content

Commit

Permalink
feat: finish email confirmation flow, login and register
Browse files Browse the repository at this point in the history
  • Loading branch information
limwa committed Jan 22, 2025
1 parent 3db18b5 commit cfefe4b
Show file tree
Hide file tree
Showing 44 changed files with 551 additions and 191 deletions.
8 changes: 7 additions & 1 deletion website/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@ SESSION_DRIVER=cookie

# E-mail
FROM_EMAIL=[email protected]
REPLY_TO_EMAIL=[email protected]
SMTP_HOST=localhost
SMTP_PORT=1025

# Rate limiting
LIMITER_STORE=memory

# Ally
GITHUB_CLIENT_ID=********
GITHUB_CLIENT_SECRET=********
Expand All @@ -30,4 +34,6 @@ LINKEDIN_CLIENT_SECRET=********
INERTIA_PUBLIC_TZ=Europe/Lisbon
INERTIA_PUBLIC_EVENT_COUNTDOWN_DATE=2025-04-11
INERTIA_PUBLIC_APP_URL=http://127.0.0.1:3333
LIMITER_STORE=redis
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
3 changes: 2 additions & 1 deletion website/adonisrc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,8 @@ export default defineConfig({
() => import('@adonisjs/mail/mail_provider'),
() => import('@tuyau/core/tuyau_provider'),
() => import('@adonisjs/ally/ally_provider'),
() => import('@adonisjs/limiter/limiter_provider')
() => import('@adonisjs/limiter/limiter_provider'),
() => import('@adonisjs/redis/redis_provider')
],

/*
Expand Down
133 changes: 79 additions & 54 deletions website/app/controllers/authentication_controller.ts
Original file line number Diff line number Diff line change
@@ -1,89 +1,114 @@
import Account from '#models/account'
import { socialAccountLoginValidator } from '#validators/account'
import User from '#models/user'
import type { HttpContext } from '@adonisjs/core/http'
import { registerWithCredentialsValidator } from '#validators/authentication'
import {
registerWithCredentialsValidator,
emailVerificationCallbackValidator,
loginWithCredentialsValidator,
} from '#validators/authentication'
import { UserService } from '#services/user_service'
import { inject } from '@adonisjs/core'
import UserCreated from '#events/user_created'
import SendVerificationEmail from '#listeners/send_verification_email'
import { errors } from '@adonisjs/auth'

@inject()
export default class AuthenticationController {
async login({ request, auth, response, session }: HttpContext) {
const { email, password } = request.only(['email', 'password'])
constructor(private userService: UserService) {}

async login({ request, auth, session, response }: HttpContext) {
const { email, password } = await request.validateUsing(loginWithCredentialsValidator)

try {
const account = await Account.verifyCredentials(email, password)
const account = await Account.verifyCredentials(`credentials:${email}`, password)

const user = await User.query().where('id', account.userId).first()
if (user) await auth.use('web').login(user)
await account.load('user')
await auth.use('web').login(account.user)

response.redirect('/')
return response.redirect().toRoute('pages:home')

} catch (error) {
session.flash('errors', { oauth: 'Email ou palavra-passe incorretos' })
return response.redirect().back()
if (error instanceof errors.E_INVALID_CREDENTIALS) {
session.flashErrors({ password: 'As credenciais que introduziste não são válidas' })
return response.redirect().back()
}
}
}

@inject()
async register({ request, auth, response }: HttpContext, userService: UserService) {
async logout({ auth, response }: HttpContext) {
await auth.use('web').logout()
return response.redirect().toRoute('pages:home')
}

async register({ request, auth, response }: HttpContext) {
const { email, password } = await request.validateUsing(registerWithCredentialsValidator)

const user = await userService.createUserWithCredentials(email, password)
const user = await this.userService.createUserWithCredentials(email, password)
await auth.use('web').login(user)

return response.redirect().toRoute('auth.email-confirmation.show')
return response.redirect().toRoute('pages:auth.verify')
}

async showEmailConfirmation({ inertia }: HttpContext) {
return inertia.render('email_confirmation')
}
async retryEmailVerification({ auth, response }: HttpContext) {
const user = auth.getUserOrFail()

async verify({ request, view }: HttpContext) {
if (request.method() === 'POST') return request.toJSON()
return view.render('automatic_submit')
const listener = new SendVerificationEmail()
listener.handle(new UserCreated(user))

return response.redirect().toRoute('pages:auth.verify')
}

async initiateGithubLogin({ ally, inertia }: HttpContext) {
const url = await ally.use('github').redirectUrl()
console.log(url)
return inertia.location(url)
async callbackForEmailVerification({ request, view, response }: HttpContext) {
if (request.method() !== 'POST') return view.render('automatic_submit')

const { email } = await request.validateUsing(emailVerificationCallbackValidator)
await this.userService.verifyEmail(email)

return response.redirect().toRoute('actions:auth.verify.success')
}

async callbackForGithubLogin({ ally }: HttpContext) {
const github = ally.use('github')
const user = await github.user()
// SOCIAL AUTHENTICATION

const data = await socialAccountLoginValidator.validate(user)
console.log(data)
// async initiateGithubLogin({ ally, inertia }: HttpContext) {
// const url = await ally.use('github').redirectUrl()
// return inertia.location(url)
// }

// const account = await getOrCreate({
// provider: 'github',
// providerId: data.id,
// })
// async callbackForGithubLogin({ ally }: HttpContext) {
// const github = ally.use('github')
// const user = await github.user()

// return response.json({ user, account: account.serialize() })
}
// const data = await socialAccountLoginValidator.validate(user)
// console.log(data)

async initiateGoogleLogin({ ally, inertia }: HttpContext) {
const url = await ally.use('google').redirectUrl()
return inertia.location(url)
}
// const account = await getOrCreate({
// provider: 'github',
// providerId: data.id,
// })

async callbackForGoogleLogin({ response, ally }: HttpContext) {
const google = ally.use('google')
const user = await google.user()
// return response.json({ user, account: account.serialize() })
// }

return response.json({ user })
}
// async initiateGoogleLogin({ ally, inertia }: HttpContext) {
// const url = await ally.use('google').redirectUrl()
// return inertia.location(url)
// }

async initiateLinkedinLogin({ ally, inertia }: HttpContext) {
const url = await ally.use('linkedin').redirectUrl()
return inertia.location(url)
}
// async callbackForGoogleLogin({ response, ally }: HttpContext) {
// const google = ally.use('google')
// const user = await google.user()

async callbackForLinkedinLogin({ response, ally }: HttpContext) {
const linkedin = ally.use('linkedin')
const user = await linkedin.user()
// return response.json({ user })
// }

return response.json({ user })
}
// async initiateLinkedinLogin({ ally, inertia }: HttpContext) {
// const url = await ally.use('linkedin').redirectUrl()
// return inertia.location(url)
// }

// async callbackForLinkedinLogin({ response, ally }: HttpContext) {
// const linkedin = ally.use('linkedin')
// const user = await linkedin.user()

// return response.json({ user })
// }
}
8 changes: 8 additions & 0 deletions website/app/events/user_email_verified.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import type User from '#models/user'
import { BaseEvent } from '@adonisjs/core/events'

export default class UserEmailVerified extends BaseEvent {
constructor(public readonly user: User) {
super()
}
}
28 changes: 11 additions & 17 deletions website/app/listeners/send_verification_email.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,23 @@
import UserCreated from '#events/user_created'
import EmailVerificationNotification from '#mails/email_verification_notification'
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import UserCreated from "#events/user_created";
import EmailVerificationNotification from "#mails/email_verification_notification";
import mail from "@adonisjs/mail/services/main";
import { buildUrl, staticUrl } from "../url.js";

export default class SendVerificationEmail {
async handle(event: UserCreated) {
// Don't send the verification e-mail if the user has already verified it
if (event.user.emailVerifiedAt) return

const mailer = await app.container.make('mail.manager')
const router = await app.container.make('router')

const email = event.user.email
if (event.user.emailVerifiedAt) return;

const email = event.user.email;
const notification = new EmailVerificationNotification({
email,
logoUrl: '/images/logo-white.svg',
logoUrl: staticUrl("/images/logo-white.png"),

verificationLink: router
.builder()
verificationLink: buildUrl()
.qs({ email })
.prefixUrl(env.get("INERTIA_PUBLIC_APP_URL"))
.makeSigned('auth.verify', { expiresIn: '1h' }),
})
.makeSigned("actions:auth.verify.callback", { expiresIn: "1h" }),
});

await mailer.send(notification)
await mail.send(notification);
}
}
9 changes: 4 additions & 5 deletions website/app/mails/email_verification_notification.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import { ReactNotification } from './base/react_notification.js'
import type { EmailVerificationProps } from "#resources/emails/authentication/email_verification"
import type { EmailVerificationProps } from '#resources/emails/authentication/email_verification'

export default class EmailVerificationNotification extends ReactNotification {
constructor(
private props: EmailVerificationProps
) {
constructor(private props: EmailVerificationProps) {
super()
}

async prepare() {
this.message.to(this.props.email)
this.message.to(this.props.email).subject('Confirma o teu e-mail!')

await this.jsx(() => import('#resources/emails/authentication/email_verification'), this.props)
}
}
2 changes: 1 addition & 1 deletion website/app/middleware/auth_middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export default class AuthMiddleware {
/**
* The URL to redirect to, when authentication fails
*/
redirectTo = '/login'
redirectTo = '/auth/login'

async handle(
ctx: HttpContext,
Expand Down
13 changes: 13 additions & 0 deletions website/app/middleware/verify_url_signature_middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'

export default class VerifyUrlSignatureMiddleware {
async handle({request, response}: HttpContext, next: NextFn) {
if (!request.hasValidSignature()) {
return response.badRequest("Invalid or expired URL")
}

const output = await next()
return output
}
}
8 changes: 4 additions & 4 deletions website/app/models/account.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { DateTime } from 'luxon'
import { BaseModel, column, hasOne } from '@adonisjs/lucid/orm'
import type { HasOne } from '@adonisjs/lucid/types/relations'
import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'
import type { SocialProviders } from '@adonisjs/ally/types'
import { compose } from '@adonisjs/core/helpers'
import hash from '@adonisjs/core/services/hash'
Expand Down Expand Up @@ -31,8 +31,8 @@ export default class Account extends compose(BaseModel, AuthFinder) {
@column()
declare userId: number

@hasOne(() => User)
declare user: HasOne<typeof User>
@belongsTo(() => User)
declare user: BelongsTo<typeof User>

static findByCredentials(email: string) {
return this.findForAuth(['id'], `credentials:${email}`)
Expand Down
6 changes: 5 additions & 1 deletion website/app/models/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ export default class User extends BaseModel {
@column()
declare email: string

@column()
@column.dateTime()
declare emailVerifiedAt: DateTime | null

@column.dateTime({ autoCreate: true })
Expand All @@ -21,4 +21,8 @@ export default class User extends BaseModel {

@hasMany(() => Account)
declare accounts: HasMany<typeof Account>

isEmailVerified() {
return this.emailVerifiedAt !== null
}
}
23 changes: 23 additions & 0 deletions website/app/services/user_service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import UserCreated from '#events/user_created'
import UserEmailVerified from '#events/user_email_verified'
import SendVerificationEmail from '#listeners/send_verification_email'
import User from '#models/user'
import db from '@adonisjs/lucid/services/db'
import { DateTime } from 'luxon'

export class UserService {
async createUserWithCredentials(email: string, password: string) {
Expand All @@ -15,4 +18,24 @@ export class UserService {

return committedUser
}

sendVerificationEmail(user: User) {
const listener = new SendVerificationEmail()
listener.handle(new UserCreated(user))
}

async verifyEmail(email: string) {
const verifiedUser = await db.transaction(async (trx) => {
const user = await User.findByOrFail('email', email, { client: trx })
if (user.isEmailVerified()) return null

user.emailVerifiedAt = DateTime.now()
return await user.save()
})

if (!verifiedUser) return null

UserEmailVerified.dispatch(verifiedUser)
return verifiedUser
}
}
12 changes: 12 additions & 0 deletions website/app/url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import env from "#start/env";
import router from "@adonisjs/core/services/router";

const base = env.get("INERTIA_PUBLIC_APP_URL");

export function staticUrl(path: string) {
return new URL(path, base).toString();
}

export function buildUrl() {
return router.builder().prefixUrl(base);
}
Loading

0 comments on commit cfefe4b

Please sign in to comment.