280 changes: 280 additions & 0 deletions backend/src/routes/users/update_user.rs
@@ -0,0 +1,280 @@
use sqlx::Row;

#[derive(actix_multipart::form::MultipartForm)]
pub struct UserForm {
first_name: Option<actix_multipart::form::text::Text<String>>,
last_name: Option<actix_multipart::form::text::Text<String>>,
thumbnail: Option<actix_multipart::form::tempfile::TempFile>,
phone_number: Option<actix_multipart::form::text::Text<String>>,
birth_date: Option<actix_multipart::form::text::Text<chrono::NaiveDate>>,
github_link: Option<actix_multipart::form::text::Text<String>>,
}

#[derive(serde::Deserialize, Debug)]
pub struct UpdateUser {
first_name: Option<String>,
thumbnail: Option<String>,
last_name: Option<String>,
}
#[derive(serde::Deserialize, Debug)]
pub struct UpdateUserProfile {
phone_number: Option<String>,
birth_date: Option<chrono::NaiveDate>,
github_link: Option<String>,
}

#[derive(serde::Deserialize)]
pub struct Thumbnail {
pub thumbnail: Option<String>,
}

#[tracing::instrument(name = "Updating an user", skip(pool, form, session))]
#[actix_web::patch("/update-user/")]
pub async fn update_users_details(
pool: actix_web::web::Data<sqlx::postgres::PgPool>,
form: actix_multipart::form::MultipartForm<UserForm>,
session: actix_session::Session,
s3_client: actix_web::web::Data<crate::uploads::Client>,
) -> actix_web::HttpResponse {
let session_uuid = match crate::routes::users::logout::session_user_id(&session).await {
Ok(id) => id,
Err(e) => {
tracing::event!(target: "session",tracing::Level::ERROR, "Failed to get user from session. User unauthorized: {}", e);
return actix_web::HttpResponse::Unauthorized().json(crate::types::ErrorResponse {
error: "You are not logged in. Kindly ensure you are logged in and try again"
.to_string(),
});
}
};

// Create a transaction object.
let mut transaction = match pool.begin().await {
Ok(transaction) => transaction,
Err(e) => {
tracing::event!(target: "backend", tracing::Level::ERROR, "Unable to begin DB transaction: {:#?}", e);
return actix_web::HttpResponse::InternalServerError().json(
crate::types::ErrorResponse {
error: "Something unexpected happend. Kindly try again.".to_string(),
},
);
}
};

// At first, set all fields to update to None except user_id
let mut updated_user = UpdateUser {
first_name: None,
last_name: None,
thumbnail: None,
};

let mut user_profile = UpdateUserProfile {
phone_number: None,
birth_date: None,
github_link: None,
};

// If thumbnail was included for update
if let Some(thumbnail) = &form.0.thumbnail {
// Get user's current thumbnail from the DB
let user_current_thumbnail = match sqlx::query("SELECT thumbnail FROM users WHERE id=$1")
.bind(session_uuid)
.map(|row: sqlx::postgres::PgRow| Thumbnail {
thumbnail: row.get("thumbnail"),
})
.fetch_one(&mut *transaction)
.await
{
Ok(image_url) => image_url.thumbnail,
Err(e) => {
tracing::event!(target: "sqlx",tracing::Level::ERROR, "Failed to get user thumbnail from the DB: {:#?}", e);
None
}
};
// If there is a current image, delete it
if let Some(url) = user_current_thumbnail {
let s3_image_key = &url[url.find("media").unwrap_or(url.len())..];

if !s3_client.delete_file(s3_image_key).await {
tracing::event!(target: "backend",tracing::Level::INFO, "We could not delete the current thumbnail of user with ID: {}", session_uuid);
}
}
// make key prefix (make sure it ends with a forward slash)
let s3_key_prefix = format!("media/rust-auth/{session_uuid}/");
// upload temp files to s3 and then remove them
let uploaded_file = s3_client.upload(thumbnail, &s3_key_prefix).await;
updated_user.thumbnail = Some(uploaded_file.s3_url);
}

// If first_name is updated
if let Some(f_name) = form.0.first_name {
updated_user.first_name = Some(f_name.0);
}

// If last_name is updated
if let Some(l_name) = form.0.last_name {
updated_user.last_name = Some(l_name.0);
}

// If phone_number is updated
if let Some(phone) = form.0.phone_number {
user_profile.phone_number = Some(phone.0);
}
// If birth_date is updated
if let Some(bd) = form.0.birth_date {
user_profile.birth_date = Some(bd.0);
}
// If github_link is updated
if let Some(gl) = form.0.github_link {
user_profile.github_link = Some(gl.0);
}

// Update a user in the DB
match update_user_in_db(&mut transaction, &updated_user, &user_profile, session_uuid).await {
Ok(u) => u,
Err(e) => {
tracing::event!(target: "sqlx",tracing::Level::ERROR, "Failed to update user in DB: {:#?}", e);
let error_message = crate::types::ErrorResponse {
error: format!("User could not be updated: {e}"),
};
return actix_web::HttpResponse::InternalServerError().json(error_message);
}
};

let updated_user = match crate::utils::get_active_user_from_db(
None,
Some(&mut transaction),
Some(session_uuid),
None,
)
.await
{
Ok(user) => {
tracing::event!(target: "backend", tracing::Level::INFO, "User retrieved from the DB.");
crate::types::UserVisible {
id: user.id,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
is_active: user.is_active,
is_staff: user.is_staff,
is_superuser: user.is_superuser,
date_joined: user.date_joined,
thumbnail: user.thumbnail,
profile: crate::types::UserProfile {
id: user.profile.id,
user_id: user.profile.user_id,
phone_number: user.profile.phone_number,
birth_date: user.profile.birth_date,
github_link: user.profile.github_link,
},
}
}
Err(e) => {
tracing::event!(target: "backend", tracing::Level::ERROR, "User cannot be retrieved from the DB: {:#?}", e);
let error_message = crate::types::ErrorResponse {
error: "User was not found".to_string(),
};
return actix_web::HttpResponse::NotFound().json(error_message);
}
};

if transaction.commit().await.is_err() {
return actix_web::HttpResponse::InternalServerError().finish();
}

tracing::event!(target: "backend", tracing::Level::INFO, "User updated successfully.");
actix_web::HttpResponse::Ok().json(updated_user)
}

