Introduction
We have made a lot of changes in the backend system without a corresponding change in the front end. We’ll do that in this article.
Source code
The source code for this series is hosted on GitHub via:
A fullstack session-based authentication system using golang and sveltekit
This repository accompanies a series of tutorials on session-based authentication using Go at the backend and JavaScript (SvelteKit) on the front-end.
It is currently live here (the backend may be brought down soon).
To run locally, kindly follow the instructions in each subdirectory.
Implementation
Step 1: Regenerate the token page
To regenerate tokens, users need to submit their unverified email addresses. Let’s create the route:
import { applyAction, enhance } from '$app/forms';
import { receive, send } from '$lib/utils/helpers';
import { scale } from 'svelte/transition';
/** @type {import('./$types').ActionData} */
export let form;
/** @type {import('./$types').SubmitFunction} */
const handleGenerate = async () => {
return async ({ result }) => {
await applyAction(result);
};
};
class="container">
The route will be /auth/regenerate-token
. It only has one input and the page looks like this:
Its corresponding +page.server.js
is:
// frontend/src/routes/auth/regenerate-token/+page.server.js
import { BASE_API_URI } from '$lib/utils/constants';
import { formatError, isEmpty, isValidEmail } from '$lib/utils/helpers';
import { fail, redirect } from '@sveltejs/kit';
/** @type {import('./$types').Actions} */
export const actions = {
default: async ({ fetch, request }) => {
const formData = await request.formData();
const email = String(formData.get('email'));
// Some validations
/** @type {Record} */
const fieldsError = {};
if (!isValidEmail(email)) {
fieldsError.email = 'That email address is invalid.';
}
if (!isEmpty(fieldsError)) {
return fail(400, { fieldsError: fieldsError });
}
/** @type {RequestInit} */
const requestInitOptions = {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ email: email })
};
const res = await fetch(`${BASE_API_URI}/users/regenerate-token/`, requestInitOptions);
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.error);
return fail(400, { errors: errors });
}
const response = await res.json();
// redirect the user
throw redirect(302, `/auth/confirming?message=${response.message}`);
}
};
Here, we are using the default form action hence the reason we omitted the action
attribute on the form
tag.
Password reset request is almost exactly like this route. Same with the password change route. As a result, I won’t discuss them in this article to avoid repetition. However, the pages’ images are shown below:
Their source codes are in this folder in the repository.
Step 2: Profile Update, Image upload and deletion
Now to the user profile update. The route is in frontend/src/routes/auth/about/[id]/+page.svelte
whose content looks like this:
import { applyAction, enhance } from '$app/forms';
import { page } from '$app/stores';
import ImageInput from '$lib/components/ImageInput.svelte';
import Modal from '$lib/components/Modal.svelte';
import SmallLoader from '$lib/components/SmallLoader.svelte';
import Avatar from '$lib/img/teamavatar.png';
import { receive, send } from '$lib/utils/helpers';
$: ({ user } = $page.data);
let showModal = false,
isUploading = false,
isUpdating = false;
const open = () => (showModal = true);
const close = () => (showModal = false);
/** @type {import('./$types').ActionData} */
export let form;
/** @type {import('./$types').SubmitFunction} */
const handleUpdate = async () => {
isUpdating = true;
return async ({ result }) => {
isUpdating = false;
if (result.type === 'success' || result.type === 'redirect') {
close();
}
await applyAction(result);
};
};
/** @type {import('./$types').SubmitFunction} */
const handleUpload = async () => {
isUploading = true;
return async ({ result }) => {
isUploading = false;
/** @type {any} */
const res = result;
if (result.type === 'success' || result.type === 'redirect') {
user.thumbnail = res.data.thumbnail;
}
await applyAction(result);
};
};
class="hero-container">
class="hero-logo">
src={user.thumbnail ? user.thumbnail : Avatar}
alt={`${user.first_name} ${user.last_name}`}
/>
class="hero-subtitle subtitle">
Name (First and Last): {`${user.first_name} ${user.last_name}`}
{#if user.profile.phone_number}
class="hero-subtitle">
Phone: {user.profile.phone_number}
{/if}
{#if user.profile.github_link}
class="hero-subtitle">
GitHub: {user.profile.github_link}
{/if}
{#if user.profile.birth_date}
class="hero-subtitle">
Date of birth: {user.profile.birth_date}
{/if}
class="hero-buttons-container">
{#if showModal}
on:close={close}>
{/if}
.hero-container .hero-subtitle:not(:last-of-type) {
margin: 0 0 0 0;
}
.content.image {
display: flex;
align-items: center;
justify-content: center;
}
@media (max-width: 680px) {
.content.image {
margin: 0 0 0;
}
}
.content.image .btn-wrapper {
margin-top: 2.5rem;
margin-left: 1rem;
}
.content.image .btn-wrapper button {
padding: 15px 18px;
}
The page ordinarily displays the user’s data based on the fields filled. It looks like this:
Since the user in the screenshot is brand new, only the user’s first and last names appeared. A default profile picture was also supplied. These data will change depending on the fields you have updated.
On this same page, a modal transitions in as soon as you click the EDIT PROFILE
button. The modal is a different component:
import { quintOut } from 'svelte/easing';
import { createEventDispatcher } from 'svelte';
const modal = (/** @type {Element} */ node, { duration = 300 } = {}) => {
const transform = getComputedStyle(node).transform;
return {
duration,
easing: quintOut,
css: (/** @type {any} */ t, /** @type {number} */ u) => {
return `transform:
${transform}
scale(${t})
translateY(${u * -100}%)
`;
}
};
};
const dispatch = createEventDispatcher();
function closeModal() {
dispatch('close', {});
}
class="modal-background">
transition:modal={{ duration: 1000 }} class="modal" role="dialog" aria-modal="true">
title="Close" class="modal-close" on:click={closeModal}>
class="container">
/>
.modal-background {
width: 100%;
height: 100%;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
z-index: 9999;
}
.modal {
position: absolute;
left: 50%;
top: 50%;
width: 70%;
box-shadow: 0 0 10px hsl(0 0% 0% / 10%);
transform: translate(-50%, -50%);
}
@media (max-width: 990px) {
.modal {
width: 90%;
}
}
.modal-close {
border: none;
}
.modal-close svg {
display: block;
margin-left: auto;
margin-right: auto;
fill: rgb(14 165 233 /1);
transition: all 0.5s;
}
.modal-close:hover svg {
fill: rgb(225 29 72);
transform: scale(1.5);
}
.modal .container {
max-height: 90vh;
overflow-y: auto;
}
@media (min-width: 680px) {
.modal .container {
flex-direction: column;
left: 0;
width: 100%;
}
}
On the user profile page, clicking the EDIT PROFILE
button shows something like the image below (the screenshot isn’t exact):
The modal has two forms in it: Image upload and User data update. The image upload form can also be used to delete an image. If a user already has an image, the “UPLOAD IMAGE” button will turn to the “REMOVE IMAGE” button and there will be an image instead of the “Select user image” input. The custom input for user image upload is a component on its own as well:
// @ts-nocheck
export let avatar;
export let fieldName;
export let title;
let newAvatar;
const onFileSelected = (e) => {
const target = e.target;
if (target && target.files) {
let reader = new FileReader();
reader.readAsDataURL(target.files[0]);
reader.onload = (e) => {
newAvatar = e.target?.result;
};
}
};
id="app">
{#if avatar}
class="avatar" src={avatar} alt="d" />
{:else}
class="avatar"
src={newAvatar
? newAvatar
: 'https://cdn4.iconfinder.com/data/icons/small-n-flat/24/user-alt-512.png'}
alt=""
/>
type="file" id="file" name={fieldName} required on:change={(e) => onFileSelected(e)} />
{/if}
#app {
margin-top: 1rem;
display: flex;
align-items: center;
justify-content: center;
flex-flow: column;
color: rgb(148 163 184);
}
.avatar {
display: flex;
height: 6.5rem;
width: 8rem;
}
[type='file'] {
height: 0;
overflow: hidden;
width: 0;
}
[type='file'] + label {
background: #9b9b9b;
border: none;
border-radius: 5px;
color: #fff;
cursor: pointer;
display: inline-block;
font-weight: 500;
margin-bottom: 1rem;
outline: none;
padding: 1rem 50px;
position: relative;
transition: all 0.3s;
vertical-align: middle;
}
[type='file'] + label:hover {
background-color: #9b9b9b;
}
[type='file'] + label.btn-3 {
background-color: #d43aff;
border-radius: 0;
overflow: hidden;
}
[type='file'] + label.btn-3 span {
display: inline-block;
height: 100%;
transition: all 0.3s;
width: 100%;
}
[type='file'] + label.btn-3::before {
color: #fff;
content: ' 1F4F7';
font-size: 200%;
height: 100%;
left: 45%;
position: absolute;
top: -180%;
transition: all 0.3s;
width: 100%;
}
[type='file'] + label.btn-3:hover {
background-color: rgba(14, 166, 236, 0.5);
}
[type='file'] + label.btn-3:hover span {
transform: translateY(300%);
}
[type='file'] + label.btn-3:hover::before {
top: 0;
}
We built a custom file upload component with pure CSS. When a user clicks the “Select user image” button — inwardly, it’s just an input label — and picks an image, the default image icon will be replaced by the newly selected image and a message, Image selected! Click upload.
, will appear. Clicking UPLOAD IMAGE
will send the file to our backend’s file upload endpoint which, in turn, sends it to AWS S3 for storage. A successful image upload or deletion will prompt the user to ensure the entire profile is updated for the image to be saved in the database.
The form actions responsible for all of these are in frontend/src/routes/auth/about/[id]/+page.server.js
:
// frontend/src/routes/auth/about/[id]/+page.server.js
import { BASE_API_URI } from '$lib/utils/constants';
import { formatError } from '$lib/utils/helpers';
import { fail, redirect } from '@sveltejs/kit';
/** @type {import('./$types').PageServerLoad} */
export async function load({ locals, params }) {
// redirect user if not logged in
if (!locals.user) {
throw redirect(302, `/auth/login?next=/auth/about/${params.id}`);
}
}
/** @type {import('./$types').Actions} */
export const actions = {
/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @param cookies - SvelteKit's cookie object
* @param locals - The local object, housing current user
* @returns Error data or redirects user to the home page or the previous page
*/
updateUser: async ({ request, fetch, cookies, locals }) => {
const formData = await request.formData();
const firstName = String(formData.get('first_name'));
const lastName = String(formData.get('last_name'));
const thumbnail = String(formData.get('thumbnail'));
const phoneNumber = String(formData.get('phone_number'));
const birthDate = String(formData.get('birth_date'));
const githubLink = String(formData.get('github_link'));
const apiURL = `${BASE_API_URI}/users/update-user/`;
const res = await fetch(apiURL, {
method: 'PATCH',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
Cookie: `sessionid=${cookies.get('go-auth-sessionid')}`
},
body: JSON.stringify({
first_name: firstName,
last_name: lastName,
thumbnail: thumbnail,
phone_number: phoneNumber,
birth_date: birthDate,
github_link: githubLink
})
});
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.error);
return fail(400, { errors: errors });
}
const response = await res.json();
locals.user = response;
if (locals.user.profile.birth_date) {
locals.user.profile.birth_date = response['profile']['birth_date'].split('T')[0];
}
throw redirect(303, `/auth/about/${response.id}`);
},
/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @param cookies - SvelteKit's cookie object
* @param locals - The local object, housing current user
* @returns Error data or redirects user to the home page or the previous page
*/
uploadImage: async ({ request, fetch, cookies }) => {
const formData = await request.formData();
/** @type {RequestInit} */
const requestInitOptions = {
method: 'POST',
headers: {
Cookie: `sessionid=${cookies.get('go-auth-sessionid')}`
},
body: formData
};
const res = await fetch(`${BASE_API_URI}/file/upload/`, requestInitOptions);
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.error);
return fail(400, { errors: errors });
}
const response = await res.json();
return {
success: true,
thumbnail: response['s3_url']
};
},
/**
*
* @param request - The request object
* @param fetch - Fetch object from sveltekit
* @param cookies - SvelteKit's cookie object
* @param locals - The local object, housing current user
* @returns Error data or redirects user to the home page or the previous page
*/
deleteImage: async ({ request, fetch, cookies }) => {
const formData = await request.formData();
/** @type {RequestInit} */
const requestInitOptions = {
method: 'DELETE',
headers: {
Cookie: `sessionid=${cookies.get('go-auth-sessionid')}`
},
body: formData
};
const res = await fetch(`${BASE_API_URI}/file/delete/`, requestInitOptions);
if (!res.ok) {
const response = await res.json();
const errors = formatError(response.error);
return fail(400, { errors: errors });
}
return {
success: true,
thumbnail: ''
};
}
};
Three named form actions are there. They do exactly what their names imply using different API endpoints to achieve their aims.
Because uploading to and deleting a file from AWS S3 takes some seconds, I included a small loader to inform the user that something is still ongoing. The loader is a basic component:
/** @type {number | null} */
export let width;
/** @type {string | null} */
export let message;
class="loading">
class="simple-loader" style={width ? `width: ${width}px` : ''} />
{#if message}
{message}
{/if}
.loading {
display: flex;
align-items: center;
justify-content: center;
}
.loading p {
margin-left: 0.5rem;
}
The CSS for the real loader is in styles.css
.
With that, you can test out the feature. Ensure you update Header.svelte
.
Step 3: The admin interface
Though this article is becoming quite long, I feel I should include this here nevertheless. In the last article, we made an endpoint that exposes our application’s metrics. The endpoint returns a JSON which isn’t fancy enough for everyone to look at. This prompted me to build out a dashboard where the data therein are elegantly visualized. Therefore, I created an admin
route, which can only be accessed by users with is_superuser
set to true
. The route has the following files’ contents:
import '$lib/css/dash.min.css';
import { page } from '$app/stores';
import List from '$lib/components/Admin/List.svelte';
/** @type {import('./$types').PageData} */
export let data;
$: ({ metrics } = data);
const calculateAvgProTime = (/** @type {any} */ metric) => {
const div = metric.total_processing_time_μs / metric.total_requests_received;
const inSecs = div * 0.000001;
return `${inSecs.toFixed(2)}s/req`;
};
const turnMemstatsObjToArray = (/** @type {any} */ metric) => {
const exclude = new Set(['PauseNs', 'PauseEnd', 'BySize']);
const data = Object.fromEntries(Object.entries(metric).filter((e) => !exclude.has(e[0])));
return Object.keys(data).map((key) => {
return {
id: crypto.randomUUID(),
name: key,
value: data[key]
};
});
};
const returnDate = (/** @type {number} */ timestamp) => {
const date = new Date(timestamp);
return date.toUTCString();
};
class="app">
class="app-body">
class="app-body-main-content">
class="service-header">
Metrics
App's version: {metrics.version}; Timestamp: {returnDate(metrics.timestamp)}
class="tiles">
class="tile">
class="tile-header">
Avg Pro. Time
total pro. time(μs) / total reqs
{calculateAvgProTime(metrics)}
{`(${metrics.total_processing_time_μs} / ${metrics.total_requests_received}) x 0.000001`}
class="tile">
class="tile-header">
Active in-flight reqs
total reqs - total res
{metrics.total_requests_received - metrics.total_responses_sent}
{`${metrics.total_requests_received} - ${metrics.total_responses_sent}`}
class="tile">
class="tile-header">
Goroutines used
No. of active goroutines
{metrics.goroutines}
No. of active goroutines
class="stats">
class="stats-heading-container">
class="stats-heading ss-heading">Database
App's database statistics
class="stats-list">
{#each turnMemstatsObjToArray(metrics.database) as stat, idx}
{stat} {idx} />
{/each}
class="stats">
class="stats-heading-container">
class="stats-heading ss-heading">Memstats
App's memory usage statistics
class="stats-list">
{#each turnMemstatsObjToArray(metrics.memstats) as stat, idx}
{stat} {idx} />
{/each}
class="stats">
class="stats-heading-container">
class="stats-heading ss-heading">Responses by status
App's responses by HTTP status
class="stats-list">
{#each turnMemstatsObjToArray(metrics.total_responses_sent_by_status) as stat, idx}
{stat} {idx} />
{/each}
The page looks like this:
It has a sub-component:
import { receive, send } from '$lib/utils/helpers';
/** @type {any} */
export let stat;
/** @type {number} */
export let idx;
class="stats-item" in:receive={{ key: stat.id }} out:send={{ key: stat.id }}>
class="stats-item-heading">{stat.name}
class="stats-item-sub">{stat.value}
class="stats_more">
class="stats_more-svg"
style="background: linear-gradient(20deg, hsla({20 * idx}, 60%, 50%, .2),
hsla({20 * idx + 20}, 60%, 50%, .3));"
>
The data for the page was fetched by the page’s +page.server.js
file:
// frontend/src/routes/auth/admin/+page.server.js
import { BASE_API_URI } from '$lib/utils/constants';
import { redirect } from '@sveltejs/kit';
/** @type {import('./$types').PageServerLoad} */
export async function load({ locals, cookies }) {
// redirect user if not logged in or not a superuser
if (!locals.user || !locals.user.is_superuser) {
throw redirect(302, `/auth/login?next=/auth/admin`);
}
const fetchMetrics = async () => {
const res = await fetch(`${BASE_API_URI}/metrics/`, {
credentials: 'include',
headers: {
Cookie: `sessionid=${cookies.get('go-auth-sessionid')}`
}
});
return res.ok && (await res.json());
};
return {
metrics: fetchMetrics()
};
}
It first ensures that only users with superuser status can access the page. Then, it fetches the metrics to visualize. Notice the use of an async function to do the fetching. It may not be evident now — since we are only fetching data from one endpoint — but that prevents waterfall issues thereby improving performance.
I apologize for the rather long article.
The coming articles will be based on automated testing, dockerization of the backend and deployments on fly.io (backend) and vercel (frontend). See you.
Outro
Enjoyed this article? Consider contacting me for a job, something worthwhile or buying a coffee ☕. You can also connect with/follow me on LinkedIn and Twitter. It isn’t bad if you help share this article for wider coverage. I will appreciate it…