diff --git a/deploy/website/entrypoint.sh b/deploy/website/entrypoint.sh index 723139a..08a9617 100755 --- a/deploy/website/entrypoint.sh +++ b/deploy/website/entrypoint.sh @@ -1,6 +1,7 @@ -#!/bin/sh +#!/bin/bash set -e +set -o pipefail # Script configuration @@ -28,9 +29,13 @@ run_command() { echo print "# $command" + trap "print_file $_LOG_FILE" EXIT + # Strip any ANSI escape sequences from the output eval "$command" 2>&1 | sed "s/\x1B\[[0-9;]*[a-zA-Z]//g" > "$_LOG_FILE" + trap - EXIT + print_file "$_LOG_FILE" echo } @@ -120,11 +125,13 @@ print ">> Application preparation" push_indent " " if [ "$ON_STARTUP_MIGRATE" = "true" ]; then - print ">> MIGRATE_ON_STARTUP is enabled" + mode="${ON_STARTUP_MIGRATE_MODE:-run}" + + print ">> ON_STARTUP_MIGRATE is enabled [mode: $mode]" push_indent " " print "Migrating database..." - run_command node ace migration:run --force + run_command node ace migration:$mode --force pop_indent fi diff --git a/website/.env.example b/website/.env.example index 75d73a3..e148677 100644 --- a/website/.env.example +++ b/website/.env.example @@ -15,10 +15,32 @@ SESSION_DRIVER=cookie # E-mail FROM_EMAIL=noreply@eneiconf.pt +REPLY_TO_EMAIL=geral@eneiconf.pt SMTP_HOST=localhost SMTP_PORT=1025 +# Rate limiting +LIMITER_STORE=memory + +# Redis +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_PASSWORD= + +# Ally +GITHUB_CLIENT_ID=******** +GITHUB_CLIENT_SECRET=******** +GOOGLE_CLIENT_ID=******** +GOOGLE_CLIENT_SECRET=******** +LINKEDIN_CLIENT_ID=******** +LINKEDIN_CLIENT_SECRET=******** + +# Feature flags +FEATURES_DISABLE_AUTH=true + # Frontend INERTIA_PUBLIC_TZ=Europe/Lisbon INERTIA_PUBLIC_EVENT_COUNTDOWN_DATE=2025-04-11 + +# Tuyau INERTIA_PUBLIC_APP_URL=http://127.0.0.1:3333 diff --git a/website/adonisrc.ts b/website/adonisrc.ts index 8a1efd4..e065fba 100644 --- a/website/adonisrc.ts +++ b/website/adonisrc.ts @@ -45,6 +45,9 @@ export default defineConfig({ () => import('@adonisjs/inertia/inertia_provider'), () => import('@adonisjs/mail/mail_provider'), () => import('@tuyau/core/tuyau_provider'), + () => import('@adonisjs/ally/ally_provider'), + () => import('@adonisjs/limiter/limiter_provider'), + () => import('@adonisjs/redis/redis_provider') ], /* @@ -55,7 +58,7 @@ export default defineConfig({ | List of modules to import before starting the application. | */ - preloads: [() => import('#start/routes'), () => import('#start/kernel')], + preloads: [() => import('#start/routes'), () => import('#start/kernel'), () => import('#start/events'), () => import('#start/validator')], /* |-------------------------------------------------------------------------- diff --git a/website/app/controllers/authentication_controller.ts b/website/app/controllers/authentication_controller.ts new file mode 100644 index 0000000..0f57b10 --- /dev/null +++ b/website/app/controllers/authentication_controller.ts @@ -0,0 +1,117 @@ +import Account from '#models/account' +import type { HttpContext } from '@adonisjs/core/http' +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 { + constructor(private userService: UserService) {} + + async login({ request, auth, session, response }: HttpContext) { + const { email, password } = await request.validateUsing(loginWithCredentialsValidator) + + try { + const account = await Account.verifyCredentials(`credentials:${email}`, password) + + await account.load('user') + await auth.use('web').login(account.user) + + if (!account.user.isEmailVerified()) + return response.redirect().toRoute('pages:auth.verify') + + return response.redirect().toRoute('pages:home') + + } catch (error) { + if (error instanceof errors.E_INVALID_CREDENTIALS) { + session.flashErrors({ password: 'As credenciais que introduziste não são válidas' }) + return response.redirect().back() + } + + throw error + } + } + + 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 this.userService.createUserWithCredentials(email, password) + await auth.use('web').login(user) + + return response.redirect().toRoute('pages:auth.verify') + } + + async retryEmailVerification({ auth, response }: HttpContext) { + const user = auth.getUserOrFail() + + const listener = new SendVerificationEmail() + listener.handle(new UserCreated(user)) + + return response.redirect().toRoute('pages:auth.verify') + } + + async callbackForEmailVerification({ request, response }: HttpContext) { + const { email } = await request.validateUsing(emailVerificationCallbackValidator) + await this.userService.verifyEmail(email) + + return response.redirect().toRoute('actions:auth.verify.success') + } + + // SOCIAL AUTHENTICATION + + // async initiateGithubLogin({ ally, inertia }: HttpContext) { + // const url = await ally.use('github').redirectUrl() + // return inertia.location(url) + // } + + // async callbackForGithubLogin({ 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 }) + // } +} diff --git a/website/app/env.ts b/website/app/env.ts index 72a90ad..c1f4e61 100644 --- a/website/app/env.ts +++ b/website/app/env.ts @@ -1,8 +1,8 @@ import vine from '@vinejs/vine' -import { ConstructableSchema, SchemaTypes } from '@vinejs/vine/types' +import type { ConstructableSchema, SchemaTypes } from '@vinejs/vine/types' import { EnvProcessor, Env as AdonisEnv } from '@adonisjs/core/env' -type Primitives = string | number | boolean +type Primitives = string | number | boolean | null | undefined function createObjectInterceptor() { const keys = new Set() diff --git a/website/app/events/user_created.ts b/website/app/events/user_created.ts new file mode 100644 index 0000000..49db649 --- /dev/null +++ b/website/app/events/user_created.ts @@ -0,0 +1,8 @@ +import User from '#models/user' +import { BaseEvent } from '@adonisjs/core/events' + +export default class UserCreated extends BaseEvent { + constructor(public readonly user: User) { + super() + } +} \ No newline at end of file diff --git a/website/app/events/user_email_verified.ts b/website/app/events/user_email_verified.ts new file mode 100644 index 0000000..c3eea0b --- /dev/null +++ b/website/app/events/user_email_verified.ts @@ -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() + } +} \ No newline at end of file diff --git a/website/app/exceptions/authentication_disabled_exception.ts b/website/app/exceptions/authentication_disabled_exception.ts new file mode 100644 index 0000000..6d40b15 --- /dev/null +++ b/website/app/exceptions/authentication_disabled_exception.ts @@ -0,0 +1,8 @@ +import { Exception } from '@adonisjs/core/exceptions' + +export default class AuthenticationDisabledException extends Exception { + static status = 403 + static code = "E_AUTH_DISABLED" + static message = "Authentication is disabled" + static help = "Did you forget to enable authentication in your .env file?" +} diff --git a/website/app/exceptions/handler.ts b/website/app/exceptions/handler.ts index 0ab9d3b..f1c0d0e 100644 --- a/website/app/exceptions/handler.ts +++ b/website/app/exceptions/handler.ts @@ -14,13 +14,16 @@ export default class HttpExceptionHandler extends ExceptionHandler { * codes. You might want to enable them in production only, but feel * free to enable them in development as well. */ - protected renderStatusPages = app.inProduction + protected renderStatusPages = true + + protected ignoreCodes = ["E_AUTH_DISABLED"] /** * Status pages is a collection of error code range and a callback * to return the HTML contents to send as a response. */ protected statusPages: Record = { + '403': (error, { inertia }) => inertia.render('errors/forbidden', { error }), '404': (error, { inertia }) => inertia.render('errors/not_found', { error }), '500..599': (error, { inertia }) => inertia.render('errors/server_error', { error }), } diff --git a/website/app/listeners/send_verification_email.ts b/website/app/listeners/send_verification_email.ts new file mode 100644 index 0000000..a922121 --- /dev/null +++ b/website/app/listeners/send_verification_email.ts @@ -0,0 +1,23 @@ +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 email = event.user.email; + const notification = new EmailVerificationNotification({ + email, + logoUrl: staticUrl("/images/logo-white.png"), + + verificationLink: buildUrl() + .qs({ email }) + .makeSigned("actions:auth.verify.callback", { expiresIn: "1h" }), + }); + + await mail.send(notification); + } +} diff --git a/website/app/mails/base/react_notification.ts b/website/app/mails/base/react_notification.ts new file mode 100644 index 0000000..7c5204e --- /dev/null +++ b/website/app/mails/base/react_notification.ts @@ -0,0 +1,18 @@ +import { BaseMail } from '@adonisjs/mail' +import { render } from '@react-email/components' +import type { JSX } from 'react' + +type JSXImport = () => Promise<{ default: (props: T) => JSX.Element }> + +export abstract class ReactNotification extends BaseMail { + async jsx(importer: JSXImport, props: NoInfer) { + const component = await importer().then((mod) => mod.default) + const element = component(props) + + this.message + .html(await render(element)) + .text(await render(element, { plainText: true })) + } + + abstract prepare(): void | Promise; +} diff --git a/website/app/mails/email_verification_notification.ts b/website/app/mails/email_verification_notification.ts new file mode 100644 index 0000000..1352e86 --- /dev/null +++ b/website/app/mails/email_verification_notification.ts @@ -0,0 +1,14 @@ +import { ReactNotification } from './base/react_notification.js' +import type { EmailVerificationProps } from '#resources/emails/auth/email_verification' + +export default class EmailVerificationNotification extends ReactNotification { + constructor(private props: EmailVerificationProps) { + super() + } + + async prepare() { + this.message.to(this.props.email).subject('Confirma o teu e-mail!') + + await this.jsx(() => import('#resources/emails/auth/email_verification'), this.props) + } +} diff --git a/website/app/mails/example_e_notification.ts b/website/app/mails/example_e_notification.ts deleted file mode 100644 index c78fbfd..0000000 --- a/website/app/mails/example_e_notification.ts +++ /dev/null @@ -1,31 +0,0 @@ -import env from '#start/env' -import { BaseMail } from '@adonisjs/mail' - -export default class ExampleENotification extends BaseMail { - private userEmail: string - - from = env.get('FROM_EMAIL') - subject = 'This is an example email' - - constructor(userEmail: string) { - super() - - this.userEmail = userEmail - } - - /** - * The "prepare" method is called automatically when - * the email is sent or queued. - */ - async prepare() { - this.message - .to(this.userEmail) - .subject(this.subject) - .htmlView('emails/example_email_html', { - userEmail: this.userEmail, - }) - .textView('emails/example_email_text', { - userEmail: this.userEmail, - }) - } -} diff --git a/website/app/messages.ts b/website/app/messages.ts new file mode 100644 index 0000000..620c63c --- /dev/null +++ b/website/app/messages.ts @@ -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 diff --git a/website/app/middleware/auth_middleware.ts b/website/app/middleware/auth_middleware.ts index f5a2ba3..22cfb23 100644 --- a/website/app/middleware/auth_middleware.ts +++ b/website/app/middleware/auth_middleware.ts @@ -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, diff --git a/website/app/middleware/automatic_submit_middleware.ts b/website/app/middleware/automatic_submit_middleware.ts new file mode 100644 index 0000000..6b7d0df --- /dev/null +++ b/website/app/middleware/automatic_submit_middleware.ts @@ -0,0 +1,12 @@ +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +export default class AutomaticSubmitMiddleware { + async handle({ request, response, view }: HttpContext, next: NextFn) { + const method = request.method() + if (method === "POST") return next() + + // Clever hack by virk to render Edge.js templates in middlewares + return response.status(200).send(await view.render('automatic_submit')) + } +} \ No newline at end of file diff --git a/website/app/middleware/container_bindings_middleware.ts b/website/app/middleware/container_bindings_middleware.ts index 48e6d09..97abc83 100644 --- a/website/app/middleware/container_bindings_middleware.ts +++ b/website/app/middleware/container_bindings_middleware.ts @@ -1,6 +1,6 @@ import { Logger } from '@adonisjs/core/logger' import { HttpContext } from '@adonisjs/core/http' -import { NextFn } from '@adonisjs/core/types/http' +import type { NextFn } from '@adonisjs/core/types/http' /** * The container bindings middleware binds classes to their request diff --git a/website/app/middleware/logout_if_authentication_disabled_middleware.ts b/website/app/middleware/logout_if_authentication_disabled_middleware.ts new file mode 100644 index 0000000..61d9103 --- /dev/null +++ b/website/app/middleware/logout_if_authentication_disabled_middleware.ts @@ -0,0 +1,13 @@ +import env from '#start/env' +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +export default class LogoutIfAuthenticationDisabledMiddleware { + async handle(ctx: HttpContext, next: NextFn) { + if (env.get('FEATURES_DISABLE_AUTH')) { + await ctx.auth.use('web').logout() + } + + return next() + } +} diff --git a/website/app/middleware/redirect_if_authenticated_middleware.ts b/website/app/middleware/redirect_if_authenticated_middleware.ts new file mode 100644 index 0000000..c0edef8 --- /dev/null +++ b/website/app/middleware/redirect_if_authenticated_middleware.ts @@ -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 + } +} diff --git a/website/app/middleware/require_authentication_enabled_middleware.ts b/website/app/middleware/require_authentication_enabled_middleware.ts new file mode 100644 index 0000000..da43251 --- /dev/null +++ b/website/app/middleware/require_authentication_enabled_middleware.ts @@ -0,0 +1,14 @@ +import AuthenticationDisabledException from '#exceptions/authentication_disabled_exception' +import env from '#start/env' +import type { HttpContext } from '@adonisjs/core/http' +import type { NextFn } from '@adonisjs/core/types/http' + +export default class RequireAuthEnabledMiddleware { + async handle(_ctx: HttpContext, next: NextFn) { + if (env.get('FEATURES_DISABLE_AUTH')) { + throw new AuthenticationDisabledException() + } + + return next() + } +} diff --git a/website/app/middleware/verify_social_callback_middleware.ts b/website/app/middleware/verify_social_callback_middleware.ts new file mode 100644 index 0000000..a73d970 --- /dev/null +++ b/website/app/middleware/verify_social_callback_middleware.ts @@ -0,0 +1,34 @@ +import type { 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 + } +} diff --git a/website/app/middleware/verify_url_signature_middleware.ts b/website/app/middleware/verify_url_signature_middleware.ts new file mode 100644 index 0000000..264eadf --- /dev/null +++ b/website/app/middleware/verify_url_signature_middleware.ts @@ -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 + } +} \ No newline at end of file diff --git a/website/app/models/account.ts b/website/app/models/account.ts new file mode 100644 index 0000000..a578d13 --- /dev/null +++ b/website/app/models/account.ts @@ -0,0 +1,40 @@ +import { DateTime } from 'luxon' +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' +import User from './user.js' +import { withAuthFinder } from '@adonisjs/auth/mixins/lucid' + +const AuthFinder = withAuthFinder(() => hash.use('scrypt'), { + uids: ['id'], + passwordColumnName: 'password', +}) + +type AccountProvider = 'credentials' | keyof SocialProviders +type AccountId = `${AccountProvider}:${string}` + +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({ isPrimary: true }) + declare id: AccountId + + @column({ serializeAs: null }) + declare password: string + + @column() + declare userId: number + + @belongsTo(() => User) + declare user: BelongsTo + + static findByCredentials(email: string) { + return this.findForAuth(['id'], `credentials:${email}`) + } +} diff --git a/website/app/models/user.ts b/website/app/models/user.ts index dfe4857..64b4d2d 100644 --- a/website/app/models/user.ts +++ b/website/app/models/user.ts @@ -1,30 +1,28 @@ 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' +import { BaseModel, column, hasMany } from '@adonisjs/lucid/orm' +import Account from './account.js' +import type { HasMany } from '@adonisjs/lucid/types/relations' -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 - @column() - declare fullName: string | null - @column() declare email: string - @column({ serializeAs: null }) - declare password: string + @column.dateTime() + declare emailVerifiedAt: DateTime | null @column.dateTime({ autoCreate: true }) declare createdAt: DateTime @column.dateTime({ autoCreate: true, autoUpdate: true }) declare updatedAt: DateTime | null + + @hasMany(() => Account) + declare accounts: HasMany + + isEmailVerified() { + return this.emailVerifiedAt !== null + } } diff --git a/website/app/services/user_service.ts b/website/app/services/user_service.ts new file mode 100644 index 0000000..52207ec --- /dev/null +++ b/website/app/services/user_service.ts @@ -0,0 +1,41 @@ +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) { + const committedUser = await db.transaction(async (trx) => { + const user = await User.create({ email }, { client: trx }) + await user.related('accounts').create({ id: `credentials:${email}`, password }) + + return user + }) + + UserCreated.dispatch(committedUser) + + 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 + } +} diff --git a/website/app/url.ts b/website/app/url.ts new file mode 100644 index 0000000..b81e161 --- /dev/null +++ b/website/app/url.ts @@ -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); +} diff --git a/website/app/validators/account.ts b/website/app/validators/account.ts new file mode 100644 index 0000000..226f346 --- /dev/null +++ b/website/app/validators/account.ts @@ -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 + ), + }) +) diff --git a/website/app/validators/authentication.ts b/website/app/validators/authentication.ts new file mode 100644 index 0000000..481f0f7 --- /dev/null +++ b/website/app/validators/authentication.ts @@ -0,0 +1,22 @@ +import vine from '@vinejs/vine' +import User from '#models/user' + +export const registerWithCredentialsValidator = vine.compile( + vine.object({ + email: vine.string().email().unique({ table: User.table, column: 'email' }), + password: vine.string().minLength(8).confirmed(), + }) +) + +export const emailVerificationCallbackValidator = vine.compile( + vine.object({ + email: vine.string().email().exists({ table: User.table, column: 'email' }), + }) +) + +export const loginWithCredentialsValidator = vine.compile( + vine.object({ + email: vine.string().email(), + password: vine.string(), + }) +) diff --git a/website/config/ally.ts b/website/config/ally.ts new file mode 100644 index 0000000..d49d03c --- /dev/null +++ b/website/config/ally.ts @@ -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 {} +} diff --git a/website/config/database.ts b/website/config/database.ts index 4281077..a38e7c9 100644 --- a/website/config/database.ts +++ b/website/config/database.ts @@ -2,6 +2,8 @@ import app from '@adonisjs/core/services/app' import { defineConfig } from '@adonisjs/lucid' const dbConfig = defineConfig({ + prettyPrintDebugQueries: true, + connection: 'sqlite', connections: { sqlite: { diff --git a/website/config/inertia.ts b/website/config/inertia.ts index 668ebb3..aced2d3 100644 --- a/website/config/inertia.ts +++ b/website/config/inertia.ts @@ -1,7 +1,13 @@ +import type User from '#models/user' import env from '#start/env' import { defineConfig } from '@adonisjs/inertia' import type { InferSharedProps } from '@adonisjs/inertia/types' +export type AuthenticationData = + | { state: "disabled" } + | { state: "unauthenticated" } + | { state: "authenticated"; user: Pick } + const inertiaConfig = defineConfig({ /** * Path to the Edge view that will be used as the root view for Inertia responses @@ -12,8 +18,16 @@ const inertiaConfig = defineConfig({ * Data that should be shared with all rendered pages */ sharedData: { - errors: (ctx) => ctx.session?.flashMessages.get('errors'), environment: env.public(), + auth: async ({ auth }): Promise => { + if (env.get('FEATURES_DISABLE_AUTH')) return { state: "disabled" } + + if (!auth.authenticationAttempted) await auth.check() + const user = auth.user + + if (!user) return { state: "unauthenticated" } + return { state: "authenticated", user: { email: user.email } } + }, }, /** diff --git a/website/config/limiter.ts b/website/config/limiter.ts new file mode 100644 index 0000000..f070691 --- /dev/null +++ b/website/config/limiter.ts @@ -0,0 +1,31 @@ +import env from '#start/env' +import { defineConfig, stores } from '@adonisjs/limiter' + +const limiterConfig = defineConfig({ + default: env.get('LIMITER_STORE'), + + stores: { + /** + * Redis store to save rate limiting data inside a + * redis database. + * + * It is recommended to use a separate database for + * the limiter connection. + */ + redis: stores.redis({ + connectionName: 'limiter', + }), + + /** + * Memory store could be used during + * testing + */ + memory: stores.memory({}), + }, +}) + +export default limiterConfig + +declare module '@adonisjs/limiter/types' { + export interface LimitersList extends InferLimiters {} +} diff --git a/website/config/mail.ts b/website/config/mail.ts index d6aa78a..a40fcde 100644 --- a/website/config/mail.ts +++ b/website/config/mail.ts @@ -4,6 +4,9 @@ import { defineConfig, transports } from '@adonisjs/mail' const mailConfig = defineConfig({ default: 'smtp', + from: env.get("FROM_EMAIL"), + replyTo: env.get("REPLY_TO_EMAIL"), + /** * The mailers object can be used to configure multiple mailers * each using a different transport or same transport with different diff --git a/website/config/redis.ts b/website/config/redis.ts new file mode 100644 index 0000000..7cc23f1 --- /dev/null +++ b/website/config/redis.ts @@ -0,0 +1,46 @@ +import env from '#start/env' +import { defineConfig } from '@adonisjs/redis' +import type { InferConnections } from '@adonisjs/redis/types' + +const redisConfig = defineConfig({ + connection: 'main', + + connections: { + /* + |-------------------------------------------------------------------------- + | The default connection + |-------------------------------------------------------------------------- + | + | The main connection you want to use to execute redis commands. The same + | connection will be used by the session provider, if you rely on the + | redis driver. + | + */ + main: { + host: env.get('REDIS_HOST'), + port: env.get('REDIS_PORT'), + password: env.get('REDIS_PASSWORD', ''), + db: 0, + keyPrefix: '', + retryStrategy(times) { + return times > 10 ? null : times * 50 + }, + }, + + limiter: { + host: env.get('REDIS_HOST'), + port: env.get('REDIS_PORT'), + password: env.get('REDIS_PASSWORD', ''), + db: 1, + retryStrategy(times) { + return times > 10 ? null : times * 50 + }, + } + }, +}) + +export default redisConfig + +declare module '@adonisjs/redis/types' { + export interface RedisConnections extends InferConnections {} +} \ No newline at end of file diff --git a/website/database/migrations/1734342326224_create_users_table.ts b/website/database/migrations/1734342326224_create_users_table.ts index dbca083..f036fab 100644 --- a/website/database/migrations/1734342326224_create_users_table.ts +++ b/website/database/migrations/1734342326224_create_users_table.ts @@ -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() diff --git a/website/database/migrations/1736362099709_create_accounts_table.ts b/website/database/migrations/1736362099709_create_accounts_table.ts new file mode 100644 index 0000000..e126563 --- /dev/null +++ b/website/database/migrations/1736362099709_create_accounts_table.ts @@ -0,0 +1,21 @@ +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('id').primary() + table.string('password') + + table.integer('user_id').references('id').inTable('users').notNullable() + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/website/database/migrations/1737498252213_alter_users_table.ts b/website/database/migrations/1737498252213_alter_users_table.ts new file mode 100644 index 0000000..ebd4c22 --- /dev/null +++ b/website/database/migrations/1737498252213_alter_users_table.ts @@ -0,0 +1,17 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'users' + + async up() { + this.schema.alterTable(this.tableName, (table) => { + table.timestamp('email_verified_at').nullable() + }) + } + + async down() { + this.schema.alterTable(this.tableName, (table) => { + table.dropColumn('email_verified_at') + }) + } +} \ No newline at end of file diff --git a/website/eslint.config.js b/website/eslint.config.js index 9be1be3..3e88f05 100644 --- a/website/eslint.config.js +++ b/website/eslint.config.js @@ -1,2 +1,19 @@ -import { configApp } from '@adonisjs/eslint-config' -export default configApp() +import { configApp, RULES_LIST } from '@adonisjs/eslint-config' + +// Downgrade all lints to warnings +import 'eslint-plugin-only-warn' + +export default configApp( + { + name: 'Custom config for Inertia', + files: ['inertia/**/*.ts', 'inertia/**/*.tsx'], + ignores: ['inertia/components/ui/**/*'], + rules: RULES_LIST, + }, + { + ignores: ['.adonisjs/**/*'], + rules: { + 'prettier/prettier': 'off', + }, + } +) diff --git a/website/inertia/app/app.tsx b/website/inertia/app/app.tsx index fe79063..d410578 100644 --- a/website/inertia/app/app.tsx +++ b/website/inertia/app/app.tsx @@ -1,6 +1,9 @@ /// -/// +/// /// +/// +/// +/// import '../css/app.css' diff --git a/website/inertia/app/ssr.tsx b/website/inertia/app/ssr.tsx index 77a6e5e..d55a139 100644 --- a/website/inertia/app/ssr.tsx +++ b/website/inertia/app/ssr.tsx @@ -2,9 +2,9 @@ import ReactDOMServer from 'react-dom/server' import { createInertiaApp } from '@inertiajs/react' import { TuyauWrapper } from './tuyau' -export default function render(page: any) { +export default function render(intialPage: any) { return createInertiaApp({ - page, + page: intialPage, render: ReactDOMServer.renderToString, resolve: (name) => { const pages = import.meta.glob('../pages/**/*.tsx', { eager: true }) diff --git a/website/inertia/app/tuyau.tsx b/website/inertia/app/tuyau.tsx index c54f6cd..3870292 100644 --- a/website/inertia/app/tuyau.tsx +++ b/website/inertia/app/tuyau.tsx @@ -3,7 +3,9 @@ import { createTuyau } from '@tuyau/client' import { TuyauProvider } from '@tuyau/inertia/react' import { useEnvironment } from '~/hooks/use_env' -export function TuyauWrapper({ children }: { children?: React.ReactNode }) { +export type TuyauClient = ReturnType + +function useTuyau() { const tuyau = useEnvironment((env) => createTuyau({ api, @@ -11,5 +13,10 @@ export function TuyauWrapper({ children }: { children?: React.ReactNode }) { }) ) + return tuyau +} + +export function TuyauWrapper({ children }: { children?: React.ReactNode }) { + const tuyau = useTuyau() return {children} } diff --git a/website/inertia/components/navbar.tsx b/website/inertia/components/navbar.tsx index 4f5bc5c..d827319 100644 --- a/website/inertia/components/navbar.tsx +++ b/website/inertia/components/navbar.tsx @@ -1,4 +1,8 @@ -import { Link } from "@inertiajs/react"; +import { useForm } from '@inertiajs/react' +import { Link } from '@tuyau/inertia/react' +import { Button, buttonVariants } from '~/components/ui/button' +import { useAuth } from '~/hooks/use_auth' +import { useTuyau } from '~/hooks/use_tuyau' /* import { Menu } from "lucide-react"; @@ -26,6 +30,32 @@ type PageRoute = { */ +function LoginButton() { + return ( + + Login + + ) +} + +function LogoutButton() { + const tuyau = useTuyau() + const { post } = useForm() + + function onSubmit(e: React.FormEvent) { + e.preventDefault() + post(tuyau.$url('actions:auth.logout')) + } + + return ( +
+ +
+ ) +} + export default function NavBar() { /* const navButtonStyle = @@ -44,18 +74,20 @@ export default function NavBar() { }]; */ + const auth = useAuth() + return ( <> - ); + ) } diff --git a/website/inertia/components/ui/form.tsx b/website/inertia/components/ui/form.tsx index 61cfed6..3bdaf45 100644 --- a/website/inertia/components/ui/form.tsx +++ b/website/inertia/components/ui/form.tsx @@ -5,9 +5,9 @@ import * as LabelPrimitive from '@radix-ui/react-label' import { Slot } from '@radix-ui/react-slot' import { Controller, - ControllerProps, - FieldPath, - FieldValues, + type ControllerProps, + type FieldPath, + type FieldValues, FormProvider, useFormContext, } from 'react-hook-form' diff --git a/website/inertia/components/ui/pagination.tsx b/website/inertia/components/ui/pagination.tsx index b9160a7..554d4d7 100644 --- a/website/inertia/components/ui/pagination.tsx +++ b/website/inertia/components/ui/pagination.tsx @@ -2,7 +2,7 @@ import * as React from 'react' import { ChevronLeft, ChevronRight, MoreHorizontal } from 'lucide-react' import { cn } from '~/lib/utils' -import { ButtonProps, buttonVariants } from '~/components/ui/button' +import { type ButtonProps, buttonVariants } from '~/components/ui/button' const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (