Let's start with our sign up flow, as this is how users will be created inside our application. For this, we'll create a NewAccountController with a create and store method inside of it. Again, following the resourceful naming convention.
node ace make:controller new_account create store # DONE: create app/controllers/new_account_controller.tsCopied!
Remember, with the make:controller command we can end with a list of methods we want stubbed inside the controller. So, with this we're creating a controller called new_account with two methods, create and store. It'll look like the following to start:
import type { HttpContext } from '@adonisjs/core/http' export default class NewAccountController { async create({}: HttpContext) {} async store({}: HttpContext) {} }Copied!
- app
- controllers
- new_account_controller.ts
We've left our EdgeJS pages intact as we're already familiar with everything going on in those. So, we can get our create out of the way by rendering its page.
import type { HttpContext } from '@adonisjs/core/http' export default class NewAccountController { async create({ view }: HttpContext) { return view.render('pages/auth/signup') } async store({}: HttpContext) {} }Copied!
- app
- controllers
- new_account_controller.ts
As a reminder, this page includes a form that'll post to new_account.store with the fields fullName, email, password, and passwordConfirmation, as shown below.
@layout() <div class="form-container"> <div> <h1> Signup </h1> <p> Enter your details below to create your account </p> </div> <div> @form({ route: 'new_account.store', method: 'POST' }) <div> @field.root({ name: 'fullName' }) @!field.label({ text: 'Full name' }) @!input.control() @!field.error() @end </div> <div> @field.root({ name: 'email' }) @!field.label({ text: 'Email' }) @!input.control({ type: 'email', autocomplete: 'email' }) @!field.error() @end </div> <div> @field.root({ name: 'password' }) @!field.label({ text: 'Password' }) @!input.control({ type: 'password', autocomplete: 'new-password' }) @!field.error() @end </div> <div> @field.root({ name: 'passwordConfirmation' }) @!field.label({ text: 'Confirm password' }) @!input.control({ type: 'password', autocomplete: 'new-password' }) @!field.error() @end </div> <div> @!button({ text: 'Sign up', type: 'submit' }) </div> @end </div> </div> @endCopied!
- resources
- views
- pages
- auth
- signup.edge
We'll next want to validate this data so we aren't accepting raw user-provided data. So, let's create our validator next.
node ace make:validator user # DONE: create app/validators/user.tsCopied!
To start, that'll give us a new user validator file with a single import for VineJS.
import vine from '@vinejs/vine'Copied!
- app
- validators
- user.ts
We'll want to create a new validator in here called signupValidator and export it. Inside, we'll add our form's fields, all of which will be strings.
import vine from '@vinejs/vine' export const signupValidator = vine.create({ fullName: vine.string(), email: vine.string(), password: vine.string(), })Copied!
- app
- validators
- user.ts
Do note, we've omitted the passwordConfirmation field from our validator. We don't actually need that value; all we want to do is ensure it matches one-to-one with the password value. To do that, we can add a confirmed() rule to our password stating it should be confirmed by our passwordConfirmation value.
import vine from '@vinejs/vine' export const signupValidator = vine.create({ fullName: vine.string(), email: vine.string(), password: vine.string().confirmed({ confirmationField: 'passwordConfirmation', }), })Copied!
- app
- validators
- user.ts
Next, we'll also want to ensure it is at least eight characters long. You can change to match your security policy from there if you'd like. To do this, we could chain it directly to our password rules, but let's mimic what the starter kit had. It had these rules split into their own variable, which opens up the opportunity for reusability for these rules. You might want to reuse this if you have a change password section in your account settings, for example.
import vine from '@vinejs/vine' const password = () => vine.string().minLength(8).maxLength(32) export const signupValidator = vine.create({ fullName: vine.string(), email: vine.string(), password: password().confirmed({ confirmationField: 'passwordConfirmation', }), })Copied!
- app
- validators
- user.ts
Here we're adding a max length for the password to 32 to match what the starter kit provided, but you can allow this to go longer if you'd like. By default, our database string column length will be 255 characters. Plus, as we discussed in our database module, our User model will secure our password inside our database using a hash and salt mechanism. This results in our password looking like the below inside our database.
$scrypt$n=16384,r=8,p=1$MKExIjg7F/RbrIRl7ZlKPg$Sajr82p4xhTr+0Kw/z6cakM00gh4mDDsil5Ts954pYvuAl7eS2CNLRQzuharYaWnavyx4JrF8EI2zXOxfsnltwThe length of the passwords stored in our database ultimately varies depending on our hash configuration. By default, AdonisJS has set us up with scrypt. We can switch to [argon2](https://docs.adonisjs.com/guides/security/hashing), [bcrypt](https://docs.adonisjs.com/guides/security/hashing), or other solutions via this config if needed. The keyLength` option, omitted from below, is what would add or subtract from the length of the secured password.
import { defineConfig, drivers } from '@adonisjs/core/hash' /** * Hashing configuration. * Defines the hashing algorithm and parameters used for password hashing. */ const hashConfig = defineConfig({ /** * The default hashing algorithm to use. */ default: 'scrypt', /** * List of available hashers and their configuration. */ list: { /** * Scrypt hasher configuration. * Scrypt is a password-based key derivation function designed to be * computationally intensive and memory-hard to resist brute-force attacks. */ scrypt: drivers.scrypt({ /** * CPU/memory cost parameter. * Higher values increase security but require more resources. */ cost: 16384, /** * Block size parameter. * Affects memory usage and performance. */ blockSize: 8, /** * Parallelization parameter. * Controls the number of independent mixing functions. */ parallelization: 1, /** * Maximum memory in bytes to use for hashing. * Set to 32MB (33554432 bytes) by default. */ maxMemory: 33554432, }), }, }) export default hashConfig /** * Inferring types for the list of hashers you have configured * in your application. */ declare module '@adonisjs/core/types' { export interface HashersList extends InferHashers<typeof hashConfig> {} }Copied!
- config
- hash.ts
Several AdonisJS configurations follow this structure. We have a list of configured services, in this case, hashing mechanisms. Then, we specify a default for AdonisJS to use from the list.
Okay, back to our validator. On our users table our email column is marked as unique. We don't want to allow more than one user to use an email; otherwise, that could lead to our accounts cross-pollinating. We can validate this up front to prevent it from failing and erroring out later on when it reaches our database using the unique() rule.
import vine from '@vinejs/vine' const password = () => vine.string().minLength(8).maxLength(32) export const signupValidator = vine.create({ fullName: vine.string(), email: vine.string().unique({ table: 'users', column: 'email' }), password: password().confirmed({ confirmationField: 'passwordConfirmation', }), })Copied!
- app
- validators
- user.ts
It accepts in options that will help it build out a query. Alternatively, we can also provide a callback function to build the query manually ourselves. Here, we're specifying that we want the user-supplied email value to be unique in our users table's email column. This will perform a quick query to ensure no record with the supplied email exists. If one does exist, we'll get a validation error.
Note, there is also a property on this option set to do a case-insensitive search and another to filter further.
import vine from '@vinejs/vine' export const exampleValidator = vine.create({ email: vine.string().unique({ table: 'users', column: 'email', caseInsensitive: true, filter(db, value) { return db.whereNull('deleted_at') } }), })Copied!
Then, we also need to ensure the value supplied is a valid email structure and that it'll fit in our database column.
import vine from '@vinejs/vine' const email = () => vine.string().email().maxLength(254) const password = () => vine.string().minLength(8).maxLength(32) export const signupValidator = vine.create({ fullName: vine.string(), email: email().unique({ table: 'users', column: 'email' }), password: password().confirmed({ confirmationField: 'passwordConfirmation', }), })Copied!
- app
- validators
- user.ts
We'll do something similar to our password and make this reusable, which is handy if you have a change email section in your account settings. Finally, our fullName should be nullable; the user doesn't need to supply it.
import vine from '@vinejs/vine' const email = () => vine.string().email().maxLength(254) const password = () => vine.string().minLength(8).maxLength(32) export const signupValidator = vine.create({ fullName: vine.string().nullable(), email: email().unique({ table: 'users', column: 'email' }), password: password().confirmed({ confirmationField: 'passwordConfirmation', }), })Copied!
- app
- validators
- user.ts
Jumping back into our NewAccountController, we can validate our request body in our store handler, then use the validated data to create a new user record. We can also redirect our user back home after all is said and done.
import type { HttpContext } from '@adonisjs/core/http' export default class NewAccountController { async create({ view }: HttpContext) { return view.render('pages/auth/signup') } async store({ request }: HttpContext) { const payload = await request.validateUsing(signupValidator) const user = await User.create(payload) return response.redirect().toRoute('home') } }Copied!
- app
- controllers
- new_account_controller.ts
In between creating our user and redirecting our user, though, we want to log the user in. We've discussed how auth is a global in our EdgeJS context, but it's also available via our HttpContext as well! It's how we'll login, logout, and otherwise manage our session-based authentication.
import type { HttpContext } from '@adonisjs/core/http' export default class NewAccountController { async create({ view }: HttpContext) { return view.render('pages/auth/signup') } async store({ request, response, auth }: HttpContext) { const payload = await request.validateUsing(signupValidator) const user = await User.create(payload) await auth.use('web').login(user) return response.redirect().toRoute('home') } }Copied!
- app
- controllers
- new_account_controller.ts
Similar to how hash has a list of configured hashing mechanisms, our auth has a list of configured authentication guards. With the starter kit we've selected, the default guard that's been configured for us is the "web" guard, for session authentication. Here we're using that guard to login the provided user.
If we scope out our auth config to see this guard, we'll find:
import { defineConfig } from '@adonisjs/auth' import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session' import type { InferAuthenticators, InferAuthEvents, Authenticators } from '@adonisjs/auth/types' /** * Authentication configuration. * Defines guards and user providers for session-based authentication. */ const authConfig = defineConfig({ /** * The default guard to use for authentication. * This guard will be used when no specific guard is mentioned. */ default: 'web', /** * List of authentication guards. * Guards define how users are authenticated for different parts of the application. */ guards: { /** * Web guard uses session-based authentication for web requests. */ web: sessionGuard({ /** * Whether to use "remember me" tokens for persistent authentication. * When enabled, users can stay logged in across browser sessions. */ useRememberMeTokens: false, /** * User provider configuration. * Defines how to fetch and verify user credentials. */ provider: sessionUserProvider({ model: () => import('#models/user'), }), }), }, }) export default authConfig /** * Inferring types from the configured auth * guards. */ declare module '@adonisjs/auth/types' { export interface Authenticators extends InferAuthenticators<typeof authConfig> {} } declare module '@adonisjs/core/types' { interface EventsList extends InferAuthEvents<Authenticators> {} }Copied!
- config
- auth.ts
Note, the web guard is our default guard, and within its configuration is a provider. This provider accepts in our User model, and that is how the login() method we used in our controller knows how to accept a User model instance, among other things.
Great, the last thing to do is register our routes!
import { controllers } from '#generated/controllers' import { middleware } from '#start/kernel' import router from '@adonisjs/core/services/router' router.where('id', router.matchers.number()) router.on('/').render('pages/home').as('home') router.on('/terms').render('pages/terms') router .resource('challenges', controllers.Challenges) .use(['index', 'edit', 'create'], middleware.requestLogger()) router.get('/signup', [controllers.NewAccount, 'create']) router.post('/signup', [controllers.NewAccount, 'store'])Copied!
- start
- routes.ts
Remember, barrel files are updated when the dev server is running. You may need to boot your dev server to get the NewAccountController added to the controllers.NewAccount barrel mapping.
Finally, back into our header, we can add our sign up link and our authenticated user's avatar.
<header> <div> <div> @link({ route: 'home', text: 'Home' }) {{-- svg ... --}} @end </div> <div> <nav> @if(auth.user) @!avatar({ initials: auth.user.initials }) @else @!link({ route: 'new_account.create', text: 'Signup' }) @end </nav> </div> </div> </header>Copied!
- resource
- views
- partials
- header.edge
Here, when a user is authenticated, our auth.user will be populated with the user's User model instance. So, we can use it to show an avatar of their initials. Otherwise, we'll show a link to our sign up page.
That should do it, we should now be able to head into our /signup page in our app and sign up users! Once you sign up, you should be redirected back to the home page, and you should see your user's initials in place of the sign up link.