Authentication (for real this time?)

(Edit: after learning more about how Remix handles form submission, I realized that my use of the fields parameter to pass submitted form field values from the server back to the client is unnecessary. Remix intercepts the form submission event and makes a request to the server in the background. That means that there isn’t a full page reload when the form is submitted. Previously entered form values are maintained.)

It’s time to add a login page to the site. Before getting to that I’m going to change the stories route to store. The change is more difficult than it should be because I’ve hard coded the route into the sidebar:

// app/component/Sidebar.tsx

//...
  const matches = useMatches();
  const pathStart: string = matches.slice(-1)?.[0].pathname.split("/")[1] || "";

  let isRoot = false,
    isBlog = false,
    isMusic = false,
    isStories = false,
    isHireMe = false;

  switch (pathStart) {
    case "blog":
      isBlog = true;
      break;
    case "music":
      isMusic = true;
      break;
    case "stories":
      isStories = true;
      break;
    case "hire-me":
      isHireMe = true;
      break;
    default:
      isRoot = true;
  }
//...

I won’t deal with it now, but I’d like to be pulling this data from WordPress – maybe with a “menu-item” custom post type?

$ mv app/routes/stories.tsx app/routes/store.tsx

“Stories” to “Store” is an easy edit:

I also removed the “hire-me”, “stories”, and “blog” links that I added to the header yesterday.

I’m tempted to keep working on the Sidebar.

Adding a login/registration page

I’ll start with a dedicated login page. Ideally login and registration forms would open in a modal window.

$ touch app/routes/login.tsx
// app/routes/login.tsx

export default function Login() {
  return (
    <div>
      <h1>Login</h1>
    </div>
  );
}

Add a “login” link to the header:

// app/components/Header.tsx

//...
            <div className="pl-4 pr-16 flex flex-row-reverse w-full">
              {/* header links go here */}
              <NavLink
                to="/login"
                className={({ isActive, isPending }) =>
                  `px-3 py-1 bg-sky-50 hover:bg-sky-100  text-sky-900 rounded ${
                    isPending ? "pending" : isActive ? "active" : ""
                  }`
                }
              >
                Login
              </NavLink>
            </div>
//...

For my own reference, I’ve removed the CSS rules that were conditionally hiding header links:

"hidden min-[390px]:flex min-[390px]:justify-between"

Login users

Docs:

Remix Auth

Remix Auth is an authentication library for Remix. I’ll start by creating a new project and working through its documentation on my local computer:

$ npx create-remix@latest

From the project’s directory:

$ npm run dev

> dev
> remix vite:dev

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

