DEV Community

Cover image for Authentication system using rust (actix-web) and sveltekit - User Profile Update UI
John Owolabi Idogun
John Owolabi Idogun

Posted on

Authentication system using rust (actix-web) and sveltekit - User Profile Update UI

Introduction

In the last article, we built out the backend API for updating users' profiles and storing users' thumbnails on AWS S3. This article will build a UI that interacts with the API.

Source code

The source code for this series is hosted on GitHub via:

GitHub logo Sirneij / rust-auth

A fullstack authentication system using rust, sveltekit, and Typescript

rust-auth

A full-stack secure and performant authentication system using rust's Actix web and JavaScript's SvelteKit.

This application resulted from this series of articles and it's currently live here(I have disabled the backend from running live).

Run locally

You can run the application locally by first cloning it:

~/$ git clone https://github.com/Sirneij/rust-auth.git
Enter fullscreen mode Exit fullscreen mode

After that, change directory into each subdirectory: backend and frontend in different terminals. Then following the instructions in each subdirectory to run them.




Implementation

You can get the overview of the code for this article on github. Some changes were also made directly to the repo's main branch. We will cover them.

Step 1: Build users' about page

As a placeholder, we currently have in our frontend/src/routes folder, an about folder. We need the route to dynamically detect the requesting user via the users' IDs. To achieve this, in SvelteKit, we will create a subfolder in the about folder. The name of which is [id]. In SvelteKit, you use a pair of square brackets with the dynamic variable inside for dynamic routing. You can have multiple such "dynamic" subfolders like so: about/[id]/[email]... or about/[id]-[email]... or anything you can imagine! For us, we just need about/[id]. We also want to make sure that ONLY authenticated users can access the page. If an unauthenticated user tries to access it, we redirect such a user back to the login page and after a successful login, redirect them back to the About page. To achieve this, we have this in +page.ts inside the about/[id] routes:

// frontend/src/routes/auth/about/[id]/+page.ts
import { notification } from '$lib/stores/notification.store';
import { isAuthenticated } from '$lib/stores/user.store';
import { angryEmoji } from '$lib/utils/constant';
import { get } from 'svelte/store';
import type { PageLoad } from './$types';
import { redirect } from '@sveltejs/kit';

export const load: PageLoad = async ({ params }) => {
    if (!get(isAuthenticated)) {
        notification.set({
            message: `You are not logged in ${angryEmoji}...`,
            colorName: `red`
        });
        throw redirect(302, `/auth/login?next=/auth/about/${params.id}`);
    }
};
Enter fullscreen mode Exit fullscreen mode

The script checks whether or not isAuthenticated store is true. If it isn't, we notify the user and throw a redirect to the login page. Notice that the redirect string has this part: ...?next=/auth/about/${params.id}. We simply fed the login page with a next query parameter and redirect the user to whatever it equals to in the login logic:

