Let’s built a server-side rendered application Pt 2
In the last blog, we discussed how to build a simple API with Deno and Express. Today, we’ll focus on the front-end aspect of our application using Handlebars and how to register and create our users.
Now, I know I mentioned using Express in the previous tutorial, but I lied LOL. At the heart of the application, in the index file, we’ll be importing and using each of these packages.
import { Application, Router } from "https://deno.land/x/[email protected]/mod.ts";
import { Handlebars } from "https://deno.land/x/handlebars/mod.ts";
import { join } from "https://deno.land/std/path/mod.ts";
import { oakCors } from "https://deno.land/x/cors/mod.ts";
Application, Router — Used to create the api routes in our project.
Handlebars — This package is used so that our project can read the handlebar templates that we’ll be creating shortly.
Join — We’ll be using this to join paths together so our code can accurately pin point our handlebar templates
oakCors — Used to make sure Cross-Origin Resource is enable on our routes
Now that we have imported these packages, let’s put them to use. We will first create a new instance of applcation and router.
const app = new Application();
const router = new Router();
Up next, we need to create a new instance of handlebars and add some options to it
const handle = new Handlebars({
baseDir: join(Deno.cwd(), "/pages"),
extname: ".handlebars",
partialsDir: "views/",
layoutsDir: "layouts/",
defaultLayout: "main",
helpers: undefined,
compilerOptions: undefined,
});
baseDir — used to pin point where our handlebars document are
extname — We are telling this handlebar instance only to target files with the ‘.handlebars’ extension.
partialDir — Where our partial views are
layoutDir — Where our layout are
defaultLayout — What layout all handlebar templates should use by default
As for helpers and compilerOptions, leave those as undefined for now.
Also make sure to add these middleware and listen for request coming to port 4000 using this code
app.use(oakCors());
app.use(router.routes());
app.use(router.allowedMethods());
await app.listen({ port: 4000 });
Although we have configured our code to use Handlebars, we haven’t created the necessary directories yet. So let’s get started.
In the base directory of your project, create a folder called pages. Inside pages, create another folder called layouts, and within this folder, create a file called main.handlebars. If you're not familiar with Handlebars, you can read their documentation on their official website.
I will be using Tailwind CSS to style this application, but you can use any framework you prefer. Here’s what my main.handlebars file looks like:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{title}}</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<main>
{{{body}}}
</main>
</html>
Now outside the layouts folder we’ll be creating two more handlebars files login and register.
login.handlebars:
<body class="bg-gray-100 flex items-center justify-center h-screen w-100">
<div class="w-full max-w-sm bg-white rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold mb-6 text-center">Login</h2>
<form action="/loginUser" method="POST">
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2" for="username">
Email
</label>
<input name="email" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" id="username" type="email" placeholder="Enter Email">
</div>
<div class="mb-6">
<label class="block text-gray-700 text-sm font-bold mb-2" for="password">
Password
</label>
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" id="password" type="password" placeholder="Password">
</div>
<div class="flex items-center justify-between">
<button class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" type="button">
Sign In
</button>
<a class="ml-3 inline-block align-baseline font-bold text-sm text-blue-500 hover:text-blue-800" href="/register">
Don't have an account? Click Here
</a>
</div>
</form>
</div>
</body>
register.handlebars:
<body class="bg-gray-100 flex items-center justify-center h-screen w-100">
<div class="w-full max-w-sm bg-white rounded-lg shadow-md p-6">
<h2 class="text-2xl font-bold mb-6 text-center">Register</h2>
<p class="block text-red-700 text-sm font-bold mb-2">{{error}}</p>
<form action="/registerUser" method="POST" >
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2" for="username">
Email
</label>
<input name="email" class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" id="username" type="email" placeholder="Email">
</div>
<div class="mb-4">
<label class="block text-gray-700 text-sm font-bold mb-2" for="username">
Username
</label>
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" id="username" name="username" type="text" placeholder="Username">
</div>
<div class="mb-6">
<label class="block text-gray-700 text-sm font-bold mb-2" for="password">
Password
</label>
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" id="password" name="password" type="password" placeholder="Password">
</div>
<div class="mb-6">
<label class="block text-gray-700 text-sm font-bold mb-2" for="password">
Confirm Password
</label>
<input class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline" id="password" name="c-password" type="password" placeholder="Password">
</div>
<div class="flex items-center justify-between">
<button type="submit" class="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline" type="button">
Sign In
</button>
<a class="ml-3 inline-block align-baseline font-bold text-sm text-blue-500 hover:text-blue-800" href="/">
Already have an account? Click Here
</a>
</div>
</form>
</div>
</body>
Now, back at the root level of your project, create a TypeScript file called supabase.ts. In this file, you will create a Supabase client that all your other files will import from to gain access to your Supabase database.
supabase.ts:
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
const supabase = createClient('supabase URL', 'anon-key')
export default supabase;
You should have received your anon-key and Supabase URL when you signed up and created your account and project.
Now, back in your index.ts file, we are going to create some routes. Using the router instance, we will create two routes: one to direct us to our login page and the other for our register page.
领英推荐
router.get('/', async (context) => {
const data = {
title: "Login",
};
const html = await handle.renderView("login", data);
context.response.body = html;
})
router.get('/register', async (context) => {
const data = {
title: "Register",
};
const html = await handle.renderView("register", data);
context.response.body = html;
})
What we are doing here is using our Handlebars instance to render our Handlebars pages and assign them to the response body of our routes. When a browser calls these routes, our Handlebars pages will be displayed in the browser.
Now, run your application, and you should see the login screen appear in your browser.
I like to code using the best practices. You don’t have to do this, but before moving along, we’re going to create some models using TypeScript classes. In the root of your directory, create a folder named models, and inside it, create a file named users.ts. This file will serve as the model for the user data we are storing in our database.
Inside this file, we are going to import our supabase file as well as Deno's bcrypt package.
import sb from "../supabase.ts";
import * as bcrypt from "https://deno.land/x/bcrypt/mod.ts";
We will be using bcrypt to hash our users’ passwords when they register and to compare passwords when logging in.
Now, we’re going to create a User class. This class will have some properties as well as some functions to keep our code clean and organized.
Let’s start by creating the User class:
class Users {
private email?: string;
private username?: string;
private password?: string;
private cPassword?: string;
constructor(obj: URLSearchParams) {
this.email = obj.get("email")?.toString();
this.username = obj.get("username")?.toString();
this.password = obj.get("password")?.toString();
this.cPassword = obj.get("c-password")?.toString();
}
}
This class will have four properties: email, username, password, and cPassword for confirming the user's password when signing up. We will assign values to these properties through the constructor. You might notice the constructor is expecting data that has the type URLSearchParams. This is because HTML forms normally return data in this format, so we need to code accordingly.
Next, we’ll add in two private functions:
private getRandomNumber = (): Number => {
return Math.floor(Math.random() * 10000) + 1;
}
private checkEmail = async (email?: string): Promise<Boolean> => {
let data = await sb.from("users").select().eq("email", email)
if (data.data.length > 0) {
return false
}
return true
}
These two functions are utility functions for our User class. One generates a random number to help with hashing, and the other checks if an email is already in the database, returning a boolean value based on whether the email exists or not.
Next, we are going to add a function to save a new user to the database:
public saveUser = async (): Promise<Object> => {
if (this.password == this.cPassword) {
let checker: Boolean = await this.checkEmail(this.email);
if (checker == true) {
const hash = await bcrypt.hash(this.email + " " + this.getRandomNumber().toString());
const passwordHash = await bcrypt.hash(this.password);
let data = await sb.from("users").insert({
email: this.email,
username: this.username,
password: passwordHash,
token: hash
})
if (data.error == null) {
return { valid: true, msg: "", token: hash }
} else {
return { valid: false, msg: "An error has occured" }
}
} else {
return { valid: false, msg: "Email Already Taken" }
}
} else {
return { valid: false, msg: "Passwords doesn't match" }
}
}
Here’s how the process works in the file: First, we check if the password and confirmPassword variables match. If they don't match, we return a message and a boolean. If they do match, we proceed to check if the email is already in the database. If it isn't, we then combine the user's email with a random number to create a hash for our token using bcrypt. I'll explain the purpose of this token later. After generating the token, we then hash the user's password then we save the user's information in the database and return an object with the token and a boolean indicating success. Finally, we export the class.
Your entire file should look something like this:
import sb from "../supabase.ts";
import * as bcrypt from "https://deno.land/x/bcrypt/mod.ts";
class Users {
private email?: string;
private username?: string;
private password?: string;
private cPassword?: string;
constructor(obj: URLSearchParams) {
this.email = obj.get("email")?.toString();
this.username = obj.get("username")?.toString();
this.password = obj.get("password")?.toString();
this.cPassword = obj.get("c-password")?.toString();
}
private getRandomNumber = (): Number => {
return Math.floor(Math.random() * 10000) + 1;
}
private checkEmail = async (email?: string): Promise<Boolean> => {
let data = await sb.from("users").select().eq("email", email)
if (data.data.length > 0) {
return false
}
return true
}
public saveUser = async (): Promise<Object> => {
if (this.password == this.cPassword) {
let checker: Boolean = await this.checkEmail(this.email);
if (checker == true) {
const hash = await bcrypt.hash(this.email + " " + this.getRandomNumber().toString());
const passwordHash = await bcrypt.hash(this.password);
let data = await sb.from("users").insert({
email: this.email,
username: this.username,
password: passwordHash,
token: hash
})
if (data.error == null) {
return { valid: true, msg: "", token: hash }
} else {
return { valid: false, msg: "An error has occured" }
}
} else {
return { valid: false, msg: "Email Already Taken" }
}
} else {
return { valid: false, msg: "Passwords doesn't match" }
}
}
}
export default Users;
Now back into your index file, import the users.ts file and create this route:
router.post('/registerUser', async (context) => {
// template data that we'll be sending to the handlebars
const templateData:Object = {
title: "Home Page",
error: ""
};
const body = await context.request.body();
const value = await body.value
const newUser = new Users(value);
const data = await newUser.saveUser()
if (data.valid == true) {
await context.cookies.set('token', data.token, {
httpOnly: true, // Makes the cookie accessible only by the web server
sameSite: 'strict', // Helps prevent CSRF attacks
maxAge: 60 * 60 * 24 // Cookie expiration time in seconds
})
context.response.body = "<h1>Success</h1>";
} else {
templateData.error = data.msg;
const html = await handle.renderView("register", templateData);
context.response.body = html;
}
})
As you might recall, in our register.handlebars file, we have a form that submits a POST request to /registerUser when it's submitted. In the backend, we handle this by extracting the form data, creating a new instance of a user with that data passed into its constructor, and then calling a function like saveUser to store it in the database.:
const body = await context.request.body();
const value = await body.value;
const newUser = new Users(value);
const data = await newUser.saveUser();
After handling the registration process and checking the validity of the returned data, if valid is false, we redirect the user back to the register page. We pass an object containing the appropriate error message, which is then displayed at the top of the register form.
If valid is true, our next step is to create a cookie for authentication purposes. This allows us to authenticate the user immediately after registration. We do this by passing the token we received from the save user function and assigning to a token variable in the context.cokies.set() function. Finally, we return a simple HTML snippet to confirm the registration for now.
if (data.valid == true) {
await context.cookies.set('token', data.token, {
httpOnly: true, // Makes the cookie accessible only by the web server
sameSite: 'strict', // Helps prevent CSRF attacks
maxAge: 60 * 60 * 24 // Cookie expiration time in seconds
})
context.response.body = "<h1>Success</h1>";
} else {
templateData.error = data.msg;
const html = await handle.renderView("register", templateData);
context.response.body = html;
}
Your entire index page should look like this:
import { Application, Router } from "https://deno.land/x/[email protected]/mod.ts";
import { Handlebars } from "https://deno.land/x/handlebars/mod.ts";
import { join } from "https://deno.land/std/path/mod.ts";
import { oakCors } from "https://deno.land/x/cors/mod.ts";
import Users from "./models/users.ts";
const app = new Application();
const router = new Router();
const handle = new Handlebars({
baseDir: join(Deno.cwd(), "/pages"),
extname: ".handlebars",
partialsDir: "views/",
layoutsDir: "layouts/",
defaultLayout: "main",
helpers: undefined,
compilerOptions: undefined,
});
router.get('/', async (context) => {
const data = {
title: "Login",
};
const html = await handle.renderView("login", data);
context.response.body = html;
})
router.get('/register', async (context) => {
const data = {
title: "Register",
};
const html = await handle.renderView("register", data);
context.response.body = html;
})
router.post('/registerUser', async (context) => {
const templateData:Object = {
title: "Home Page",
error: ""
};
const body = await context.request.body();
const value = await body.value
// console.log(value.get("email"))
const newUser = new Users(value);
const data = await newUser.saveUser()
if (data.valid == true) {
await context.cookies.set('token', data.token, {
httpOnly: true, // Makes the cookie accessible only by the web server
sameSite: 'strict', // Helps prevent CSRF attacks
maxAge: 60 * 60 * 24 // Cookie expiration time in seconds (1 day)
})
context.response.body = "<h1>Success</h1>";
} else {
templateData.error = data.msg;
const html = await handle.renderView("register", templateData);
context.response.body = html;
}
})
app.use(oakCors());
app.use(router.routes());
app.use(router.allowedMethods());
await app.listen({ port: 4000 });
Before trying this you should make sure you’ve created a table in your supabase project that properly correspond with the data we are sending in the code.
If you want a table exactly like mine. These are the properties.
With this I conclude part two. In part three when we start building our dashboard to direct user to when they login or register and functions to create our blogs.