Skip to content

Commit

Permalink
[SOA-55] Password recovery (#57)
Browse files Browse the repository at this point in the history
* feat 🎸 (be): add generic user_tokens model

* feat 🎸 (be): add service and controller methods for generating token and updating password

* styles 💅 : add pages for reseting and updating password

* fix ✅ : resolve comment

* test 🧪 (unit): add unit tests for the new service methods

* fix ✅ : typo

* feat 🎸 (wip): prepare mail notification

* feat 🎸 (be): add reset access email template

* fix ✅ : general corrections

* fix ✅ (be): user follow flaky behaviour

* fix ✅ (fe): correction of tslint error suppression
  • Loading branch information
mariadriana-deemaze authored Dec 10, 2024
1 parent 0a0a387 commit 05762bc
Show file tree
Hide file tree
Showing 23 changed files with 672 additions and 24 deletions.
54 changes: 54 additions & 0 deletions app/controllers/auth_controller.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { HttpContext } from '@adonisjs/core/http'
import AuthService from '#services/auth_service'
import { inject } from '@adonisjs/core'
import User from '#models/user'
import { updateAuthValidator } from '#validators/auth'
import { errors } from '@vinejs/vine'
import { errorsReducer } from '#utils/index'

@inject()
export default class AuthController {
Expand All @@ -13,6 +17,56 @@ export default class AuthController {
return await this.authService.show(ctx)
}

async update({ request, session, response }: HttpContext): Promise<void> {
const token: string | null = request.qs().token
const payload = request.body()

try {
if (!token) throw Error('Invalid request')
const data = await updateAuthValidator.validate(payload)
await this.authService.update(token, data)
return response.redirect().toRoute('auth.show')
} catch (error) {
if (error instanceof errors.E_VALIDATION_ERROR) {
const reducedErrors = errorsReducer(error.messages)
session.flash('errors', reducedErrors)
} else {
session.flash('errors', {
password: 'Error updating',
})
}
return response.redirect().withQs({ token }).toRoute('auth.update')
}
}

async reset({ request, response, session }: HttpContext): Promise<void> {
const { email } = request.only(['email'])

const user = await User.findBy('email', email)
if (!user) {
session.flash('errors', {
email: 'User not found',
})
return response.redirect().back()
}

try {
await this.authService.reset(user)
session.flash('notification', {
type: 'success',
message: 'Request submitted with success. Check your inbox.',
})
return response.redirect().toRoute('auth.show')
} catch (error) {
console.error('error ->', error)
// TODO: Refactor as generic notification, diferentiated by type.
session.flash('errors', {
message: 'Invalid request',
})
return response.redirect().toRoute('auth.show')
}
}

async destroy(ctx: HttpContext) {
return await this.authService.destroy(ctx)
}
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/user_follows_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export default class UserFollowsController {
async show(ctx: HttpContext) {
const currentUserId = ctx.auth.user?.id!
const followerId = ctx.params.userId
const relation = await this.service.show(currentUserId, followerId)
const relation = await this.service.show(followerId, currentUserId)
return ctx.response.ok({ following: !!relation })
}

Expand Down
3 changes: 3 additions & 0 deletions app/enums/user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export enum UserTokenType {
RESET_ACCESS = 'RESET_ACCESS',
}
8 changes: 8 additions & 0 deletions app/listeners/trigger_auth_reset_notification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import User from '#models/user'
import AuthResetNotification from '#notifications/auth_reset_notification'

export default class TriggerAuthResetNotification {
async handle({ user, token }: { user: User; token: string }) {
user.notify(new AuthResetNotification(token))
}
}
13 changes: 13 additions & 0 deletions app/mails/reset_access_mail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { renderToStaticMarkup } from 'react-dom/server'
import PlatformBaseMailNotification from '#mails/base'
import Template from '#mails/templates/reset_access_mail'
import User from '#models/user'

export default class ResetAccessMail extends PlatformBaseMailNotification {
constructor(notifiable: User, token: string) {
super(notifiable, {
subject: 'Account access reset',
body: renderToStaticMarkup(<Template user={notifiable} token={token} />),
})
}
}
81 changes: 81 additions & 0 deletions app/mails/templates/reset_access_mail.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import User from '#models/user'
import { route } from '@izzyjs/route/client'
// FIX-ME: Getting import errors `app/app/...` - app appended twice.
// import { Button } from '@/components/ui/button'
// import AdonisLogo from '../../../inertia/components/svg/logo'
// import { Button } from '../../../inertia/components/ui/button'

const hr = 'bg-gray-100 my-5'
const paragraph = 'text-sm text-slate-400 text-left'
const baseUrl =
'http://' +
(process.env['NODE_ENV'] === 'development'
? 'localhost:3000'
: process.env['PRODUCTION_URL'] || 'social-adonis.fly.dev')

export default function Template({ user, token }: { user: User; token: string }) {
return (
<html>
<head>
<script src="https://cdn.tailwindcss.com" />
</head>

<body
style={{
backgroundColor: '#f6f9fc',
fontFamily:
'-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Ubuntu,sans-serif',
}}
>
{/* <AdonisLogo /> */}
<a href={baseUrl + route('home.show').path}>
<svg className="h-16 w-16 fill-primary" viewBox="0 0 33 33">
<path
fillRule="evenodd"
d="M0 16.333c0 13.173 3.16 16.333 16.333 16.333 13.173 0 16.333-3.16 16.333-16.333C32.666 3.16 29.506 0 16.333 0 3.16 0 0 3.16 0 16.333Zm6.586 3.393L11.71 8.083c.865-1.962 2.528-3.027 4.624-3.027 2.096 0 3.759 1.065 4.624 3.027l5.123 11.643c.233.566.432 1.297.432 1.93 0 2.893-2.029 4.923-4.923 4.923-.986 0-1.769-.252-2.561-.506-.812-.261-1.634-.526-2.695-.526-1.048 0-1.89.267-2.718.529-.801.253-1.59.503-2.538.503-2.894 0-4.923-2.03-4.923-4.924 0-.632.2-1.363.432-1.929Zm9.747-9.613-5.056 11.443c1.497-.699 3.227-1.032 5.056-1.032 1.763 0 3.56.333 4.99 1.032l-4.99-11.444Z"
clipRule="evenodd"
/>
</svg>
</a>

<hr className="my-5 bg-gray-100" />

<p className={paragraph}>
Hello {user.name} {user.surname},
</p>

<p className={paragraph}>Click the button below to reset your account access.</p>

<button className="h-10 rounded-md bg-blue-500 text-white">
<a
href={
baseUrl +
route('auth.update', {
qs: {
token,
},
}).path
}
>
Update password
</a>
</button>

{/* // FOOTER partial */}
<p className={paragraph}>
We'll be here to help you with any step along the way. You can find answers to most
questions and get in touch with us on our{' '}
<a className="text-blue-500" href={baseUrl}>
support site
</a>
.
</p>
<p className={paragraph}>— The Social Adonis team</p>
<hr className={hr} />
<p className="text-xs text-cyan-200">
Social Adonis, 354 Oyster Point Blvd, South San Francisco, CA 94080
</p>
</body>
</html>
)
}
33 changes: 33 additions & 0 deletions app/models/user_token.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { DateTime } from 'luxon'
import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm'
import User from '#models/user'
import { UserTokenType } from '#enums/user'
import { createHash, type UUID } from 'node:crypto'
import type { BelongsTo } from '@adonisjs/lucid/types/relations'

export default class UserToken extends BaseModel {
@column({ isPrimary: true })
declare id: UUID

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

@column()
declare type: UserTokenType

@column()
declare userId: UUID

@column()
declare token: string

@column.dateTime({ autoCreate: true })
declare createdAt: DateTime

@column.dateTime()
declare expiresAt: DateTime
}

export function generateNewToken(date: DateTime) {
return createHash('sha256').update(date.toJSDate().toISOString()).digest('hex')
}
19 changes: 19 additions & 0 deletions app/notifications/auth_reset_notification.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { NotificationChannelName, NotificationContract } from '@osenco/adonisjs-notifications/types'
import type User from '#models/user'
import ResetAccessMail from '#mails/reset_access_mail'

export default class AuthResetNotification implements NotificationContract<User> {
private token: string

constructor(token: string) {
this.token = token
}

via(): NotificationChannelName | Array<NotificationChannelName> {
return 'mail'
}

toMail(notifiable: User) {
return new ResetAccessMail(notifiable, this.token)
}
}
64 changes: 63 additions & 1 deletion app/services/auth_service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
import type { HttpContext } from '@adonisjs/core/http'
import { createAuthValidator } from '#validators/auth'
import hash from '@adonisjs/core/services/hash'
import User from '#models/user'
import Session from '#models/session'
import { errors } from '@vinejs/vine'
import { errorsReducer } from '#utils/index'
import UserToken, { generateNewToken } from '#models/user_token'
import { UserTokenType } from '#enums/user'
import { DateTime } from 'luxon'
import { isAfter } from 'date-fns'
import db from '@adonisjs/lucid/services/db'
import type { HttpContext } from '@adonisjs/core/http'
import emitter from '@adonisjs/core/services/emitter'

export default class AuthService {
async create(ctx: HttpContext) {
Expand Down Expand Up @@ -40,6 +47,61 @@ export default class AuthService {
}
}

async reset(user: User): Promise<void> {
const expiresAt = DateTime.now().plus({ minutes: 10 })
const token = generateNewToken(expiresAt)
await UserToken.updateOrCreate(
{
type: UserTokenType.RESET_ACCESS,
userId: user.id,
},
{
type: UserTokenType.RESET_ACCESS,
userId: user.id,
token,
expiresAt,
}
)
emitter.emit('auth:reset', { user, token })
}

async update(
token: string,
{
password,
}: {
password: string
passwordConfirmation: string
}
): Promise<void> {
const record = await UserToken.findBy('token', token)

if (!record) {
throw Error('Invalid request')
}

if (record && isAfter(new Date(), record.expiresAt.toJSDate())) {
await record.delete()
throw Error('Token no longer valid')
}

const trx = await db.transaction()

try {
const encriptedPassword = await hash.make(password)
await trx
.query()
.from('users')
.where('id', record.userId)
.update({ password: encriptedPassword })
await trx.query().from('user_tokens').where('id', record.id).delete()
await trx.commit()
} catch (error) {
await trx.rollback()
throw Error(error)
}
}

async destroy({ auth, session, response }: HttpContext) {
await auth.user!.related('sessions').query().where('sessionToken', session.sessionId).delete()
await auth.use('web').logout()
Expand Down
4 changes: 2 additions & 2 deletions app/services/user_follow_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ export default class UserFollowService {

async show(currentUserId: UUID, followerUserId: UUID): Promise<UserFollower | null> {
const [relation] = await UserFollower.query()
.where('user_id', followerUserId)
.andWhere('follower_id', currentUserId)
.where('user_id', currentUserId)
.andWhere('follower_id', followerUserId)
.limit(1)
return relation
}
Expand Down
2 changes: 2 additions & 0 deletions app/types/events.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import Post from '#models/post'
import User from '#models/user'

declare module '@adonisjs/core/types' {
interface EventsList {
'auth:reset': { user: User; token: string }
'post:mention': [string[], Post]
}
}
12 changes: 12 additions & 0 deletions app/validators/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,18 @@ export const createAuthValidator = vine.compile(
})
)

/**
* Validates the account creation action payload
*/
export const updateAuthValidator = vine.compile(
vine.object({
password: vine.string().minLength(8).maxLength(32).confirmed({
confirmationField: 'passwordConfirmation',
}),
passwordConfirmation: vine.string().minLength(8).maxLength(32),
})
)

createAuthValidator.messagesProvider = new SimpleMessagesProvider({
required: 'The {{ field }} field is required',
string: 'The value of {{ field }} field must be a string',
Expand Down
1 change: 1 addition & 0 deletions config/inertia.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ const inertiaConfig = defineConfig({
},
queryParams: (ctx) => ctx.request.qs(),
errors: (ctx) => ctx.session?.flashMessages.get('errors'),
notification: (ctx) => ctx.session?.flashMessages.get('notification') ?? {},
domain: () => env.get('PRODUCTION_URL'),
},

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { UserTokenType } from '#enums/user'
import { BaseSchema } from '@adonisjs/lucid/schema'

export default class extends BaseSchema {
protected tableName = 'user_tokens'

async up() {
this.schema.createTable(this.tableName, (table) => {
table.uuid('id').primary().defaultTo(this.db.rawQuery('uuid_generate_v4()').knexQuery)
table.uuid('user_id').references('users.id').notNullable()
table.enu('type', Object.values(UserTokenType), {
useNative: true,
enumName: 'user_token_types',
existingType: false,
}).defaultTo(UserTokenType.RESET_ACCESS).notNullable()
table.string('token').notNullable()
table.timestamp('created_at', { useTz: false }).notNullable()
table.timestamp('expires_at', { useTz: false }).notNullable().comment('If consulted after this date, it is considered to be an expired record.')
})
}

async down() {
this.schema.dropTable(this.tableName)
this.schema.raw('DROP TYPE IF EXISTS "user_token_types"')
}
}
Loading

0 comments on commit 05762bc

Please sign in to comment.