It’s using vite! (https://remix.run/docs/en/main/future/vite)

Awesome! Now install Remix Auth:

$ npm install remix-auth

I also need to install a “strategy.” The docs link to a list of available strategies at https://github.com/sergiodxa/remix-auth/discussions/111. I might be getting ahead of myself, but I’m thinking that part of the value of Remix Auth is that it makes it easy to share authentication strategies. For example:

A strategy for Discourse authentication might be useful: https://meta.discourse.org/t/use-discourse-as-an-identity-provider-sso-discourseconnect/32974.

For now, I’ll follow the example from the Remix Auth docs and install the Remix Auth Form strategy:

$ remix-auth-form

I’m unsure about using the Remix Auth Form strategy to register users. I’ll start by just using it to authenticate an existing user. To set things up I’ll add a database and a User model to the app – more or less following the steps outlined here: https://remix.run/docs/en/main/tutorials/jokes#database.

I’m using Prisma as the ORM. To initialize Prisma with SQLite:

$ npx prisma init --datasource-provider sqlite

That creates a schema.prisma file. I’ll add a User model to that file:

// prisma/schema.prisma

//...
model User {
  id Int @id @default(autoincrement())
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
  passwordHash String
  username String @unique
  email String @unique
}

Then run:

$ npx prisma db push

The easiest way to get a User record into the database will be to add it via the Prisma Studio web interface. Prisma Studio can be opened with:

$ npx prisma studio

The obvious problem here is that the User model has a passwordHash field, not a password field. I suppose I could have just used a password field for this test. I’ll sort that out later.

Back to Remix Auth. It needs a session storage object to store the user session. It can be any object that implements the Remix SessionStorage interface: https://remix.run/docs/en/main/utils/sessions.

The Remix Auth example uses createCookieSessionStorage. Following along, I’ll create an app/services directory and add a session.server.ts file to it:

// app/services/session.server.ts

import { createCookieSessionStorage } from "@remix-run/node";

if (!process.env.SESSION_SECRET) {
  throw new Error("SESSION_SECRET environment variable not set");
}

const sessionSecret: string = process.env.SESSION_SECRET as string;

export let sessionStorage = createCookieSessionStorage({
  cookie: {
    name: "_session",
    sameSite: "lax",
    path: "/",
    httpOnly: true,
    secrets: [sessionSecret],
    secure: process.env.NODE_ENV === "production",
  },
});

// The Remix Auth docs indicate that this is optional. I think
// what that means is that either `sessionStorage` can be exported
// as it is above, or the individual methods can be exported.
export let { getSession, commitSession, destroySession } = sessionStorage;

The next step is to create a configuration file. The documentation adds it at app/services/auth.server.ts.

Attempting to understand Remix Auth

… I was stumped for a bit. Here’s what I ended up with. I’ll try explaining what’s going on:

// app/services/auth.server.ts

import { Authenticator } from "remix-auth";
import { FormStrategy } from "remix-auth-form";
import { sessionStorage } from "~/services/session.server";
import { login } from "~/services/session.server";
import type { User } from "@prisma/client";
import invariant from "tiny-invariant";

export let authenticator = new Authenticator<User>(sessionStorage);

authenticator.use(
  new FormStrategy(async ({ form }) => {
    let username: FormDataEntryValue | null = form.get("username");
    let password: FormDataEntryValue | null = form.get("password");
    invariant(typeof username === "string", "username must be a string");
    invariant(username.length > 0, "username must not be empty");
    invariant(typeof password === "string", "password must be a string");
    invariant(password.length > 0, "password must not be empty");

    let user = await login({ username: username, password: password });

    return user;
  }),

  "user-pass"
);

Maybe starting with this:

export let authenticator = new Authenticator<User>(sessionStorage);

That line has the following comment in the documentation:

// Create an instance of the authenticator, pass a generic with what
// strategies will return and will store in the session

“pass a generic” threw me off. Here’s how the Authenticator class is defined in the Remix Auth code:

// authenticator.ts

//...
export class Authenticator<User = unknown> {
//...

It’s defined with a generic type parameter that defaults to unknown. To use the class, it needs to be instantiated with a specific type. Where my code is calling:

export let authenticator = new Authenticator<User>(sessionStorage);

User is the type that I’m importing here:

import type { User } from "@prisma/client";

That’s the User model that’s defined in schema.prisma.

At the risk of getting off course, here’s the Authenticator class constructor:

// authenticator.ts

  constructor(
    private sessionStorage: SessionStorage,
    options: AuthenticatorOptions = {}
  ) {
    this.sessionKey = options.sessionKey || "user";
    this.sessionErrorKey = options.sessionErrorKey || "auth:error";
    this.sessionStrategyKey = options.sessionStrategyKey || "strategy";
    this.throwOnError = options.throwOnError ?? false;
  }

By using the private modifier for its sessionStorage argument it’s both defining a parameter and declaring a class property. As a test:

// app/services/auth.server.ts

//...
export let authenticator = new Authenticator<User>(sessionStorage);
let foo = authenticator.sessionStorage;
//...

generates the following Typescript warning:

Property 'sessionStorage' is private and only accessible within class 'Authenticator<User>'.

Moving on… authenticator is an instance of Authenticator that has been initialize with the sessionStorage object I’m exporting from session.server.ts. That object implements the Remix SessionStorage interface – it has methods for getSession, commitSession, and destroySession.

The auth.server.ts code now calls authenticator.use with two arguments: a strategy, and a name:

// authenticator.ts

//...
  /**
   * Call this method with the Strategy, the optional name allows you to setup
   * the same strategy multiple times with different names.
   * It returns the Authenticator instance for concatenation.
   * @example
   * authenticator
   *  .use(new SomeStrategy({}, (user) => Promise.resolve(user)))
   *  .use(new SomeStrategy({}, (user) => Promise.resolve(user)), "another");
   */
  use(strategy: Strategy<User, never>, name?: string): Authenticator<User> {
    this.strategies.set(name ?? strategy.name, strategy);
    return this;
  }
//...

I’m unsure about the use of never as the second type parameter for the strategy argument. I’m guessing that the strategy is expected to return a User, but if the user can’t be authenticated based on the data that’s passed to the strategy instance, the expectation seems to be that an AuthorizationError will be thrown somewhere in the code.

I’m passing an instance of FormStrategy to the use method:

// app/services/auth.server.ts

//...
authenticator.use(
  new FormStrategy(async ({ form }) => {
    let username: FormDataEntryValue | null = form.get("username");
    let password: FormDataEntryValue | null = form.get("password");
    invariant(typeof username === "string", "username must be a string");
    invariant(username.length > 0, "username must not be empty");
    invariant(typeof password === "string", "password must be a string");
    invariant(password.length > 0, "password must not be empty");

    let user = await login({ username: username, password: password });

    return user;
  }),
//...

Its constructor takes a callback function with a form argument. form is the FormData that’s passed from the Remix login route’s action to authenticator.authenticate.

The callback then returns a User object. (Or an error should be thrown?)

Here’s the login function that I’m using:

// app/services/session.server.ts

//...
interface LoginForm {
  username: string;
  password: string;
}

export async function login({ username, password }: LoginForm) {
  const user = await db.user.findUnique({
    where: { username },
  });

  if (!user) {
    throw new AuthorizationError(
      "Bad credentials: User not found for username."
    );
  }

  const isCorrectPassword = await bcrypt.compare(password, user.passwordHash);
  if (!isCorrectPassword) {
    throw new AuthorizationError("Bad credentials: wrong password");
  }

  return user;
}

The login function is either going to return a User, or it’s going to throw an AuthorizationError (edit: a regular Error can be used instead of an AuthorizationError.) If an AuthorizationError is thrown, no value will be returned. Instead, the error will propagate up the call stack until it is handled.

I’m not sure how far to go into this. The other thing that threw me off was how the FormStrategy initializer was verifying form parameters. For now I’ll just accept that it is verifying parameters. The calls to invarient with the parameters that I’m passing to the login function are there to make Typescript happy. Possibly this could be handled in the login function.

The Remix Auth authentication flow

Here’s a basic login route:

// app/routes/login.tsx

import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { Form } from "@remix-run/react";
import { authenticator } from "~/services/auth.server";

export async function action({ request }: ActionFunctionArgs) {
  return await authenticator.authenticate("user-pass", request, {
    successRedirect: "/",
    failureRedirect: "/login",
  });
}

export default function Login() {
  return (
    <Form method="post">
      <label>
        username: <input type="username" name="username" required />
      </label>
      <br />
      <label>
        password:{" "}
        <input
          type="password"
          name="password"
          autoComplete="current-password"
          required
        />
      </label>
      <br />
      <button>Sign In</button>
    </Form>
  );
}

The route’s action will be called when the form is submitted. The action is calling the authenticator.authenticate method with the strategy name ("user-pass") and request object as arguments:

  return await authenticator.authenticate("user-pass", request, {
    successRedirect: "/",
    failureRedirect: "/login",
  });

This triggers the callback that’s in the call to authenticator.use in auth.server.ts:

// app/services/auth.server.ts

//...
   let username: FormDataEntryValue | null = form.get("username");
    let password: FormDataEntryValue | null = form.get("password");
    invariant(typeof username === "string", "username must be a string");
    invariant(username.length > 0, "username must not be empty");
    invariant(typeof password === "string", "password must be a string");
    invariant(password.length > 0, "password must not be empty");

    let user = await login({ username: username, password: password });

    return user;
  }),
//...

That callback calls the login function:

// app/services/session.server.ts

//...
interface LoginForm {
  username: string;
  password: string;
}

export async function login({ username, password }: LoginForm) {
  const user = await db.user.findUnique({
    where: { username },
  });

  if (!user) {
    throw new AuthorizationError(
      "Bad credentials: User not found for username."
    );
  }

  const isCorrectPassword = await bcrypt.compare(password, user.passwordHash);
  if (!isCorrectPassword) {
    throw new AuthorizationError("Bad credentials: wrong password");
  }

  return user;
}

The login function will either return a User object, or throw an AuthorizationError.

Testing this out, it fails as expected:

Before I can successfully login to the site, I need to add a user to the database with the following properties:

  • passwordHash
  • username
  • email

To get the password hash:

// app/services/session.server.ts

//...
export async function login({ username, password }: LoginForm) {
  console.log(`passwordHash: ${await bcrypt.hash("simplepass", 10)}`);
//...

Then add the record to the database using the Prisma Studio UI. With that done, I can login to the site! After being authenticated, the successRedirect is redirecting me to /.

Displaying error messages

I want to see if it’s possible to access errors that are thrown by the login function.

After trying to figure it out on my own, it turns out this is documented: https://remix.run/resources/remix-auth#reading-authentication-errors.

Authentication errors are set on the session with the authenticator.sessionErrorKey property. They can be returned from a loader with something like this. Note that I’ve imported getSession from "~/services/session.server":

// app/routes/login.tsx

//...
export async function loader({ request }: LoaderFunctionArgs) {
  const session = await getSession(request.headers.get("cookie"));
  const error = session.get(authenticator.sessionErrorKey);
  return json(
    { error },
    {
      headers: {
        "Set-Cookie": await commitSession(session),
      },
    }
  );
}
//...

Somewhat related, I didn’t realize the json function allowed for passing headers, but it does: https://remix.run/docs/en/main/utils/json.

Errors like this can now be passed to the client from the loader function:

{
  "message": "Bad credentials: wrong password"
}

That’s a start, but not super useful for updating the UI. I’d like to find a way of associating an error with the field that triggered it. I’ll look into that some more if I use Remix Auth on a production site.

Limiting access to routes

// app/routes/secure.tsx

import type { LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { authenticator } from "~/services/auth.server";

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const user = await authenticator.isAuthenticated(request, {
    failureRedirect: "/login",
  });

  return user;
};

export default function Secure() {
  const user = useLoaderData<typeof loader>();
  console.log(`data from loader: ${JSON.stringify(user, null, 2)}`);

  return <div>this is a test, this is only a test...</div>;
}

Logging out users

// app/routes/_index.tsx

import { json } from "@remix-run/node";
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import { authenticator } from "~/services/auth.server";

export const loader = async ({ request }: LoaderFunctionArgs) => {
  const user = await authenticator.isAuthenticated(request);

  return json({ user: user });
};

export const action = async ({ request }: ActionFunctionArgs) => {
  await authenticator.logout(request, {
    redirectTo: "/login",
  });
};

export default function Index() {
  const { user } = useLoaderData<typeof loader>();

  return (
    <div>
      <h1>Remix Auth Test</h1>
      {user && (
        <>
          <p>Hey, you're logged in. Give logging out a try!</p>
          <Form method="post">
            <button type="submit">Logout</button>
          </Form>
        </>
      )}
    </div>
  );
}

That ends my (somewhat) deep dive into Remix Auth. I’d probably use it if I wanted to authenticate users with a strategy other than the FormStrategy.

Authenticating users with createCookieSessionStorage

I’ll test this on a new branch of the same project:

$ git checkout -b create_cookie_session_storage

I’m loosely following: https://remix.run/docs/en/main/tutorials/jokes#authentication.

I’d like to send a confirmation email after the registration form is submitted. To do that, I need to figure out how to send emails from Remix in my local dev environment:

Testing emails locally with MailHog

I’ll use MailHog (https://github.com/mailhog/MailHog) for receiving emails (locally), and nodemailer for sending emails. Resend (https://resend.com/docs/send-with-remix) seems to be a popular alternative. I’ve already got MailHog setup on my local computer, and have an email service provider configured for sending emails from my production domain, so I’ll stick with MailHog for local development.

Install nodemailer and @types/nodemailer:

$ npm install nodemailer
$ npm install --save @types/nodemailer

Configure a nodemailer transporter to send emails on port 1025 (the default Mailhog port):

// app/services/mailer.ts

import nodemailer from "nodemailer";

export let transporter = nodemailer.createTransport({
  host: "localhost",
  port: 1025,
  secure: false,
  auth: {
    user: "test",
    pass: "test",
  },
});

Create a resource route for sending emails. This is just a proof of concept:

// app/routes/mailtest.tsx

import { transporter } from "~/services/mailer";

export const loader = async () => {
  transporter.sendMail(
    {
      from: "simon@example.com",
      to: "scossar@example.com",
      subject: "Test Email",
      text: "Hello World?",
      html: "<b>Hello world?</b>",
    },
    (error, info) => {
      if (error) {
        console.log(`email error: ${error}`);
      }
      console.log(`Message sent: ${info.messageId}`);
    }
  );

  return null;
};

Start MailHog from a terminal:

$ mailhog

2024/03/20 23:31:00 Using in-memory storage
2024/03/20 23:31:00 [SMTP] Binding to address: 0.0.0.0:1025
[HTTP] Binding to address: 0.0.0.0:8025
2024/03/20 23:31:00 Serving under http://0.0.0.0:8025/

Visit the MailHog dashboard at http://localhost:8025.

To trigger the email test, visit the Remix app at /mailtest.

That’s kind of awesome! I was expecting it to be more difficult.

While I’m messing around (it’s late) I’ll add Tailwind to the remix_auth project. That will come in handy tomorrow. (For reference, Remix has recently started using Vite as its default compiler. Details for configuring Vite to work with Tailwind are here: https://remix.run/docs/en/main/future/vite#fix-up-css-imports-referenced-in-links.)

Registration and authentication with createCookieSessionStorage

I’ll start by creating login and login.register routes:

$ touch app/routes/login.tsx
$ touch app/routes/login._index.tsx
$ touch app/routes/login.register.tsx

The login form will be on login._index.tsx. The registration form will be on login.register.tsx. (/login/register as the registration URL seems a bit off.)

I’m following the concepts used in https://remix.run/docs/en/main/tutorials/jokes#build-the-login-form. Hopefully I’m not getting too far ahead of myself:

// app/routes/login._index.tsx

import type { ActionFunctionArgs } from "@remix-run/node";
import {
  Form,
  json,
  Link,
  useActionData,
} from "@remix-run/react";

export const action = async ({ request }: ActionFunctionArgs) => {
  let fieldErrors = {
    usernameOrEmail: null,
    password: null,
  };
  let fields = {
    usernameOrEmail: "",
    password: "",
  };

  return json({ fields, fieldErrors });
};

export default function LoginForm() {
  const actionData = useActionData<typeof action>();

  return (
    <div>
      <h1 className="text-2xl">Log In</h1>
      <Form
        className="flex flex-col max-w-80 border border-slate-400 p-3"
        method="post"
      >
        <label htmlFor="username-or-email">Username or Email</label>
        <input
          className="border border-slate-600"
          type="text"
          id="username-or-email"
          name="usernameOrEmail" 
          defaultValue={actionData?.fields?.usernameOrEmail}
          aria-invalid={Boolean(actionData?.fieldErrors?.usernameOrEmail)}
          aria-errormessage={
            actionData?.fieldErrors?.usernameOrEmail
              ? "Username or Email error"
              : ""
          }
        />
        {actionData?.fieldErrors?.usernameOrEmail && (
          <p>{actionData.fieldErrors.usernameOrEmail}</p>
        )}
        <label htmlFor="password">Password</label>
        <input
          className="border border-slate-600"
          type="password"
          id="password"
          name="password" 
          defaultValue={actionData?.fields?.password}
          aria-invalid={Boolean(actionData?.fieldErrors?.password)}
          aria-errormessage={
            actionData?.fieldErrors?.password ? "Password error" : ""
          }
        />
        {actionData?.fieldErrors?.password && (
          <p>{actionData.fieldErrors.password}</p>
        )}
        <div className="mt-4">
          <button className="border border-slate-600 w-16" type="submit">
            Login
          </button>
        </div>
      </Form>
      <Link
        className="text-sky-700 text-sm hover:underline"
        to="/login/register"
      >
        Register an account
      </Link>
    </div>
  );
}

If there are errors in a form submission, the fieldErrors object will make them available to the component. The previously submitted form values will be available in the fields object. They will be used to set default values for the form’s input fields. This makes it possible to give feedback in the UI about errors, and not force users to re-enter values that were correct on the previous submission. (Edit: the fields object is being used to prevent form submission from causing a negative mutation. More on that in the next post.)

I’m loving Tailwind CSS. I was initially reluctant to use it:

Logging in users

The login form has a username/email input. I’ll adjust the login function to query for a user based on their username or their email. Here’s the first attempt:

// app/services/session.server.ts

//...
interface LoginForm {
  usernameOrEmail: string;
  password: string;
}

export async function login({ usernameOrEmail, password }: LoginForm) {
  const user = await db.user.findFirst({
    where: {
      OR: [{ username: usernameOrEmail }, { email: usernameOrEmail }],
    },
  });

  if (!user) {
    return null;
  }

  const isCorrectPassword = await bcrypt.compare(password, user.passwordHash);
  if (!isCorrectPassword) {
    return null;
  }

  return { id: user.id, usernameOrEmail };
}

I want to see what the login route’s request object looks like after the form is submitted:

// app/routes/login._index.tsx

import type { ActionFunctionArgs } from "@remix-run/node";
import { Form, json, Link, useActionData } from "@remix-run/react";

export const action = async ({ request }: ActionFunctionArgs) => {
  const form = await request.formData();
  const usernameOrEmail = form.get("usernameOrEmail");
  const password = form.get("password");
  console.log(`usernameOrEmail: ${usernameOrEmail}, password: ${password}`);

Looking good:

usernameOrEmail: foo, password: bar

Now try returning a user:

// app/routes/login._index.tsx

//...
import { login } from "~/services/session.server";

export const action = async ({ request }: ActionFunctionArgs) => {
  const form = await request.formData();
  const usernameOrEmail = form.get("usernameOrEmail");
  const password = form.get("password");
  const user = await login({
    usernameOrEmail: usernameOrEmail,
    password: password,
  });
  console.log(JSON.stringify(user, null, 2));
//...

The User in the database is username: "scossar", email: "scossar@example.com", passwordHash: <hash_of_"simplepass">

Submitting the form with usernameOrEmail: "scossar", password: "simplepass" returns:

{
  "id": 1,
  "usernameOrEmail": "scossar"
}

"scossar@example.com" as the usernameOrEmail value is also working!

The next thing to deal with is this Typescript warning:

Type 'FormDataEntryValue | null' is not assignable to type 'string'.
  Type 'null' is not assignable to type 'string'.

I think the issue could be fixed with invariant, but I’ll use the approach that’s outlined in the Jokes tutorial: https://remix.run/docs/en/main/tutorials/jokes#mutations:

// app/services/request.server.ts

import { json } from "@remix-run/node";

/**
 * A helper function to return the accurate HTTP status to the client.
 */

export const badRequest = <T>(data: T) => {
  return json<T>(data, { status: 400 });
};
// app/routes/login._index.tsx

//...
import { login } from "~/services/session.server";
import { badRequest } from "~/services/request.server";

export const action = async ({ request }: ActionFunctionArgs) => {
  const form = await request.formData();
  const usernameOrEmail = form.get("usernameOrEmail");
  const password = form.get("password");

  if (typeof usernameOrEmail !== "string" || typeof password !== "string") {
    return badRequest({
      fieldErrors: null,
      fields: null,
      formError: "Form not submitted correctly.",
    });
  }
//...

To get the use of the generic parameter type in the badRequest function to stick in my head:

<T> is used to declare that the badRequest parameter can be of any type. (T could be anything (anything?) The use of T to stand for type is a convention.

(data: T) – the function’s data argument can be of any type. Typescript will infer the type based on the the the value that’s passed to the function.

VS Code makes this clear. When I hover over the call to:

badRequest({
      fieldErrors: null,
      fields: null,
      formError: "Form not submitted correctly.",
    });

it shows that the function’s return value is:

TypedResponse<{
  fieldErrors: null;
  fields: null;
  formError: string;
}>

The condition that returns badRequest is a “guard clause.”


While I’m at it, I’ll add a call to badRequest after the action attempts to return a User:

// app/routes/login._index.tsx

//...
export const action = async ({ request }: ActionFunctionArgs) => {
  const form = await request.formData();
  const usernameOrEmail = form.get("usernameOrEmail");
  const password = form.get("password");

  if (typeof usernameOrEmail !== "string" || typeof password !== "string") {
    return badRequest({
      fieldErrors: null,
      fields: null,
      formError: "Form not submitted correctly.",
    });
  }

  let fieldErrors = {
    usernameOrEmail: null,
    password: null,
  };
  const fields = {
    usernameOrEmail,
    password,
  };

  const user = await login({
    usernameOrEmail: usernameOrEmail,
    password: password,
  });
  if (!user) {
    return badRequest({
      fieldErrors: null,
      fields,
      formError: "Supplied password does not match the username or email.",
    });
  }
  console.log(JSON.stringify(user, null, 2));
//...

The next step is to create a session for the user if the correct credentials have been supplied.

Add a createUserSession function:

// app/services/session.server.ts

import { createCookieSessionStorage, redirect } from "@remix-run/node";
//...

export async function createUserSession(userId: number, redirectTo: string) {
  const session = await getSession();
  session.set("userId", userId);
  return redirect(redirectTo, {
    headers: {
      "Set-Cookie": await commitSession(session),
    },
  });
}
//...

Use createUserSession in the login action:

// app/routes/login._index.tsx

import type { ActionFunctionArgs } from "@remix-run/node";
import { Form, Link, useActionData } from "@remix-run/react";

import { createUserSession, login } from "~/services/session.server";
import { badRequest } from "~/services/request.server";

export const action = async ({ request }: ActionFunctionArgs) => {
  const form = await request.formData();
  const usernameOrEmail = form.get("usernameOrEmail");
  const password = form.get("password");

  if (typeof usernameOrEmail !== "string" || typeof password !== "string") {
    return badRequest({
      fieldErrors: null,
      fields: null,
      formError: "Form not submitted correctly.",
    });
  }

  const fields = {
    usernameOrEmail,
    password,
  };

  const user = await login({
    usernameOrEmail: usernameOrEmail,
    password: password,
  });

  if (user) {
    return createUserSession(user.id, "/");
  } else {
    return badRequest({
      fieldErrors: null,
      fields,
      formError: "Supplied password does not match the username or email.",
    });
  }
};

That works:

Todo:

  • check for the user’s session on the homepage and conditionally display either a “login” or a “log out” button
  • deal with this Typescript warnings: “Property ‘usernameOrEmail’ does not exist on type ‘never'” and “Property ‘password’ does not exist on type ‘never'”

I’ll start with the Typescript warning.

Refactoring the login route action

The Typescript warnings are related to my initial implementation of the login form:

{actionData?.fieldErrors?.usernameOrEmail && (
  <p>{actionData.fieldErrors.usernameOrEmail}</p>
)}

The goal is to display error messages in the UI when the form is re-rendered after submission. Thinking about it some more, for the case of the login form, as opposed to the registration form, the appropriate place to set fieldErrors is in the login function.

I think the login function needs to make its return types explicit.

This took a few tries:

// app/services/session.server.ts

//...
interface LoginForm {
  usernameOrEmail: string;
  password: string;
}

interface LoginSuccess {
  user: {
    id: number;
    usernameOrEmail: string;
  };
}

interface LoginFailure {
  fieldError: Record<string, string>;
}

type LoginResult = LoginSuccess | LoginFailure;

export async function login({
  usernameOrEmail,
  password,
}: LoginForm): Promise<LoginResult> {
  const user = await db.user.findFirst({
    where: {
      OR: [{ username: usernameOrEmail }, { email: usernameOrEmail }],
    },
  });

  if (!user) {
    return { fieldError: { usernameOrEmail: "User not found" } };
  }

  const isCorrectPassword = await bcrypt.compare(password, user.passwordHash);
  if (!isCorrectPassword) {
    return { fieldError: { password: "Incorrect password" } };
  }

  return { user: { id: user.id, usernameOrEmail } };
}

(Edit: displaying a “User not found” message if a match isn’t found for the username or email is questionable. )

The LoginFailure interface is a new one for me:

interface LoginFailure {
  fieldError: Record<string, string>;
}

That interface is saying that objects of that type must have a fieldError property. The Record<string, string> type for the fieldError property is using the Typescript Record<K, T> utility type. It’s used for objects with key’s K and values T.

The reason why the LoginSuccess and LoginFailure types can be defined with an interface, but the LoginResult type needs to be defined with a type is because unions (LoginResult = LoginSuccess | LoginFailure) can’t be preformed on interfaces.

Using the login function’s return values:

// app/routes/login._index.tsx

//...
  const loginReaponse = await login({
    usernameOrEmail: usernameOrEmail,
    password: password,
  });

  let fieldError, user;
  if ("user" in loginReaponse) {
    user = loginReaponse.user;
  } else if ("fieldError" in loginReaponse) {
    fieldError = loginReaponse.fieldError;
  }
//...

That keeps typescript happy 🙂

The possible values of loginResponse are used here:

// app/routes/login._index.tsx

//...
  if (user) {
    return createUserSession(user.id, "/");
  } else if (fieldError?.usernameOrEmail) {
    return badRequest({
      fieldErrors: fieldError,
      fields,
      formError: "User not found",
    });
  } else if (fieldError?.password) {
    return badRequest({
      fieldErrors: fieldError,
      fields,
      formError: "Wrong password",
    });
  } else {
    return badRequest({
      fieldErrors: null,
      fields: null,
      formError: "Something went wrong",
    });
  }
};

If a user is returned, they are redirected to /.

If a user isn’t returned, the badRequest function is called to pass the appropriate data to the client.

This might be overkill, but it meets my initial requirements:

I’ll likely make some changes after adding the user registration form.

Login and Registration components

The initial plan was to have the login form at /login and the registration form at /login/registration. That doesn’t seem right. The easiest fix would be to have the login form at /login and the registration form at /register. That still seems a bit off.

Instead of using distinct URLs, I’ll make a LoginForm and a RegistrationForm component. Both components will be loaded from the login route. Copying how WordPress handle this, the registration form will be rendered on the login route if the ?action=register parameter is in the URL.

LoginForm component

After moving the login code to a component, I started getting Typescript warnings for the component’s props. Attempts to remove the warnings resulted in creating more and more complicated type definitions. The code runs as expected, but the difficulty I’m having with removing the warnings seems to point to something being off.

… That was interesting. It seemed (and still seems) to me that fieldErrors should be an array of fieldError objects. So I redefined the LoginForm props:

interface Fields {
  usernameOrEmail?: string;
  password?: string;
}

export interface FieldError {
  key: string;
  message: string;
}

export type FieldErrors = FieldError[];

interface LoginProps {
  fields?: Fields | null;
  fieldErrors?: FieldErrors | null;
  formError?: string | null;
}

If I’m understanding the Typescript definitions correctly, the pattern:

propName?: propType | null;

is the same as:

propName: Maybe<T>;

when using graphql/jsutils/Maybe.

That pattern, or something similar, might be required when getting data from form submission with useActionData()

export default function Login() {
  const actionData = useActionData<typeof action>();
  const fields = actionData?.fields;
  const fieldErrors = actionData?.fieldErrors;
  const formError = actionData?.formError;

I guess an alternative would be:

export default function Login() {
  const actionData = useActionData<typeof action>();
  const fields = actionData?.fields ?? undefined;
//...

I spent a long time sorting out an issue with the action function’s guard condition:

export const action = async ({ request }: ActionFunctionArgs) => {
  const form = await request.formData();
  const usernameOrEmail = form.get("usernameOrEmail");
  const password = form.get("password");

  if (typeof usernameOrEmail !== "string" || typeof password !== "string") {
    return json(
      {
        fields: null,
        fieldErrors: [],
        formError: "Form not submitted correctly.",
      },
      { status: 400 }
    );
  }

Returning an empty array (fieldErrors: [] ) seemed like the right thing to do, but it triggered the Typescript warning:

Type 'null[] | JsonifyObject<FieldError>[] |
undefined' is not assignable to type
'FieldErrors | null | undefined'.

I think the JsonifyObject<T> type comes from Remix’s json helper function: https://remix.run/docs/en/main/utils/json. In any case, it seems appropriate that any of the fields that are passed to the LoginForm component should be allowed to accept a null value.

The fix was:

export const action = async ({ request }: ActionFunctionArgs) => {
  const form = await request.formData();
  const usernameOrEmail = form.get("usernameOrEmail");
  const password = form.get("password");

  if (typeof usernameOrEmail !== "string" || typeof password !== "string") {
    return json(
      {
        fields: null,
        fieldErrors: null,
        formError: "Form not submitted correctly.",
      },
      { status: 400 }
    );
  }

After some back and forth, I settled on passing a fieldError (singular) prop. Accessing the prop in the component feels awkward, but this is good for now:

// app/services/session.server.ts

//...
interface LoginForm {
  usernameOrEmail: string;
  password: string;
}

interface LoginSuccess {
  user: {
    id: number;
    usernameOrEmail: string;
  };
}

interface LoginFailure {
  fieldError: FieldError;
}

type LoginResult = LoginSuccess | LoginFailure;

export async function login({
  usernameOrEmail,
  password,
}: LoginForm): Promise<LoginResult> {
  const user = await db.user.findFirst({
    where: {
      OR: [{ username: usernameOrEmail }, { email: usernameOrEmail }],
    },
  });

  if (!user) {
    return {
      fieldError: { key: "usernameOrEmail", message: "User not found" },
    };
  }

  const isCorrectPassword = await bcrypt.compare(password, user.passwordHash);
  if (!isCorrectPassword) {
    return { fieldError: { key: "password", message: "Incorrect password" } };
  }

  return { user: { id: user.id, usernameOrEmail } };
}
// app/routes/login._index.tsx

//...
export const action = async ({ request }: ActionFunctionArgs) => {
  const form = await request.formData();
  const usernameOrEmail = form.get("usernameOrEmail");
  const password = form.get("password");

  if (typeof usernameOrEmail !== "string" || typeof password !== "string") {
    return json(
      {
        fields: null,
        fieldError: null,
        formError: "Form not submitted correctly.",
      },
      { status: 400 }
    );
  }

  let user, fieldError, formError;

  const fields = {
    usernameOrEmail,
    password,
  };

  const loginReaponse = await login({
    usernameOrEmail: usernameOrEmail,
    password: password,
  });

  if ("user" in loginReaponse) {
    user = loginReaponse.user;
    return createUserSession(user.id, "/");
  }

  if ("fieldError" in loginReaponse) {
    fieldError = loginReaponse.fieldError;
  } else {
    fieldError = null;
    formError = "Something's gone wrong";
  }

  return json(
    {
      fields: fields,
      fieldError: fieldError,
      formError: formError,
    },
    { status: 400 }
  );
};

export default function Login() {
  const actionData = useActionData<typeof action>();
  const fields = actionData?.fields;
  const fieldError = actionData?.fieldError;
  const formError = actionData?.formError;

  return (
    <div>
      <h1 className="text-2xl">Log In</h1>
      <LoginForm
        fields={fields}
        fieldError={fieldError}
        formError={formError}
      />
    </div>
  );
}
// app/components/LoginForm.tsx

import { Form } from "@remix-run/react";

interface Fields {
  usernameOrEmail?: string;
  password?: string;
}

export interface FieldError {
  key: string;
  message: string;
}

interface LoginProps {
  fields?: Fields | null;
  fieldError?: FieldError | null;
  formError?: string | null;
}

export default function LoginForm({
  fields,
  fieldError,
  formError,
}: LoginProps) {
  const emailErrorMessage =
    fieldError?.key === "usernameOrEmail" ? fieldError?.message : undefined;
  const passwordErrorMessage =
    fieldError?.key === "password" ? fieldError?.message : undefined;

  return (
    <div>
      <Form
        className="flex flex-col max-w-80 border border-slate-400 p-3"
        method="post"
      >
        <label htmlFor="username-or-email">Username or Email</label>
        <input
          className="border border-slate-600 px-1"
          type="text"
          id="username-or-email"
          name="usernameOrEmail"
          defaultValue={fields?.usernameOrEmail}
          aria-invalid={Boolean(emailErrorMessage)}
          aria-errormessage={emailErrorMessage}
        />
        {emailErrorMessage && (
          <p className="text-sm text-red-600">{emailErrorMessage}</p>
        )}
        <label htmlFor="password">Password</label>
        <input
          className="border border-slate-600 px-1"
          type="password"
          id="password"
          name="password"
          defaultValue={fields?.password}
          aria-invalid={Boolean(passwordErrorMessage)}
          aria-errormessage={passwordErrorMessage ? passwordErrorMessage : ""}
        />
        {passwordErrorMessage && (
          <p className="text-sm text-red-600">{passwordErrorMessage}</p>
        )}
        {formError && (
          <div className="border border-red-500 p-2 my-2 rounded-sm text-sm">
            {formError} // warning 'FormError is not assignable to type
            ReactNode'
          </div>
        )}

        <div className="mt-4">
          <button className="border border-slate-600 w-16" type="submit">
            Login
          </button>
        </div>
      </Form>
    </div>
  );
}

I’ll add a RegistrationForm component in the next post. Before doing that, I’m going to convert the login form to use fetcher.Form instead of form.