Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feature: login #32

Merged
merged 40 commits into from
Jan 24, 2025
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
f59aeb4
feat: add login page
limwa Jan 5, 2025
864ed4a
style: add lint for frontend
limwa Jan 5, 2025
4537c4e
feat: initialize social authentication
limwa Jan 5, 2025
0112aed
feat: add login form and oauth verification
limwa Jan 6, 2025
55c1c9e
refactor: public url for tuyau
limwa Jan 8, 2025
9874e14
feat: add account model
limwa Jan 8, 2025
f9ddcb2
feat: login and showing invalid credential error
tomaspalma Jan 8, 2025
c0d0f51
chore: remove unsued import
tomaspalma Jan 8, 2025
91e26b0
feat: login and registering working with account and model architecture
tomaspalma Jan 16, 2025
afcdb8e
feat: middleware to redirect from login and register if user is logge…
tomaspalma Jan 17, 2025
917705f
feat: error reporting for register if email is taken or password != c…
tomaspalma Jan 17, 2025
ebf0f8a
feat: added login button to navbar and changed login and register bac…
tomaspalma Jan 17, 2025
3db2d68
feat: email and password length validation on backend
tomaspalma Jan 19, 2025
4fd21c0
Merge branch 'main' into feature/login
tomaspalma Jan 19, 2025
e10b94a
fix: confirm password does not give false positives anymore
tomaspalma Jan 19, 2025
b6e14ee
Merge branch 'main' into feature/login
limwa Jan 19, 2025
f8a99b7
fix: shadowed variable on ssr.tsx
tomaspalma Jan 20, 2025
293c3f8
feat: implement react email and part of email verification
limwa Jan 21, 2025
80bcdc8
feat: add limiter and change route names
limwa Jan 21, 2025
ed20c1b
refactor: change auth service to user service
limwa Jan 21, 2025
b456157
build: upgrade dependencies
limwa Jan 21, 2025
21f90d0
refactor: move validation messages to #start/validator
limwa Jan 21, 2025
3db18b5
chore: add migrations
limwa Jan 21, 2025
cfefe4b
feat: finish email confirmation flow, login and register
limwa Jan 22, 2025
3a17ff7
chore: make deployment script fail with invalid env variables
limwa Jan 22, 2025
291df10
chore: change entrypoint shell
limwa Jan 22, 2025
c1d5450
fix: handle caught exceptions
limwa Jan 22, 2025
e2af983
chore: allow fresh migrations on entrypoint
limwa Jan 22, 2025
3f98a65
fix: no need for inertia.always
limwa Jan 22, 2025
79ff027
feat: add cooldown to email verification page
limwa Jan 22, 2025
8be45c8
feat: make verification pages ui/ux better
limwa Jan 22, 2025
c3926e5
fix: make authentication emails use sans serif font
limwa Jan 22, 2025
3d14167
feat: small ux improvements
limwa Jan 22, 2025
e02f304
feat: use beige background in verification email
limwa Jan 22, 2025
234dd45
Update card.tsx
limwa Jan 22, 2025
6cd0f10
refactor: some changes here and there
limwa Jan 22, 2025
efce55e
chore: some more stuff
limwa Jan 23, 2025
1cf1bc9
refactor: small change
limwa Jan 23, 2025
8c6e4ef
chore: stop using verbatimModuleSyntax on frontend
limwa Jan 23, 2025
2e8352a
fix: errors while rendering status pages
limwa Jan 24, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion website/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,15 @@ [email protected]
SMTP_HOST=localhost
SMTP_PORT=1025

# Ally
GITHUB_CLIENT_ID=********
GITHUB_CLIENT_SECRET=********
GOOGLE_CLIENT_ID=********
GOOGLE_CLIENT_SECRET=********
LINKEDIN_CLIENT_ID=********
LINKEDIN_CLIENT_SECRET=********

