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:
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
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}`);
}
};
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 redirected 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 });
...
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>
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 have already covered.
Starting with:
...
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
:
<!-- 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. 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}>
...
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 />
...
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>
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;
...
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;
};
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 additionalprofile
type we added to theUser
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? I'm a Software Engineer and Technical Writer actively seeking new opportunities, particularly in areas related to web security, finance, healthcare, and education. If you think my expertise aligns with your team's needs, let's chat! You can find me on LinkedIn and Twitter.
If you found this article valuable, consider sharing it with your network to help spread the knowledge!
Top comments (0)