#[tracing::instrument(name = "Updating user in DB.", skip(transaction))]
async fn update_user_in_db(
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,
user_to_update: &UpdateUser,
user_profile: &UpdateUserProfile,
user_id: uuid::Uuid,
) -> Result<(), sqlx::Error> {
match sqlx::query(
"
UPDATE
users
SET
first_name = COALESCE($1, first_name),
last_name = COALESCE($2, last_name),
thumbnail = COALESCE($3, thumbnail)
WHERE
id = $4
AND is_active = true
AND (
$1 IS NOT NULL
AND $1 IS DISTINCT
FROM
first_name
OR $2 IS NOT NULL
AND $2 IS DISTINCT
FROM
last_name
OR $3 IS NOT NULL
AND $3 IS DISTINCT
FROM
thumbnail
)",
)
.bind(&user_to_update.first_name)
.bind(&user_to_update.last_name)
.bind(&user_to_update.thumbnail)
.bind(user_id)
.execute(&mut *transaction)
.await
{
Ok(r) => {
tracing::event!(target: "sqlx", tracing::Level::INFO, "User has been updated successfully: {:#?}", r);
}
Err(e) => {
tracing::event!(target: "sqlx",tracing::Level::ERROR, "Failed to update user into DB: {:#?}", e);
return Err(e);
}
}

match sqlx::query(
"
UPDATE
user_profile
SET
phone_number = COALESCE($1, phone_number),
birth_date = $2,
github_link = COALESCE($3, github_link)
WHERE
user_id = $4
AND (
$1 IS NOT NULL
AND $1 IS DISTINCT
FROM
phone_number
OR $2 IS NOT NULL
AND $2 IS DISTINCT
FROM
birth_date
OR $3 IS NOT NULL
AND $3 IS DISTINCT
FROM
github_link
)",
)
.bind(&user_profile.phone_number)
.bind(user_profile.birth_date)
.bind(&user_profile.github_link)
.bind(user_id)
.execute(&mut *transaction)
.await
{
Ok(r) => {
tracing::event!(target: "sqlx", tracing::Level::INFO, "User profile has been updated successfully: {:#?}", r);
}
Err(e) => {
tracing::event!(target: "sqlx",tracing::Level::ERROR, "Failed to update user profile into DB: {:#?}", e);
return Err(e);
}
}

Ok(())
}
33 changes: 32 additions & 1 deletion backend/src/startup.rs
Expand Up @@ -56,6 +56,9 @@ async fn run(
db_pool: sqlx::postgres::PgPool,
settings: crate::settings::Settings,
) -> Result<actix_web::dev::Server, std::io::Error> {
// For S3 client: create singleton S3 client
let s3_client = actix_web::web::Data::new(configure_and_return_s3_client().await);

// Database connection pool application state
let pool = actix_web::web::Data::new(db_pool);

Expand All @@ -79,7 +82,7 @@ async fn run(
.wrap(
actix_cors::Cors::default()
.allowed_origin(&settings.frontend_url)
.allowed_methods(vec!["GET", "POST", "PATCH", "DELETE"])
.allowed_methods(vec!["GET", "POST", "PATCH", "PUT", "DELETE"])
.allowed_headers(vec![
actix_web::http::header::AUTHORIZATION,
actix_web::http::header::ACCEPT,
Expand All @@ -104,6 +107,8 @@ async fn run(
.app_data(pool.clone())
// Add redis pool to application state
.app_data(redis_pool_data.clone())
// S3 client
.app_data(s3_client.clone())
// Logging middleware
.wrap(actix_web::middleware::Logger::default())
})
Expand All @@ -112,3 +117,29 @@ async fn run(

Ok(server)
}

async fn configure_and_return_s3_client() -> crate::uploads::Client {
// S3 configuration and client
// Get id and secret key from the environment
let aws_key = std::env::var("AWS_ACCESS_KEY_ID").expect("Failed to get AWS key.");
let aws_key_secret =
std::env::var("AWS_SECRET_ACCESS_KEY").expect("Failed to get AWS secret key.");
// build the aws cred
let aws_cred = aws_sdk_s3::config::Credentials::new(
aws_key,
aws_key_secret,
None,
None,
"loaded-from-custom-env",
);
// build the aws client
let aws_region = aws_sdk_s3::config::Region::new(
std::env::var("AWS_REGION").unwrap_or("eu-west-2".to_string()),
);
let aws_config_builder = aws_sdk_s3::config::Builder::new()
.region(aws_region)
.credentials_provider(aws_cred);

let aws_config = aws_config_builder.build();
crate::uploads::Client::new(aws_config)
}
4 changes: 3 additions & 1 deletion backend/src/types/mod.rs
@@ -1,10 +1,12 @@
mod general;
mod tokens;
mod upload;
mod users;

pub use general::{
ErrorResponse, SuccessResponse, USER_EMAIL_KEY, USER_ID_KEY, USER_IS_STAFF_KEY,
USER_IS_SUPERUSER_KEY,
};
pub use tokens::ConfirmationToken;
pub use users::{LoggedInUser, User, UserVisible};
pub use upload::UploadedFile;
pub use users::{LoggedInUser, User, UserProfile, UserVisible};
21 changes: 21 additions & 0 deletions backend/src/types/upload.rs
@@ -0,0 +1,21 @@
#[derive(Debug, serde::Serialize, Clone)]
pub struct UploadedFile {
filename: String,
s3_key: String,
pub s3_url: String,
}

impl UploadedFile {
/// Construct new uploaded file info container.
pub fn new(
filename: impl Into<String>,
s3_key: impl Into<String>,
s3_url: impl Into<String>,
) -> Self {
Self {
filename: filename.into(),
s3_key: s3_key.into(),
s3_url: s3_url.into(),
}
}
}
11 changes: 11 additions & 0 deletions backend/src/types/users.rs
Expand Up @@ -10,6 +10,7 @@ pub struct User {
pub is_superuser: bool,
pub thumbnail: Option<String>,
pub date_joined: chrono::DateTime<chrono::Utc>,
pub profile: UserProfile,
}

#[derive(serde::Serialize, serde::Deserialize)]
Expand All @@ -23,6 +24,16 @@ pub struct UserVisible {
pub is_superuser: bool,
pub thumbnail: Option<String>,
pub date_joined: chrono::DateTime<chrono::Utc>,
pub profile: UserProfile,
}

#[derive(serde::Serialize, serde::Deserialize)]
pub struct UserProfile {
pub id: uuid::Uuid,
pub user_id: uuid::Uuid,
pub phone_number: Option<String>,
pub birth_date: Option<chrono::NaiveDate>,
pub github_link: Option<String>,
}
#[derive(serde::Serialize)]
pub struct LoggedInUser {
Expand Down
79 changes: 79 additions & 0 deletions backend/src/uploads/client.rs
@@ -0,0 +1,79 @@
use tokio::io::AsyncReadExt as _;

/// S3 client wrapper to expose semantic upload operations.
#[derive(Debug, Clone)]
pub struct Client {
s3: aws_sdk_s3::Client,
bucket_name: String,
}

impl Client {
/// Construct S3 client wrapper.
pub fn new(config: aws_sdk_s3::Config) -> Client {
Client {
s3: aws_sdk_s3::Client::from_conf(config),
bucket_name: std::env::var("AWS_S3_BUCKET_NAME").unwrap(),
}
}

pub fn url(&self, key: &str) -> String {
format!(
"https://{}.s3.{}.amazonaws.com/{key}",
std::env::var("AWS_S3_BUCKET_NAME").unwrap(),
std::env::var("AWS_REGION").unwrap(),
)
}

/// Facilitate the upload of file to s3.
pub async fn upload(
&self,
file: &actix_multipart::form::tempfile::TempFile,
key_prefix: &str,
) -> crate::types::UploadedFile {
let filename = file.file_name.as_deref().expect("TODO");
let key = format!("{key_prefix}{filename}");
let s3_url = self
.put_object_from_file(file.file.path().to_str().unwrap(), &key)
.await;
crate::types::UploadedFile::new(filename, key, s3_url)
}

/// Real upload of file to S3
async fn put_object_from_file(&self, local_path: &str, key: &str) -> String {
let mut file = tokio::fs::File::open(local_path).await.unwrap();

let size_estimate = file
.metadata()
.await
.map(|md| md.len())
.unwrap_or(1024)
.try_into()
.expect("file too big");

let mut contents = Vec::with_capacity(size_estimate);
file.read_to_end(&mut contents).await.unwrap();

let _res = self
.s3
.put_object()
.bucket(&self.bucket_name)
.key(key)
.body(aws_sdk_s3::primitives::ByteStream::from(contents))
.send()
.await
.expect("Failed to put object");

self.url(key)
}

/// Attempts to delete object from S3. Returns true if successful.
pub async fn delete_file(&self, key: &str) -> bool {
self.s3
.delete_object()
.bucket(&self.bucket_name)
.key(key)
.send()
.await
.is_ok()
}
}
2 changes: 2 additions & 0 deletions backend/src/uploads/mod.rs
@@ -0,0 +1,2 @@
mod client;
pub use client::Client;
2 changes: 2 additions & 0 deletions backend/src/utils/mod.rs
@@ -1,6 +1,8 @@
mod auth;
mod emails;
mod users;

pub use auth::password::{hash, verify_password};
pub use auth::tokens::{issue_confirmation_token_pasetors, verify_confirmation_token_pasetor};
pub use emails::{send_email, send_multipart_email};
pub use users::get_active_user_from_db;
61 changes: 61 additions & 0 deletions backend/src/utils/users.rs
@@ -0,0 +1,61 @@
use sqlx::Row;

#[tracing::instrument(name = "Getting an active user from the DB.", skip(pool))]
pub async fn get_active_user_from_db(
pool: Option<&sqlx::postgres::PgPool>,
transaction: Option<&mut sqlx::Transaction<'_, sqlx::Postgres>>,
id: Option<uuid::Uuid>,
email: Option<&String>,
) -> Result<crate::types::User, sqlx::Error> {
let mut query_builder =
sqlx::query_builder::QueryBuilder::new(crate::queries::USER_AND_USER_PROFILE_QUERY);

if let Some(id) = id {
query_builder.push(" u.id=");
query_builder.push_bind(id);
}

if let Some(e) = email {
query_builder.push(" u.email=");
query_builder.push_bind(e);
}

let sqlx_query = query_builder
.build()
.map(|row: sqlx::postgres::PgRow| crate::types::User {
id: row.get("u_id"),
email: row.get("u_email"),
first_name: row.get("u_first_name"),
password: row.get("u_password"),
last_name: row.get("u_last_name"),
is_active: row.get("u_is_active"),
is_staff: row.get("u_is_staff"),
is_superuser: row.get("u_is_superuser"),
thumbnail: row.get("u_thumbnail"),
date_joined: row.get("u_date_joined"),
profile: crate::types::UserProfile {
id: row.get("p_id"),
user_id: row.get("p_user_id"),
phone_number: row.get("p_phone_number"),
birth_date: row.get("p_birth_date"),
github_link: row.get("p_github_link"),
},
});

let fetched_query = {
if pool.is_some() {
let p = pool.unwrap();
sqlx_query.fetch_one(p).await
} else {
let t = transaction.unwrap();
sqlx_query.fetch_one(&mut *t).await
}
};
match fetched_query {
Ok(user) => Ok(user),
Err(e) => {
tracing::event!(target: "sqlx",tracing::Level::ERROR, "User not found in DB: {:#?}", e);
Err(e)
}
}
}
2 changes: 1 addition & 1 deletion frontend/src/lib/component/Header/Header.svelte
Expand Up @@ -39,7 +39,7 @@
Login
</a>
{:else}
<a href="/auth/about" class="block shrink-0">
<a href="/auth/about/{$loggedInUser.id}" class="block shrink-0">
<span class="sr-only">{$loggedInUser.first_name} Profile</span>
<img
alt={$loggedInUser.first_name}
Expand Down
96 changes: 96 additions & 0 deletions frontend/src/lib/component/Input/ImageInput.svelte
@@ -0,0 +1,96 @@
<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>
2 changes: 1 addition & 1 deletion frontend/src/lib/component/Loader/Loader.svelte
Expand Up @@ -37,7 +37,7 @@
<style>
.loader-container {
position: fixed;
top: 7.5rem;
top: 0;
left: 0;
right: 0;
bottom: 0;
Expand Down
78 changes: 78 additions & 0 deletions frontend/src/lib/component/Modal/Modal.svelte
@@ -0,0 +1,78 @@
<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>
3 changes: 3 additions & 0 deletions frontend/src/lib/utils/constant.ts
Expand Up @@ -4,6 +4,9 @@ export const BASE_API_URI = import.meta.env.DEV
? import.meta.env.VITE_BASE_API_URI_DEV
: import.meta.env.VITE_BASE_API_URI_PROD;

export const IMAGE_UPLOAD_SIZE = ~~import.meta.env.VITE_IMAGE_UPLOAD_SIZE || 70;
export const HIGHEST_IMAGE_UPLOAD_SIZE = IMAGE_UPLOAD_SIZE * 1024;

export const danceEmoji = '💃';
export const angryEmoji = '😠';
export const sadEmoji = '😔';
Expand Down
17 changes: 17 additions & 0 deletions frontend/src/lib/utils/helpers/image.file.size.ts
@@ -0,0 +1,17 @@
/**
* 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;
};
17 changes: 10 additions & 7 deletions frontend/src/lib/utils/requests/posts.requests.ts
Expand Up @@ -40,14 +40,10 @@ export const post = async (
if (!(body instanceof FormData)) {
headers['Content-Type'] = 'application/json';
requestInitOptions['headers'] = headers;
if (body !== undefined) {
requestInitOptions.body = JSON.stringify(body);
}
requestInitOptions.body = JSON.stringify(body);
} else if (body instanceof FormData) {
headers['Content-Type'] = 'multipart/form-data';
if (body !== undefined) {
requestInitOptions['body'] = body;
}
requestInitOptions['body'] = body;
} else if (body === undefined && method !== 'DELETE') {
const errors: Array<CustomError> = [
{ error: 'Unless you are performing DELETE operation, you must have a body.', id: 0 }
Expand Down Expand Up @@ -80,7 +76,14 @@ export const post = async (
last_name: res_json['last_name'],
is_staff: res_json['is_staff'],
thumbnail: res_json['thumbnail'],
is_superuser: res_json['is_superuser']
is_superuser: res_json['is_superuser'],
profile: {
id: res_json['profile']['id'],
user_id: res_json['profile']['user_id'],
phone_number: res_json['profile']['phone_number'],
birth_date: res_json['profile']['birth_date'],
github_link: res_json['profile']['github_link']
}
};
}

Expand Down
9 changes: 9 additions & 0 deletions frontend/src/lib/utils/types.ts
Expand Up @@ -31,6 +31,14 @@ export interface PasswordChange {
password: string;
}

interface UserProfile {
id: string;
user_id: string;
phone_number: string | null;
birth_date: string | null;
github_link: string | null;
}

export interface User {
email: string;
first_name: string;
Expand All @@ -39,6 +47,7 @@ export interface User {
is_staff: boolean;
thumbnail: string;
is_superuser: boolean;
profile: UserProfile;
}

type Status = 'IDLE' | 'LOADING' | 'NAVIGATING';
Expand Down
79 changes: 0 additions & 79 deletions frontend/src/routes/auth/about/+page.svelte

This file was deleted.

262 changes: 262 additions & 0 deletions frontend/src/routes/auth/about/[id]/+page.svelte
@@ -0,0 +1,262 @@
<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>
16 changes: 16 additions & 0 deletions frontend/src/routes/auth/about/[id]/+page.ts
@@ -0,0 +1,16 @@
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}`);
}
};
11 changes: 2 additions & 9 deletions frontend/src/routes/auth/login/+page.svelte
Expand Up @@ -30,16 +30,9 @@
errors = err;
} else {
loading.setLoading(false);
const response: User = res as User;
$loggedInUser = {
id: response['id'],
email: response['email'],
first_name: response['first_name'],
last_name: response['last_name'],
is_staff: response['is_staff'],
is_superuser: response['is_superuser'],
thumbnail: response['thumbnail']
};
$loggedInUser = response;
$isAuthenticated = true;
$notification = {
Expand Down
6 changes: 3 additions & 3 deletions frontend/vite.config.ts
@@ -1,10 +1,10 @@
import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from 'vitest/config';
// import basicSsl from '@vitejs/plugin-basic-ssl';
import basicSsl from '@vitejs/plugin-basic-ssl';

export default defineConfig({
// plugins: [basicSsl(), sveltekit()],
plugins: [sveltekit()],
plugins: [basicSsl(), sveltekit()],
// plugins: [sveltekit()],
test: {
include: ['src/**/*.{test,spec}.{js,ts}']
}
Expand Down