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.jsimport{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} */exportconstactions={default:async ({fetch,request})=>{constformData=awaitrequest.formData();constemail=String(formData.get('email'));// Some validations/** @type {Record<string, string>} */constfieldsError={};if (!isValidEmail(email)){fieldsError.email='That email address is invalid.';}if (!isEmpty(fieldsError)){returnfail(400,{fieldsError:fieldsError});}/** @type {RequestInit} */constrequestInitOptions={method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({email:email})};constres=awaitfetch(`${BASE_API_URI}/users/regenerate-token/`,requestInitOptions);if (!res.ok){constresponse=awaitres.json();consterrors=formatError(response.error);returnfail(400,{errors:errors});}constresponse=awaitres.json();// redirect the userthrowredirect(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:
<!-- frontend/src/routes/auth/about/[id]/+page.svelte --><script>import{applyAction,enhance}from'$app/forms';import{page}from'$app/stores';importImageInputfrom'$lib/components/ImageInput.svelte';importModalfrom'$lib/components/Modal.svelte';importSmallLoaderfrom'$lib/components/SmallLoader.svelte';importAvatarfrom'$lib/img/teamavatar.png';import{receive,send}from'$lib/utils/helpers';$:({user}=$page.data);letshowModal=false,isUploading=false,isUpdating=false;constopen=()=>(showModal=true);constclose=()=>(showModal=false);/** @type {import('./$types').ActionData} */exportletform;/** @type {import('./$types').SubmitFunction} */consthandleUpdate=async ()=>{isUpdating=true;returnasync ({result})=>{isUpdating=false;if (result.type==='success'||result.type==='redirect'){close();}awaitapplyAction(result);};};/** @type {import('./$types').SubmitFunction} */consthandleUpload=async ()=>{isUploading=true;returnasync ({result})=>{isUploading=false;/** @type {any} */constres=result;if (result.type==='success'||result.type==='redirect'){user.thumbnail=res.data.thumbnail;}awaitapplyAction(result);};};</script><divclass="hero-container"><divclass="hero-logo"><imgsrc={user.thumbnail?user.thumbnail:Avatar}alt={`${user.first_name}${user.last_name}`}/></div><h3class="hero-subtitle subtitle">
Name (First and Last): {`${user.first_name} ${user.last_name}`}
</h3>
{#if user.profile.phone_number}
<h3class="hero-subtitle">
Phone: {user.profile.phone_number}
</h3>
{/if}
{#if user.profile.github_link}
<h3class="hero-subtitle">
GitHub: {user.profile.github_link}
</h3>
{/if}
{#if user.profile.birth_date}
<h3class="hero-subtitle">
Date of birth: {user.profile.birth_date}
</h3>
{/if}
<divclass="hero-buttons-container"><buttonclass="button-dark"on:click={open}>Edit profile</button></div></div>
{#if showModal}
<Modalon:close={close}><formclass="content image"action="?/uploadImage"method="post"enctype="multipart/form-data"use:enhance={handleUpload}><ImageInputavatar={user.thumbnail}fieldName="thumbnail"title="Select user image"/>
{#if !user.thumbnail}
<divclass="btn-wrapper">
{#if isUploading}
<SmallLoaderwidth={30}message={'Uploading...'}/>
{:else}
<buttonclass="button-dark"type="submit">Upload image</button>
{/if}
</div>
{:else}
<inputtype="hidden"hiddenname="thumbnail_url"value={user.thumbnail}required/><divclass="btn-wrapper">
{#if isUploading}
<SmallLoaderwidth={30}message={'Removing...'}/>
{:else}
<buttonclass="button-dark"formaction="?/deleteImage"type="submit">
Remove image
</button>
{/if}
</div>
{/if}
</form><formclass="content"action="?/updateUser"method="POST"use:enhance={handleUpdate}><h1class="step-title"style="text-align: center;">Update User</h1>
{#if form?.success}
<h4class="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.
</h4>
{/if}
{#if form?.errors}
{#each form?.errors as error (error.id)}
<h4class="step-subtitle warning"in:receive={{key:error.id}}out:send={{key:error.id}}>
{error.error}
</h4>
{/each}
{/if}
<inputtype="hidden"hiddenname="thumbnail"value={user.thumbnail}/><divclass="input-box"><spanclass="label">First name:</span><inputclass="input"type="text"name="first_name"value={user.first_name}placeholder="Your first name..."/></div><divclass="input-box"><spanclass="label">Last name:</span><inputclass="input"type="text"name="last_name"value={user.last_name}placeholder="Your last name..."/></div><divclass="input-box"><spanclass="label">Phone number:</span><inputclass="input"type="tel"name="phone_number"value={user.profile.phone_number?user.profile.phone_number:''}placeholder="Your phone number e.g +2348135703593..."/></div><divclass="input-box"><spanclass="label">Birth date:</span><inputclass="input"type="date"name="birth_date"value={user.profile.birth_date?user.profile.birth_date:''}placeholder="Your date of birth..."/></div><divclass="input-box"><spanclass="label">GitHub Link:</span><inputclass="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/..."/></div>
{#if isUpdating}
<SmallLoaderwidth={30}message={'Updating...'}/>
{:else}
<buttontype="submit"class="button-dark">Update</button>
{/if}
</form></Modal>
{/if}
<style>.hero-container.hero-subtitle:not(:last-of-type){margin:0000;}.content.image{display:flex;align-items:center;justify-content:center;}@media(max-width:680px){.content.image{margin:000;}}.content.image.btn-wrapper{margin-top:2.5rem;margin-left:1rem;}.content.image.btn-wrapperbutton{padding:15px18px;}</style>
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:
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:
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.jsimport{BASE_API_URI}from'$lib/utils/constants';import{formatError}from'$lib/utils/helpers';import{fail,redirect}from'@sveltejs/kit';/** @type {import('./$types').PageServerLoad} */exportasyncfunctionload({locals,params}){// redirect user if not logged inif (!locals.user){throwredirect(302,`/auth/login?next=/auth/about/${params.id}`);}}/** @type {import('./$types').Actions} */exportconstactions={/**
*
* @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})=>{constformData=awaitrequest.formData();constfirstName=String(formData.get('first_name'));constlastName=String(formData.get('last_name'));constthumbnail=String(formData.get('thumbnail'));constphoneNumber=String(formData.get('phone_number'));constbirthDate=String(formData.get('birth_date'));constgithubLink=String(formData.get('github_link'));constapiURL=`${BASE_API_URI}/users/update-user/`;constres=awaitfetch(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){constresponse=awaitres.json();consterrors=formatError(response.error);returnfail(400,{errors:errors});}constresponse=awaitres.json();locals.user=response;if (locals.user.profile.birth_date){locals.user.profile.birth_date=response['profile']['birth_date'].split('T')[0];}throwredirect(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})=>{constformData=awaitrequest.formData();/** @type {RequestInit} */constrequestInitOptions={method:'POST',headers:{Cookie:`sessionid=${cookies.get('go-auth-sessionid')}`},body:formData};constres=awaitfetch(`${BASE_API_URI}/file/upload/`,requestInitOptions);if (!res.ok){constresponse=awaitres.json();consterrors=formatError(response.error);returnfail(400,{errors:errors});}constresponse=awaitres.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})=>{constformData=awaitrequest.formData();/** @type {RequestInit} */constrequestInitOptions={method:'DELETE',headers:{Cookie:`sessionid=${cookies.get('go-auth-sessionid')}`},body:formData};constres=awaitfetch(`${BASE_API_URI}/file/delete/`,requestInitOptions);if (!res.ok){constresponse=awaitres.json();consterrors=formatError(response.error);returnfail(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:
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:
The data for the page was fetched by the page's +page.server.js file:
// frontend/src/routes/auth/admin/+page.server.jsimport{BASE_API_URI}from'$lib/utils/constants';import{redirect}from'@sveltejs/kit';/** @type {import('./$types').PageServerLoad} */exportasyncfunctionload({locals,cookies}){// redirect user if not logged in or not a superuserif (!locals.user||!locals.user.is_superuser){throwredirect(302,`/auth/login?next=/auth/admin`);}constfetchMetrics=async ()=>{constres=awaitfetch(`${BASE_API_URI}/metrics/`,{credentials:'include',headers:{Cookie:`sessionid=${cookies.get('go-auth-sessionid')}`}});returnres.ok&&(awaitres.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? I'm a Software Engineer and Technical Writer actively seeking new opportunities, particularly in areas related to web security, finance, health care, and education. If you think my expertise aligns with your team's needs, let's chat! You can find me on LinkedIn: LinkedIn and Twitter: Twitter.
If you found this article valuable, consider sharing it with your network to help spread the knowledge!