Authentication system using Golang and Sveltekit – Updating the frontend

Valentsea
Total
0
Shares



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:




 class="container">
     class="content" method="POST" use:enhance={handleGenerate}>
        

class="step-title">Regenerate token

{#if form?.errors} {#each form?.errors as error (error.id)}

class="step-subtitle warning" in:receive={{ key: error.id }} out:send={{ key: error.id }} > {error.error}

{/each} {/if} class="input-box"> class="label">Email: class="input" type="email" name="email" id="email" placeholder="Registered e-mail address" required />
{#if form?.fieldsError && form?.fieldsError.email} class="warning" transition:scale|local={{ start: 0.7 }}> {form?.fieldsError.email}

{/if} class="button-dark">Regenerate
Enter fullscreen mode

Exit fullscreen mode

The route will be /auth/regenerate-token. It only has one input and the page looks like this:

Application's regenerate token page

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}`);
    }
};
Enter fullscreen mode

Exit fullscreen mode

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:

Application's password reset request page

Application's change password page

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:




 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} {/if} {#if user.profile.birth_date}

class="hero-subtitle"> Date of birth: {user.profile.birth_date}

{/if} class="hero-buttons-container"> class="button-dark" on:click={open}>Edit profile
{#if showModal} on:close={close}> class="content image" action="?/uploadImage" method="post" enctype="multipart/form-data" use:enhance={handleUpload} > avatar={user.thumbnail} fieldName="thumbnail" title="Select user image" /> {#if !user.thumbnail} class="btn-wrapper"> {#if isUploading} width={30} message={'Uploading...'} /> {:else} class="button-dark" type="submit">Upload image {/if}
{:else} type="hidden" hidden name="thumbnail_url" value={user.thumbnail} required /> class="btn-wrapper"> {#if isUploading} width={30} message={'Removing...'} /> {:else} class="button-dark" formaction="?/deleteImage" type="submit"> Remove image {/if}
{/if} class="content" action="?/updateUser" method="POST" use:enhance={handleUpdate}>

class="step-title" style="text-align: center;">Update User

{#if form?.success}

class="step-subtitle warning" in:receive={{ key: Math.floor(Math.random() * 100) }} out:send={{ key: Math.floor(Math.random() * 100) }} > To avoid corrupt data and inconsistencies in your thumbnail, ensure you click on the "Update" button below.

{/if} {#if form?.errors} {#each form?.errors as error (error.id)}

class="step-subtitle warning" in:receive={{ key: error.id }} out:send={{ key: error.id }} > {error.error}

{/each} {/if} type="hidden" hidden name="thumbnail" value={user.thumbnail} /> class="input-box"> class="label">First name: class="input" type="text" name="first_name" value={user.first_name} placeholder="Your first name..." />
class="input-box"> class="label">Last name: class="input" type="text" name="last_name" value={user.last_name} placeholder="Your last name..." />
class="input-box"> class="label">Phone number: class="input" type="tel" name="phone_number" value={user.profile.phone_number ? user.profile.phone_number : ''} placeholder="Your phone number e.g +2348135703593..." />
class="input-box"> class="label">Birth date: class="input" type="date" name="birth_date" value={user.profile.birth_date ? user.profile.birth_date : ''} placeholder="Your date of birth..." />
class="input-box"> class="label">GitHub Link: class="input" type="url" name="github_link" value={user.profile.github_link ? user.profile.github_link : ''} placeholder="Your github link e.g https://github.com/Sirneij/..." />
{#if isUpdating} width={30} message={'Updating...'} /> {:else} type="submit" class="button-dark">Update {/if} {/if}
Enter fullscreen mode

Exit fullscreen mode

The page ordinarily displays the user’s data based on the fields filled. It looks like this:

Application's user's profile page

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:





 class="modal-background">
     transition:modal={{ duration: 1000 }} class="modal" role="dialog" aria-modal="true">
        
         title="Close" class="modal-close" on:click={closeModal}>
             xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 384 512">
                
                    d="M342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6z"
                />
            
        
         class="container">
             />
        
Enter fullscreen mode

Exit fullscreen mode

On the user profile page, clicking the EDIT PROFILE button shows something like the image below (the screenshot isn’t exact):

Application's user's profile modal page

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:




 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)} />
         for="file" class="btn-3">
            {#if newAvatar}
                Image selected! Click upload.
            {:else}
                {title}
            {/if}
        
    {/if}
Enter fullscreen mode

Exit fullscreen mode

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: ''
        };
    }
};
Enter fullscreen mode

Exit fullscreen mode

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:




 class="loading">
     class="simple-loader" style={width ? `width: ${width}px` : ''} />
    {#if message}
        

{message}

{/if}
Enter fullscreen mode

Exit fullscreen mode

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:





 class="app">
     class="app-body">
         class="navigation">
             href="/auth/admin" class:active={$page.url.pathname === '/auth/admin'}>
                 xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512">
                    
                        d="M0 256a256 256 0 1 1 512 0A256 256 0 1 1 0 256zm320 96c0-26.9-16.5-49.9-40-59.3V88c0-13.3-10.7-24-24-24s-24 10.7-24 24V292.7c-23.5 9.5-40 32.5-40 59.3c0 35.3 28.7 64 64 64s64-28.7 64-64zM144 176a32 32 0 1 0 0-64 32 32 0 1 0 0 64zm-16 80a32 32 0 1 0 -64 0 32 32 0 1 0 64 0zm288 32a32 32 0 1 0 0-64 32 32 0 1 0 0 64zM400 144a32 32 0 1 0 -64 0 32 32 0 1 0 64 0z"
                    />
                
                Metrics
            
             href="/auth/admin#" class:active={$page.url.pathname === '/auth/admin#'}>
                 xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 640 512">
                    
                        d="M144 0a80 80 0 1 1 0 160A80 80 0 1 1 144 0zM512 0a80 80 0 1 1 0 160A80 80 0 1 1 512 0zM0 298.7C0 239.8 47.8 192 106.7 192h42.7c15.9 0 31 3.5 44.6 9.7c-1.3 7.2-1.9 14.7-1.9 22.3c0 38.2 16.8 72.5 43.3 96c-.2 0-.4 0-.7 0H21.3C9.6 320 0 310.4 0 298.7zM405.3 320c-.2 0-.4 0-.7 0c26.6-23.5 43.3-57.8 43.3-96c0-7.6-.7-15-1.9-22.3c13.6-6.3 28.7-9.7 44.6-9.7h42.7C592.2 192 640 239.8 640 298.7c0 11.8-9.6 21.3-21.3 21.3H405.3zM224 224a96 96 0 1 1 192 0 96 96 0 1 1 -192 0zM128 485.3C128 411.7 187.7 352 261.3 352H378.7C452.3 352 512 411.7 512 485.3c0 14.7-11.9 26.7-26.7 26.7H154.7c-14.7 0-26.7-11.9-26.7-26.7z"
                    />
                
                Users
            
        

         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"> xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 448 512"> d="M349.4 44.6c5.9-13.7 1.5-29.7-10.6-38.5s-28.6-8-39.9 1.8l-256 224c-10 8.8-13.6 22.9-8.9 35.3S50.7 288 64 288H175.5L98.6 467.4c-5.9 13.7-1.5 29.7 10.6 38.5s28.6 8 39.9-1.8l256-224c10-8.8 13.6-22.9 8.9-35.3s-16.6-20.7-30-20.7H272.5L349.4 44.6z" />

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"> xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 640 512"> d="M256 0c-35 0-64 59.5-64 93.7v84.6L8.1 283.4c-5 2.8-8.1 8.2-8.1 13.9v65.5c0 10.6 10.2 18.3 20.4 15.4l171.6-49 0 70.9-57.6 43.2c-4 3-6.4 7.8-6.4 12.8v42c0 7.8 6.3 14 14 14c1.3 0 2.6-.2 3.9-.5L256 480l110.1 31.5c1.3 .4 2.6 .5 3.9 .5c6 0 11.1-3.7 13.1-9C344.5 470.7 320 422.2 320 368c0-60.6 30.6-114 77.1-145.6L320 178.3V93.7C320 59.5 292 0 256 0zM640 368a144 144 0 1 0 -288 0 144 144 0 1 0 288 0zm-76.7-43.3c6.2 6.2 6.2 16.4 0 22.6l-72 72c-6.2 6.2-16.4 6.2-22.6 0l-40-40c-6.2-6.2-6.2-16.4 0-22.6s16.4-6.2 22.6 0L480 385.4l60.7-60.7c6.2-6.2 16.4-6.2 22.6 0z" />

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"> xmlns="http://www.w3.org/2000/svg" height="1em" viewBox="0 0 512 512"> d="M448 160H320V128H448v32zM48 64C21.5 64 0 85.5 0 112v64c0 26.5 21.5 48 48 48H464c26.5 0 48-21.5 48-48V112c0-26.5-21.5-48-48-48H48zM448 352v32H192V352H448zM48 288c-26.5 0-48 21.5-48 48v64c0 26.5 21.5 48 48 48H464c26.5 0 48-21.5 48-48V336c0-26.5-21.5-48-48-48H48z" />

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}
Enter fullscreen mode

Exit fullscreen mode

The page looks like this:

Application's admin page

It has a sub-component:




 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));" > xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"> id="myGradient2" gradientTransform="rotate(20)"> offset="0%" stop-color="hsl({20 * idx}, 60%, 50%)" /> offset="50%" stop-color="hsl({20 * idx + 20}, 60%, 50%)" /> d="M11 17a1 1 0 001.447.894l4-2A1 1 0 0017 15V9.236a1 1 0 00-1.447-.894l-4 2a1 1 0 00-.553.894V17zM15.211 6.276a1 1 0 000-1.788l-4.764-2.382a1 1 0 00-.894 0L4.789 4.488a1 1 0 000 1.788l4.764 2.382a1 1 0 00.894 0l4.764-2.382zM4.447 8.342A1 1 0 003 9.236V15a1 1 0 00.553.894l4 2A1 1 0 009 17v-5.764a1 1 0 00-.553-.894l-4-2z" fill="url(#myGradient2)" />
Enter fullscreen mode

Exit fullscreen mode

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()
    };
}
Enter fullscreen mode

Exit fullscreen mode

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…

Total
0
Shares
Valentsea

Take your Flutter App to the next level with Appwrite’s offline support

According to Statista, one of the leading online platforms specialized in market and consumer data, mobile phone usage…

You May Also Like