# Frontend
INERTIA_PUBLIC_TZ=Europe/Lisbon
INERTIA_PUBLIC_EVENT_COUNTDOWN_DATE=2025-04-11
INERTIA_PUBLIC_APP_URL=http://127.0.0.1:3333
INERTIA_PUBLIC_APP_URL=http://127.0.0.1:3333
1 change: 1 addition & 0 deletions website/adonisrc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export default defineConfig({
() => import('@adonisjs/inertia/inertia_provider'),
() => import('@adonisjs/mail/mail_provider'),
() => import('@tuyau/core/tuyau_provider'),
() => import('@adonisjs/ally/ally_provider'),
],

/*
Expand Down
111 changes: 111 additions & 0 deletions website/app/controllers/authentication_controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import Account from '#models/account'
import { socialAccountLoginValidator } from '#validators/account'
import User from '#models/user'
import type { HttpContext } from '@adonisjs/core/http'
import { createUserValidator, createUserValidatorErrorMessage } from '#validators/authentication'

async function getOrCreate(search: Pick<Account, 'provider' | 'providerId'>) {
const account = await Account.firstOrCreate({
provider: search.provider,
providerId: search.providerId,
})

return account
}

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

try {
const account = await Account.verifyCredentials(email, password)

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

response.redirect('/')
} catch (error) {
session.flash('errors', { oauth: 'Email ou palavra-passe incorretos' })
return response.redirect().back()
}
}

async register({ request, auth, response, session }: HttpContext) {
try {
await request.validateUsing(createUserValidator)
} catch (error) {
session.flash('errors', { oauth: createUserValidatorErrorMessage(error) })
return response.redirect().toRoute('view.register')
}

const { email, password } = request.only(['email', 'password'])

if (await User.query().where('email', email).first()) {
session.flash('errors', { oauth: 'Este e-mail já está em uso' })
return response.redirect().toRoute('view.register')
}

try {
const user = await User.create({ email })

await Account.create({
provider: 'credentials',
providerId: email,
password: password,
user_id: user.id,
})

await auth.use('web').login(user)

response.redirect('/')
} catch (error) {
session.flash('errors', { oauth: 'Ocorreu um erro no registo' })
return response.redirect().toRoute('view.register')
}
}

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

async callbackForGithubLogin({ response, ally }: HttpContext) {
const github = ally.use('github')
const user = await github.user()

const data = await socialAccountLoginValidator.validate(user)
console.log(data)

const account = await getOrCreate({
provider: 'github',
providerId: data.id,
})

return response.json({ user, account: account.serialize() })
}

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

async callbackForGoogleLogin({ response, ally }: HttpContext) {
const google = ally.use('google')
const user = await google.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 })
}
}
6 changes: 6 additions & 0 deletions website/app/exceptions/handler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import app from '@adonisjs/core/services/app'
import { HttpContext, ExceptionHandler } from '@adonisjs/core/http'
import type { StatusPageRange, StatusPageRenderer } from '@adonisjs/core/types/http'
import { errors } from '@vinejs/vine'

export default class HttpExceptionHandler extends ExceptionHandler {
/**
Expand Down Expand Up @@ -30,6 +31,11 @@ export default class HttpExceptionHandler extends ExceptionHandler {
* response to the client
*/
async handle(error: unknown, ctx: HttpContext) {
if (error instanceof errors.E_VALIDATION_ERROR) {
ctx.response.status(422).send(error.messages)
return
}

return super.handle(error, ctx)
}

Expand Down
8 changes: 8 additions & 0 deletions website/app/messages.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export const messages = {
auth: {
oauth: {
accessDenied: 'O pedido de início de sessão foi rejeitado.',
stateMismatch: 'Ocorreu um erro ao iniciar sessão. Por favor, tenta novamente.',
},
},
} as const
14 changes: 14 additions & 0 deletions website/app/middleware/redirect_if_authenticated_middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'

export default class RedirectIfAuthenticatedMiddleware {
async handle({ auth, response }: HttpContext, next: NextFn) {
if (await auth.check()) response.redirect().back()

/**
* Call next method in the pipeline and return its output
*/
const output = await next()
return output
}
}
34 changes: 34 additions & 0 deletions website/app/middleware/verify_social_callback_middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { SocialProviders } from '@adonisjs/ally/types'
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import { messages } from '../messages.js'

export default class VerifySocialCallbackMiddleware {
async handle(
{ response, session, ally }: HttpContext,
next: NextFn,
options: { provider: keyof SocialProviders }
) {
const oauth = ally.use(options.provider)

if (oauth.accessDenied()) {
session.flashErrors({ oauth: messages.auth.oauth.accessDenied })
return response.redirect('/login')
}

if (oauth.stateMisMatch()) {
session.flashErrors({ oauth: messages.auth.oauth.stateMismatch })
return response.redirect('/login')
}

const postRedirectError = oauth.getError()
if (postRedirectError) {
session.flashErrors({ oauth: postRedirectError })
return response.redirect('/login')
}

const output = await next()

return output
}
}
36 changes: 36 additions & 0 deletions website/app/models/account.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { DateTime } from 'luxon'
import { BaseModel, column, hasOne } from '@adonisjs/lucid/orm'
import type { HasOne } from '@adonisjs/lucid/types/relations'
import { SocialProviders } from '@adonisjs/ally/types'
import { compose } from '@adonisjs/core/helpers'
import hash from '@adonisjs/core/services/hash'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
import User from './user.js'

const AuthFinder = withAuthFinder(() => hash.use('scrypt'), {
uids: ['provider', 'providerId'],
passwordColumnName: 'password',
})

export default class Account extends compose(BaseModel, AuthFinder) {
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime

@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime

@column()
declare provider: 'credentials' | keyof SocialProviders

@column()
declare providerId: string

@column({ serializeAs: null })
declare password: string

@column()
declare user_id: number

@hasOne(() => User)
declare user: HasOne<typeof User>
}
13 changes: 1 addition & 12 deletions website/app/models/user.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,7 @@
import { DateTime } from 'luxon'
import hash from '@adonisjs/core/services/hash'
import { compose } from '@adonisjs/core/helpers'
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'

const AuthFinder = withAuthFinder(() => hash.use('scrypt'), {
uids: ['email'],
passwordColumnName: 'password',
})

export default class User extends compose(BaseModel, AuthFinder) {
export default class User extends BaseModel {
@column({ isPrimary: true })
declare id: number

Expand All @@ -19,9 +11,6 @@ export default class User extends compose(BaseModel, AuthFinder) {
@column()
declare email: string

@column({ serializeAs: null })
declare password: string

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

Expand Down
13 changes: 13 additions & 0 deletions website/app/validators/account.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import vine from '@vinejs/vine'

vine.convertEmptyStringsToNull = true

export const socialAccountLoginValidator = vine.compile(
vine.object({
id: vine
.string()
.parse((value) =>
typeof value === 'string' ? value : typeof value === 'number' ? value.toString() : null
),
})
)
22 changes: 22 additions & 0 deletions website/app/validators/authentication.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import vine from '@vinejs/vine'
import { VineValidationError } from '../../types/validation.js'

export const createUserValidator = vine.compile(
vine.object({
email: vine.string().email(),
password: vine.string().minLength(8).confirmed(),
})
)

export const createUserValidatorErrorMessage = (error: VineValidationError) => {
const rule = error.messages[0].rule

switch (rule) {
case 'email':
return 'E-mail inválido'
case 'minLength':
return 'Palavra-passe tem de ter no mínimo 8 caratéres'
case 'confirmed':
return 'Palavras-passe não coincidem'
}
}
28 changes: 28 additions & 0 deletions website/config/ally.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import env from '#start/env'
import { defineConfig, services } from '@adonisjs/ally'

const allyConfig = defineConfig({
github: services.github({
clientId: env.get('GITHUB_CLIENT_ID'),
clientSecret: env.get('GITHUB_CLIENT_SECRET'),
callbackUrl: '',
scopes: ['user:email'],
allowSignup: false,
}),
google: services.google({
clientId: env.get('GOOGLE_CLIENT_ID'),
clientSecret: env.get('GOOGLE_CLIENT_SECRET'),
callbackUrl: '',
}),
linkedin: services.linkedin({
clientId: env.get('LINKEDIN_CLIENT_ID'),
clientSecret: env.get('LINKEDIN_CLIENT_SECRET'),
callbackUrl: '',
}),
})

export default allyConfig

declare module '@adonisjs/ally/types' {
interface SocialProviders extends InferSocialProviders<typeof allyConfig> {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ export default class extends BaseSchema {
table.increments('id').notNullable()
table.string('full_name').nullable()
table.string('email', 254).notNullable().unique()
table.string('password').notNullable()

table.timestamp('created_at').notNullable()
table.timestamp('updated_at').nullable()
Expand Down
24 changes: 24 additions & 0 deletions website/database/migrations/1736362099709_create_accounts_table.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { BaseSchema } from '@adonisjs/lucid/schema'

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

async up() {
this.schema.createTable(this.tableName, (table) => {
table.timestamp('created_at')
table.timestamp('updated_at')

table.string('provider').notNullable()
table.string('provider_id').notNullable()
table.primary(['provider', 'provider_id'])

table.string('password')

table.integer('user_id').references('id').inTable('users')
})
}

async down() {
this.schema.dropTable(this.tableName)
}
}
11 changes: 9 additions & 2 deletions website/eslint.config.js
Original file line number Diff line number Diff line change
@@ -1,2 +1,9 @@
import { configApp } from '@adonisjs/eslint-config'
export default configApp()
import { configApp, RULES_LIST, INCLUDE_LIST, IGNORE_LIST } from '@adonisjs/eslint-config'
export default configApp({
files: [...INCLUDE_LIST, '**/*.tsx'],
ignores: [
...IGNORE_LIST.filter(ignore => !ignore.startsWith('resources/')),
"inertia/components/ui/**/*"
],
rules: RULES_LIST
})
Loading
Loading