Authentication: Register & Login Upload form data with file in Remix & Mysql

 In the previous tutorial, you learnt to do use Prisma to synchronize models with MYSQL database schema and create a migration to add a new column to users table

In this tutorial, you learn how to create authentication system in Remix. A visitor registers an user account using register form. In the register form, the user can upload an profile image and other information such as first name, last name, username, email, and password. Upon successful registration, the user account is saved in MYSQL database. The password is encrypted using bcryptjs.

A registered user logins to the app by providing username and password. A successful login will create a session for that user. To access a restricted API, a user has to be authenticated. In a restricted component, the user id is read from the session. If there is a valid user id in the session, the user is allowed to access the restricted API. Otherwise, it will be redirected to the login page.

Run the following command to install bscryptjs:

npm install bcryptjs


User Registration

In the app folder, create utils folder. In the utils, create db.tsx file to define a prisma client object that act as a repository. The repository is used to manage user data in MYSQL database.


utils/dt.tsx

 import { PrismaClient } from "@prisma/client";


let db: PrismaClient;

declare global {
  var __db: PrismaClient | undefined;
}

if (process.env.NODE_ENV === "production") {
  db = new PrismaClient();
} else {
  if (!global.__db) {
    global.__db = new PrismaClient();
  }
  db = global.__db;
}

export { db };

In the routes folder, create user.ts file. The user.ts works as a container to host other components like register, login, etc.

routes/user.jsx
import { Outlet } from "react-router";
export default function User() {
 return (
  <>
   <Outlet />
  </>
 );
}

Create user folder in routers folder. Add the register.jsx file to user folder.

routers/user/register.jsx
import  bcrypt from 'bcryptjs';
import { redirect } from "@remix-run/node";
import { 
  json,
  unstable_createFileUploadHandler,
  unstable_parseMultipartFormData,
  unstable_composeUploadHandlers,
  unstable_createMemoryUploadHandler
 } from "@remix-run/node";
 
import {db} from "../../utils/db";
import { 
  Form,
  useActionData,
  useTransition,
} from "@remix-run/react";

export const meta = () => {
  const title = 'Register';
  const description = 'Here is my Awesome Register page!';

  return {
    charset: 'utf-8',
    title,
    description,
    keywords: 'Remix,register, create user, Mysql',
    
  };
};

export async function action({ request}) {

  const validateEmail = (email) => {
    if (!email) {
      return "Email is Required!";
    } else if (!(/\S+@\S+\.\S+/.test(email)))  {
      return "Invalid emaill!";
    }
  };

  const validateUsername = (username) => {
    if (!username) {
      return "Username is Required!";
    }
    else if(!( /^[A-Za-z]+/.test(username))){
      return "Username must start with a letter!";
    } 
  };

  const validatePassword = (password) => {
    if (!password) {
      return "Password is Required!";
    }
    else if(!( /^[A-Za-z]\w{8,16}$/.test(password))) {
      return "Weak password! A strong password should contain characters, numeric digits, underscore and first character must be a letter."; 
    }
  };

  const uploadHandler_ = unstable_composeUploadHandlers(
    // parse file
    unstable_createFileUploadHandler({
      maxPartSize: 5_000_000,
      directory: "public/uploads",
      file: ({ filename }) => filename,
    }),
    // parse other form data into memory
    unstable_createMemoryUploadHandler()
  );

  const formData = await unstable_parseMultipartFormData(
    request,
    uploadHandler_
  );

  const file = formData.get("photo");
  const fname= formData.get("firstname");
  const lname= formData.get("lastname");
  const uname= formData.get("username");
  const email= formData.get("email");
  let password= formData.get("password");

  const formErrors = {
    email: validateEmail(email),
    username: validateUsername(uname),
    password: validatePassword(password)
  };
  //if there are errors, we return the form errors
  if (Object.values(formErrors).some(Boolean)) return { formErrors };
  // generate enctypted password
  password= await bcrypt.hash(password, 10);


  const user=await db.users.create(
    {
      data:{
      firstName:fname,
      lastName:lname,
      username:uname,
      email:email,
      password:password,
      photo:file?file.name:'',
      createdAt: new Date(),
      updatedAt: new Date()
    }}
  );
  if(user){
    console.log("create user:",user);
    return redirect('/user/login');
  }
  else{
    console.log("err:","failed to create the user");
  }
  
  if (file) {
    console.log(`File uploaded to public/uploads/${file.name}`);
  } else {
    console.log("No file uploaded",formData);
  }

  return {};

}

// Note the "action" export name, this will handle our form POST

export default function Register() {
    const transition = useTransition();  
    const  data = useActionData();
  
    return (
      <div className="grid place-items-center">
   
      <div className="w-full max-w-xs">
      <Form method="post" encType="multipart/form-data" className="bg-gray-200 shadow-md rounded px-8 pt-6 pb-8 mb-4 self-center">
      <fieldset
        disabled={transition.state === "submitting"}
      >
        <div className="mb-4">
          <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="firstname">
            First name
          </label>
          <input className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" id="firstname" name="firstname" type="text" placeholder="First name"/>
        </div>
        <div className="mb-4">
          <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="lastname">
            Last name
          </label>
          <input className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" id="lastname" name="lastname" type="text" placeholder="Last name"/>
        </div>
        <div className="mb-4">
          <label className="block text-gray-700  text-sm font-bold mb-2" htmlFor="username">
            Username
          </label>
          <input className="shadow appearance-none border border-red-500 rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" id="username" name="username" type="text" placeholder="Username"/>
          {
            data && data.formErrors && data.formErrors.username && <p className="text-red-500 text-xs italic">
                {data.formErrors.username}
            </p>
          }
        </div>
        <div className="mb-4">
          <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="email">
            Email
          </label>
          <input className="shadow appearance-none border border-red-500 rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" id="email" name="email" type="text" placeholder="Email"/>
          {
            data && data.formErrors && data.formErrors.email && <p className="text-red-500 text-xs italic">
                {data.formErrors.email}
            </p>
          }
          </div>
        <div className="mb-6">
          <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="password">
            Password
          </label>
          <input className="shadow appearance-none border border-red-500 rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" id="password" name="password" type="password" placeholder="******************"/>
          {
            data && data.formErrors && data.formErrors.password && <p className="text-red-500 text-xs italic">
                {data.formErrors.password}
            </p>
          }
        </div>
        <div className="mb-6">
          <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="photo">
            Profile Photo
          </label>
          <input type="file" name="photo" className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" id="photo"/>

      
        </div>
        <div className="flex items-center justify-between">
          <button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" type="submit">
          {transition.state === "submitting"
              ? "Submitting..."
              : "Submit"}
          </button>
        
        </div>
        </fieldset>
      </Form>
      
    </div>
  </div>
    );
  }
  

Handling form data in Remix is similar to PHP.  You defined a form in html with post method.
Remix provides a convenient function action() to process submitted form data. To get json data returned from the action() function in Register component, you use useActionData.

Upload Form Data with File

To upload form data with file, you need unstable_parseMultipartFormData() function. It requires request object and upload handler. You create the upload handler using unstable_composeUploadHandlers() function in which you can config max file size in byte to be uploaded, file storage, and file name using unstable_createFileUploadHandler() function. To extract data other than files, you use unstable_createMemoryUploadHandler() function.

Form Validations

After form data is submitted, in the action() function, we validate email, username, and password. If the email, username, or password is not matched the pattern defined, the error will recorded in formErrors and sent backed to display on the form.

User Login & Logout

In the utils folder, create session.tsx. The session.tsx file defines functions to create session storage, create session to store user id in the storage, read user id from the session, and to logout the user.

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

const sessionSecret = 'sessionYsecret';
if (!sessionSecret) {
    throw new Error("SESSION_SECRET must be set");
}
const storage = createCookieSessionStorage({
    cookie: {
        name: "user_session",
        // normally you want this to be `secure: true`
        // but that doesn't work on localhost for Safari
        // https://web.dev/when-to-use-local-https/
        secure: process.env.NODE_ENV === "production",
        secrets: [sessionSecret],
        sameSite: "lax",
        path: "/",
        maxAge: 60 * 60 * 24 * 30,
        httpOnly: true,
    },
});
export async function createUserSession(
    userId: string,
    redirectTo: string
) {
    const session = await storage.getSession();
    session.set("userId", userId);

    return redirect(redirectTo, {
        headers: {
            "Set-Cookie": await storage.commitSession(session),
        },
    });
}
function getUserSession(request: Request) {
    return storage.getSession(request.headers.get("Cookie"));
}
export async function getUserId(request: Request) {
    const session = await getUserSession(request);
    const userId = session.get("userId");

    if (!userId || typeof userId !== "number") return null;
    return userId;
  }
export async function logout(request: Request) {
    const session = await getUserSession(request);
    return redirect("/user/login", {
      headers: {
        "Set-Cookie": await storage.destroySession(session),
      },
    });
  }  

In the user folder, create login.jsx and logout.jsx.

user/login.jsx
import { json } from "@remix-run/node";
import {db} from "../../utils/db";
import {createUserSession} from "../../utils/session";
import bcrypt from 'bcryptjs';

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

export const meta = () => {
  const title = 'Login';
  const description = 'Here is my Awesome login page!';

  return {
    charset: 'utf-8',
    title,
    description,
    keywords: 'Remix,Awesome, Login, Mysql',
    
  };
};
export async function action({ request}) {
  const body = await request.formData();
  const username= body.get("username");
  const password= body.get("password");
  var mess="";
  const user = await db.users.findFirst({
    where: { username: username }
  });
  if(user){
    const isCorrectPassword = await bcrypt.compare(
      password,
      user.password
    );
    if (!isCorrectPassword) mess="Invalid password!";
    else{
    
      return await createUserSession(user.id, '/user');   
    }
  }
  else{
    mess="User not found";
  }
 
  return json({message:mess});
}

export default function Login() {
    const transition = useTransition();  
    const  data = useActionData();
   
  
    return (
    <div className="grid place-items-center">
   
    <div className="w-full max-w-xs">
    <Form method="post" className="bg-gray-200 shadow-md rounded px-8 pt-6 pb-8 mb-4 self-center">
    <fieldset
        disabled={transition.state === "submitting"}
      >
      <div className="mb-4">
        <label className="block text-gray-700 text-sm font-bold mb-2" htmlFor="username">
          Username
        </label>
        <input className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" name="username" id="username" type="text" placeholder="Username"/>
      </div>
      <div className="mb-6">
        <label classname="block text-gray-700 text-sm font-bold mb-2" htmlFor="password">
          Password
        </label>
        <input className="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 mb-3 leading-tight focus:outline-none focus:shadow-outline" name="password" id="password" type="password" placeholder="******************"/>

      </div>
      <div className="flex items-center justify-between">
        <button className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" type="submit">
        {transition.state === "submitting"
              ? "Validating..."
              : "Log In"}
        </button>
        <a href="/user/register" className="inline-block align-baseline font-bold text-sm text-blue-500 hover:text-blue-800">
          No account? create user
        </a>
       
      </div>
      <p className="text-red-500 text-xs italic">{data ? data.message : ""}</p>
      </fieldset>

    </Form>
    
  </div>
  </div>
    );
  }
  

logout.jsx
import { logout } from "~/utils/session";

export const loader = async ({request}) => {
  return logout(request);
};

Save the project, While MYSQL is running, click login icon at the top right of the page to open login form. From the login form, you are able to register a new user.




Comments

Popular posts from this blog

A simple Admin page to manage user list in Remix & MYSQL

Style Remix app with Tailwind CSS