Authentication system using rust (actix-web) and sveltekit - User Profile Update UI
Tags: typescript, svelte, javascript, tutorial
Published: 2023-05-01 08:38:10

## Introduction

In the last article, we built out the backend API for updating users' profiles and storing users' thumbnails on [AWS S3][1]. 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 Sirneij/rust-auth %}

## Implementation

> You can get the overview of the code for this article on [github][2]. 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:

```typescript
// 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}`);
    }
};
```

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:

```typescript
// 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 });
...
```

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

```html
<!-- 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>
```

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:

```typescript
...
const open = () => (showModal = true);
const close = () => (showModal = false);
...
```

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`:

```html
<!-- 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>
```

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][3]. 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:

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

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()`][4] 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:

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

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


```html
<!-- 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>
```

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`:

```typescript
// 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;
...
```

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`:

```typescript
// 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;
};
```

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 ☕](mailto:sirneij@gmail.com). You can also connect with/follow me on [LinkedIn](https://www.linkedin.com/in/idogun-john-nelson/) and [Twitter](https://twitter.com/Sirneij). It isn't bad if you help share this article for wider coverage. I will appreciate it...

[1]: https://s3.console.aws.amazon.com/ "Amazon S3"
[2]: https://github.com/Sirneij/rust-auth/pull/9/commits/af67cd95adbb0adc7bede8e797fe32b1e6768c99 "User profile and AWS S3 file upload"
[3]: https://svelte.dev/tutorial/component-events "Svelte component events"
[4]: https://developer.mozilla.org/en-US/docs/Web/API/FormData "FormData"