// frontend/src/routes/auth/login/+page.svelte
...
const handleLogin = async () => {
    ...
    let nextPage = $page.url.search.split('=')[1];
    if ($page.url.hash) {
        nextPage = `${nextPage}${$page.url.hash}`;
    }
    await goto(nextPage || '/', { noScroll: true });
...
Enter fullscreen mode Exit fullscreen mode

Now to frontend/src/routes/auth/about/[id]/+page.svelte:

<!-- frontend/src/routes/auth/about/[id]/+page.svelte -->

<script lang="ts">
    import { page } from '$app/stores';
    import ImageInput from '$lib/component/Input/ImageInput.svelte';
    import Modal from '$lib/component/Modal/Modal.svelte';
    import { loading } from '$lib/stores/loading.store';
    import { notification } from '$lib/stores/notification.store';
    import { loggedInUser } from '$lib/stores/user.store';
    import Avatar from '$lib/svgs/teamavatar.png';
    import { BASE_API_URI, happyEmoji } from '$lib/utils/constant';
    import { receive, send } from '$lib/utils/helpers/animate.crossfade';
    import { post } from '$lib/utils/requests/posts.requests';
    import type { CustomError, User } from '$lib/utils/types';
    let showModal = false,
        errors: Array<CustomError> = [],
        image: string | Blob,
        avatar: string | null = $loggedInUser.thumbnail,
        first_name = $loggedInUser.first_name,
        last_name = $loggedInUser.last_name,
        phone_number = $loggedInUser.profile.phone_number,
        birth_date = $loggedInUser.profile.birth_date,
        github_link = $loggedInUser.profile.github_link;
    const open = () => (showModal = true);
    const close = () => (showModal = false);
    async function handleUpdate(event: Event) {
        loading.setLoading(true, 'Please wait while your profile is being updated...');
        let data = new FormData();
        if (first_name !== $loggedInUser.first_name) {
            data.append('first_name', first_name);
        }
        if (last_name !== $loggedInUser.last_name) {
            data.append('last_name', last_name);
        }
        if (image !== null && image !== undefined) {
            data.append('thumbnail', image);
        }
        if (phone_number && phone_number !== $loggedInUser.profile.phone_number) {
            data.append('phone_number', phone_number);
        }
        if (birth_date && birth_date !== $loggedInUser.profile.birth_date) {
            data.append('birth_date', birth_date);
        }
        if (github_link && github_link !== $loggedInUser.profile.github_link) {
            data.append('github_link', github_link);
        }
        const [res, err] = await post(
            $page.data.fetch,
            `${BASE_API_URI}/users/update-user/`,
            data,
            'include',
            'PATCH'
        );
        if (err.length > 0) {
            loading.setLoading(false);
            errors = err;
        } else {
            loading.setLoading(false);
            (event.target as HTMLFormElement).reset();
            close();
            $notification = {
                message: `Your profile has been saved successfully ${happyEmoji}...`,
                colorName: 'green'
            };
            loggedInUser.set(res as User);
        }
    }
</script>

<svelte:head>
    <script src="https://kit.fontawesome.com/e9a50f7f89.js" crossorigin="anonymous"></script>

    <title>
        Auth - About {`${$loggedInUser.first_name} ${$loggedInUser.last_name}`} | Actix Web & SvelteKit
    </title>
</svelte:head>

<h2 style="text-align:center">
    {`${$loggedInUser.first_name} ${$loggedInUser.last_name}`} Profile
</h2>
<div class="card">
    <img
        src={$loggedInUser.thumbnail ? $loggedInUser.thumbnail : Avatar}
        alt={`${$loggedInUser.first_name} ${$loggedInUser.last_name}`}
        style="width:90%; margin:auto;"
    />
    <h1>{`${$loggedInUser.first_name} ${$loggedInUser.last_name}`}</h1>

    <div class="details">
        {#if $loggedInUser.profile.phone_number}
            <p><i class="fa-solid fa-phone" /> <span>{$loggedInUser.profile.phone_number}</span></p>
        {/if}

        {#if $loggedInUser.profile.birth_date}
            <p><i class="fa-solid fa-calendar" /> <span>{$loggedInUser.profile.birth_date}</span></p>
        {/if}

        {#if $loggedInUser.profile.github_link}
            <p><i class="fa-brands fa-github" /><a href={$loggedInUser.profile.github_link}>Github</a></p>
        {/if}
    </div>

    <button on:click={open}>Edit</button>
</div>

{#if showModal}
    <Modal on:close={close}>
        <form enctype="multipart/form-data" on:submit|preventDefault={handleUpdate}>
            <h2 style="text-align:center">User Profile Update</h2>

            {#if errors}
                {#each errors as error (error.id)}
                    <p
                        class="text-center text-rose-600"
                        in:receive={{ key: error.id }}
                        out:send={{ key: error.id }}
                    >
                        {error.error}
                    </p>
                {/each}
            {/if}

            <ImageInput bind:image title="Upload user image" bind:avatar bind:errors />

            <input
                type="text"
                name="first_name"
                bind:value={first_name}
                placeholder="Your first name..."
            />
            <input type="text" name="last_name" bind:value={last_name} placeholder="Your last name..." />
            <input
                type="tel"
                name="phone_number"
                bind:value={phone_number}
                placeholder="Your phone number e.g +2348135703593..."
            />
            <input
                type="date"
                name="birth_date"
                bind:value={birth_date}
                placeholder="Your date of birth..."
            />
            <input
                type="tel"
                name="github_link"
                bind:value={github_link}
                placeholder="Your github link e.g https://github.com/Sirneij/..."
            />
            <button type="submit">Update</button>
        </form>
    </Modal>
{/if}

<style>
    :root {
        --tw-bg-opacity: 1;
        --tw-text-opacity: 1;
    }
    h1,
    h2 {
        color: rgb(14 165 233 / var(--tw-text-opacity));
    }
    h2 {
        font-size: 1.5rem;
    }
    h1 {
        font-size: 2rem;
    }
    .card {
        box-shadow: 0px 4px 6px 0px rgba(0, 0, 0, 0.75);
        -webkit-box-shadow: 0px 4px 6px 0px rgba(0, 0, 0, 0.75);
        -moz-box-shadow: 0px 4px 6px 0px rgba(0, 0, 0, 0.75);
        max-width: 20rem;
        margin: auto;
        text-align: center;
    }
    button {
        border: none;
        outline: 0;
        display: inline-block;
        padding: 0.5rem;
        color: rgb(239 246 255 / var(--tw-bg-opacity));
        background-color: rgb(7 89 133 / var(--tw-bg-opacity));
        text-align: center;
        cursor: pointer;
        width: 100%;
        font-size: 18px;
    }
    .details {
        display: flex;
        flex-wrap: wrap;
        align-items: center;
        justify-content: center;
        margin-top: 0.5rem;
        margin-bottom: 0.5rem;
        color: rgb(239 246 255 / var(--tw-bg-opacity));
    }
    .details p i {
        opacity: 0.6;
        margin-right: 0.3rem;
    }
    .details p:not(:last-of-type) {
        margin-right: 1rem;
    }
    .details p:not(:last-of-type) {
        border-right: 2px solid rgb(14 165 233 / var(--tw-text-opacity));
    }
    .details p span,
    .details p a {
        margin-right: 0.3rem;
    }
    button:hover,
    a:hover {
        opacity: 0.7;
    }
    a:hover {
        color: rgb(14 165 233 / var(--tw-text-opacity));
        text-decoration: underline;
    }
    form {
        border-radius: 5px;
        background-color: rgb(30 41 59);
        padding: 1.25rem;
    }
    input {
        width: 100%;
        padding: 0.75rem 1.25rem;
        margin: 0.25rem 0;
        display: inline-block;
        border: none;
        outline: none;
        background-color: #0f172a;
        color: rgb(14 165 233);
        border-radius: 4px;
        box-sizing: border-box;
    }
    ::-webkit-input-placeholder {
        /* Edge */
        color: rgb(148 163 184);
    }
    :-ms-input-placeholder {
        /* Internet Explorer 10-11 */
        color: rgb(148 163 184);
    }
    ::placeholder {
        color: rgb(148 163 184);
    }
</style>
Enter fullscreen mode Exit fullscreen mode

Though some 262 lines of code, it is easy to pick up. I promise. Let's look into it.

NOTE: I will not be talking much about the CSS and the things we had already covered.

Starting with:

...
const open = () => (showModal = true);
const close = () => (showModal = false);
...
Enter fullscreen mode Exit fullscreen mode

These two arrow functions help show and hide the animated modal which houses the update form. The modal is in frontend/src/lib/component/Modal/Modal.svelte:

<!-- frontend/src/lib/component/Modal/Modal.svelte -->
<script lang="ts">
    import { createEventDispatcher } from 'svelte';
    import { quintOut } from 'svelte/easing';
    import type { TransitionConfig } from 'svelte/transition';
    type ModalParams = { duration?: number };
    type Modal = (node: Element, params?: ModalParams) => TransitionConfig;
    const modal: Modal = (node, { duration = 300 } = {}) => {
        const transform = getComputedStyle(node).transform;
        return {
            duration,
            easing: quintOut,
            css: (t, u) => {
                return `transform:
            ${transform}
            scale(${t})
            translateY(${u * -100}%)
            `;
            }
        };
    };
    const dispatch = createEventDispatcher();
    function closeModal() {
        dispatch('close', {});
    }
</script>

<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="modal-background" on:click={closeModal} />

<div transition:modal={{ duration: 1000 }} class="modal" role="dialog" aria-modal="true">
    <button title="Close" class="modal-close" on:click={closeModal}>
        <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 384 512">
            <path
                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"
            />
        </svg>
    </button>
    <slot />
</div>

<style>
    .modal-background {
        width: 100%;
        height: 100%;
        position: fixed;
        top: 0;
        left: 0;
        background: rgba(0, 0, 0, 0.25);
    }
    .modal {
        position: absolute;
        left: 50%;
        top: 50%;
        max-width: 32em;
        max-height: calc(100vh - 4em);
        overflow: auto;
        background: rgb(15, 23, 42);
        box-shadow: 0 0 10px hsl(0 0% 0% / 10%);
        transform: translate(-50%, -50%);
        border-radius: 0.5rem;
    }
    .modal-close {
        position: absolute;
        top: 0.5rem;
        right: 0.5rem;
    }
    .modal-close svg {
        fill: rgb(14 165 233 /1);
    }
    .modal-close:hover svg {
        fill: rgb(225 29 72);
    }
</style>
Enter fullscreen mode Exit fullscreen mode

The modal has this custom animation that makes it nicely appear and disappear into the page. All in pure CSS! Svelte always recommends using CSS instead of JavaScript for animations. Also, in the modal, we have created an event dispatcher, dispatch. This allows a function or event created in a parent component to be dispatched to the child component. You can learn more about it here. In our case, we are listening to the close event attached to the Modal in frontend/src/routes/auth/about/[id]/+page.svelte on this line:

<!-- frontend/src/routes/auth/about/[id]/+page.svelte -->
...
<Modal on:close={close}>
...
Enter fullscreen mode Exit fullscreen mode

Next, in frontend/src/routes/auth/about/[id]/+page.svelte, we defined the function that sends user-inputted data to the backend in handleUpdate. We are using FormData() instead of JSON because our API expects it and working with images in HTML forms is best done with it. Talking about file upload in HTML forms, we used a component to do that:

<!-- frontend/src/routes/auth/about/[id]/+page.svelte -->
...
<ImageInput bind:image title="Upload user image" bind:avatar bind:errors />
...
Enter fullscreen mode Exit fullscreen mode

This component resides in frontend/src/lib/component/Input/ImageInput.svelte:

<!-- frontend/src/lib/component/Input/ImageInput.svelte -->

<script lang="ts">
    import { HIGHEST_IMAGE_UPLOAD_SIZE, IMAGE_UPLOAD_SIZE } from '$lib/utils/constant';
    import { returnFileSize } from '$lib/utils/helpers/image.file.size';
    import type { CustomError } from '$lib/utils/types';
    export let title: string;
    export let image: string | Blob;
    export let avatar: string | null;
    export let errors: Array<CustomError>;
    let thumbnail: HTMLInputElement;
    const onFileSelected = (e: Event) => {
        const target = e.target as HTMLInputElement;
        if (target && target.files) {
            if (target.files[0].size < HIGHEST_IMAGE_UPLOAD_SIZE) {
                errors = [];
                image = target.files[0];
                let reader = new FileReader();
                reader.readAsDataURL(image);
                reader.onload = (e) => {
                    avatar = e.target?.result as string;
                };
            } else {
                errors = [
                    ...errors,
                    {
                        id: Math.floor(Math.random() * 100),
                        error: `Image size ${returnFileSize(
                            target.files[0].size
                        )} is too large. Please keep it below ${IMAGE_UPLOAD_SIZE}kB.`
                    }
                ];
            }
        }
    };
</script>

<div id="app">
    <h1>{title}</h1>

    {#if avatar}
        <img class="avatar" src={avatar} alt="d" />
    {:else}
        <img
            class="avatar"
            src="https://cdn4.iconfinder.com/data/icons/small-n-flat/24/user-alt-512.png"
            alt=""
        />
    {/if}
    <!-- svelte-ignore a11y-click-events-have-key-events -->
    <i
        class="upload fa-solid fa-3x fa-camera"
        title="Upload image. Max size is 49kB."
        on:click={() => {
            thumbnail.click();
        }}
    />

    <!-- svelte-ignore a11y-click-events-have-key-events -->
    <div
        class="chan"
        on:click={() => {
            thumbnail.click();
        }}
    >
        Choose Image
    </div>
    <input
        style="display:none"
        type="file"
        name="thumbnail"
        accept="image/*"
        on:change={(e) => onFileSelected(e)}
        bind:this={thumbnail}
    />
</div>

<style>
    #app {
        margin-top: 1rem;
        display: flex;
        align-items: center;
        justify-content: center;
        flex-flow: column;
        color: rgb(148 163 184);
    }
    .upload {
        display: flex;
        height: 50px;
        width: 50px;
        cursor: pointer;
    }
    .avatar {
        display: flex;
        height: 200px;
        width: 200px;
    }
</style>
Enter fullscreen mode Exit fullscreen mode

It's a relatively simple input component with some validations and styles. For validation, we use these constants, HIGHEST_IMAGE_UPLOAD_SIZE and IMAGE_UPLOAD_SIZE, defined in frontend/src/lib/utils/constant.ts:

// frontend/src/lib/utils/constant.ts
...
export const IMAGE_UPLOAD_SIZE = ~~import.meta.env.VITE_IMAGE_UPLOAD_SIZE || 70;
export const HIGHEST_IMAGE_UPLOAD_SIZE = IMAGE_UPLOAD_SIZE * 1024;
...
Enter fullscreen mode Exit fullscreen mode

We are using an environment variable to set a limit to the file size that can be uploaded. 70kB is the default. If you upload any image larger than that, it will fail to upload and an error message will be returned telling the size of the image you are trying to upload and the expected file size. This helper function in frontend/src/lib/utils/helpers/image.file.size.ts:

// frontend/src/lib/utils/helpers/image.file.size.ts
/**
 * Determine and nicely format the file size of an item.
 * @file lib/utils/helpers/image.file.size.ts
 * @param {number} num - The size of the file.
 * @returns {string} - The nicely formatted file size.
 */
export const returnFileSize = (num: number): string => {
    let returnString = '';
    if (num < 1024) {
        returnString = `${num} bytes`;
    } else if (num >= 1024 && num < 1048576) {
        returnString = `${(num / 1024).toFixed(1)} kB`;
    } else if (num >= 1048576) {
        returnString = `${(num / 1048576).toFixed(1)} MB`;
    }
    return returnString;
};
Enter fullscreen mode Exit fullscreen mode

tells us the uploaded file size. In case the upload was successful, we simply show the image and hold it in the image variable which would be sent to the server.

That's it for the About page.

NOTE: Ensure to update our frontend/src/lib/utils/requests/posts.requests.ts to reflect the additional profile type we added to the User type. Please, refer to this project's repo.

Step 2: Re-deploy the updated frontend application

Having made those changes, it behoves us to deploy them. If you use Vercel and connect it to your project's GitHub, that will be done for you automatically as soon as you push your code to GitHub.

If you use another hosting platform, ensure you redeploy there as well.

We are done with this part. However, we are still missing some parts of our project's requirements when we set out. In the coming articles, they will be addressed. 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...

Top comments (0)