From 98887d7fef24072f439645ae61bd501cc365fd63 Mon Sep 17 00:00:00 2001 From: Ben Sherriff Date: Fri, 11 Apr 2025 09:53:05 -0400 Subject: [PATCH] Working on session validation --- .env | 1 + api/src/airports/model/airport.rs | 11 +- api/src/airports/routes.rs | 4 +- api/src/auth/mod.rs | 1 - api/src/auth/model.rs | 9 +- api/src/auth/routes.rs | 88 ++++++--- api/src/auth/session.rs | 60 ++++-- api/src/db/mod.rs | 16 +- api/src/main.rs | 10 +- api/src/metars/model.rs | 3 +- api/src/metars/routes.rs | 1 - api/src/scheduler.rs | 4 +- api/src/users/routes.rs | 2 +- bruno/Users/Validate Session.bru | 11 ++ ui/package-lock.json | 18 ++ ui/package.json | 2 + ui/src/components/AirportLayer.tsx | 1 - ui/src/components/Header/HeaderModal.tsx | 224 +++++++++++++++++++++++ ui/src/components/Header/HeaderUser.tsx | 90 +++++++++ ui/src/components/Header/index.tsx | 201 ++++++++++++++------ ui/src/lib/account.ts | 63 +++++++ ui/src/lib/account.types.ts | 19 ++ ui/src/lib/index.ts | 9 +- ui/src/main.tsx | 8 +- 24 files changed, 724 insertions(+), 132 deletions(-) create mode 100644 bruno/Users/Validate Session.bru create mode 100644 ui/src/components/Header/HeaderModal.tsx create mode 100644 ui/src/components/Header/HeaderUser.tsx create mode 100644 ui/src/lib/account.ts create mode 100644 ui/src/lib/account.types.ts diff --git a/.env b/.env index 49483b6..4c75b65 100644 --- a/.env +++ b/.env @@ -32,6 +32,7 @@ MINIO_HOST=localhost HTTPD_HOST=localhost HTTPD_API_HOST=host.docker.internal HTTPD_UI_HOST=host.docker.internal +VITE_API_URL=http://localhost:8080/api ################## # Running Docker # diff --git a/api/src/airports/model/airport.rs b/api/src/airports/model/airport.rs index e974ee4..15abd14 100644 --- a/api/src/airports/model/airport.rs +++ b/api/src/airports/model/airport.rs @@ -1,12 +1,11 @@ use std::collections::HashMap; use std::str::FromStr; -use actix_web::web::Json; use futures_util::try_join; -use moka::future::Cache; use serde::{Deserialize, Serialize}; -use sqlx::{Execute, Postgres, QueryBuilder}; -use crate::airports::model::airport_category::AirportCategory; -use crate::airports::{Frequency, FrequencyRow, Runway, RunwayRow, UpdateFrequency, UpdateRunway}; +use sqlx::{Postgres, QueryBuilder}; +use crate::airports::{ + AirportCategory, Frequency, FrequencyRow, Runway, RunwayRow, UpdateFrequency, UpdateRunway, +}; use crate::db; use crate::error::{ApiResult, Error}; use crate::metars::Metar; @@ -516,7 +515,7 @@ impl Airport { } // TODO - pub async fn update(icao: &str, airport: &UpdateAirport) -> ApiResult<()> { + pub async fn update(_icao: &str, _airport: &UpdateAirport) -> ApiResult<()> { Ok(()) } diff --git a/api/src/airports/routes.rs b/api/src/airports/routes.rs index 61c95a7..04271b0 100644 --- a/api/src/airports/routes.rs +++ b/api/src/airports/routes.rs @@ -1,14 +1,12 @@ -use std::str::FromStr; use futures_util::stream::StreamExt as _; use crate::{ - airports::{Airport, AirportCategory}, + airports::Airport, db::Paged, auth::{Auth, verify_role}, }; use actix_multipart::Multipart; use actix_web::{delete, get, post, put, web, HttpResponse, HttpRequest, ResponseError}; -use serde::{Serialize, Deserialize}; use crate::airports::{AirportQuery, UpdateAirport}; use crate::users::ADMIN_ROLE; diff --git a/api/src/auth/mod.rs b/api/src/auth/mod.rs index 6fe6f73..265bd3e 100644 --- a/api/src/auth/mod.rs +++ b/api/src/auth/mod.rs @@ -5,7 +5,6 @@ use argon2::{ use rand::distr::Alphanumeric; use rand::prelude::*; use rand_chacha::ChaCha20Rng; -use serde::{Deserialize, Serialize}; mod model; mod routes; diff --git a/api/src/auth/model.rs b/api/src/auth/model.rs index 1b5a9ba..624aa94 100644 --- a/api/src/auth/model.rs +++ b/api/src/auth/model.rs @@ -27,9 +27,12 @@ impl FromRequest for Auth { Some(key_id) => { let fut = async move { // Check if the Session API key exists - let api_key = match Session::get(&key_id).await? { - Some(session) => session, - None => return Err(Error::new(401, "API Key does not exist".to_string()).into()), + let api_key = match Session::get(&key_id).await { + Ok(session) => session, + Err(err) => { + log::error!("Invalid session auth attempt: {}", err); + return Err(Error::new(401, "API Key does not exist".to_string()).into()); + } }; match User::select(&api_key.email).await { Some(user) => Ok(Auth { diff --git a/api/src/auth/routes.rs b/api/src/auth/routes.rs index 7c12eba..c9df942 100644 --- a/api/src/auth/routes.rs +++ b/api/src/auth/routes.rs @@ -1,17 +1,11 @@ -use std::sync::OnceLock; -use actix_web::{ - post, web, HttpResponse, ResponseError, - cookie::{Cookie, time::Duration}, - HttpRequest, put, -}; +use actix_web::{post, web, HttpResponse, ResponseError, HttpRequest, put, get}; use crate::{ auth::{verify_hash, Session, SESSION_COOKIE_NAME}, error::Error, users::{LoginRequest, RegisterRequest, User, UserResponse}, }; -use crate::auth::{hash, Auth, DEFAULT_SESSION_TTL}; -use crate::error::ApiResult; +use crate::auth::Auth; use crate::users::UpdateUser; #[post("/register")] @@ -19,20 +13,20 @@ async fn register(user: web::Json, req: HttpRequest) -> HttpRes let register_user = user.into_inner(); let email = register_user.email.clone(); let ip_address = req.peer_addr().unwrap().ip().to_string(); - let mut insert_user: User = match register_user.to_user() { + let insert_user: User = match register_user.to_user() { Ok(user) => user, Err(err) => return ResponseError::error_response(&err), }; match insert_user.insert().await { Ok(user) => { - let response: UserResponse = user.into(); + let user_response: UserResponse = user.into(); log::info!( "Successful user registration [Email: {}] [IP Address: {}]", email, ip_address ); - HttpResponse::Created().json(response) + HttpResponse::Created().json(user_response) } Err(err) => { // Obfuscate the service error message to prevent leaking database details @@ -63,8 +57,8 @@ async fn login(request: web::Json, req: HttpRequest) -> HttpRespon if verify_hash(&request.password, &query_user.password_hash) { // Create a session - let session = Session::new(64, &email, &ip_address, Some(DEFAULT_SESSION_TTL)); - let session_cookie = session.to_cookie(); + let session = Session::default(&email, &ip_address); + let session_cookie = session.cookie(); // Save the session to the database if let Err(err) = session.store().await { log::error!( @@ -80,7 +74,10 @@ async fn login(request: web::Json, req: HttpRequest) -> HttpRespon email, ip_address ); - HttpResponse::Ok().cookie(session_cookie).finish() + let user_response: UserResponse = query_user.into(); + HttpResponse::Ok() + .cookie(session_cookie) + .json(user_response) } else { log::error!( "Invalid login attempt [Email: {}] [IP Address: {}]", @@ -119,19 +116,59 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse { } } - let session_cookie = Cookie::build(SESSION_COOKIE_NAME, "") - .path("/") - .max_age(Duration::seconds(-1)) - .secure(true) - .http_only(true) - .finish(); - log::info!( "Successful logout attempt [Email: {}] [IP Address: {}]", email, ip_address ); - HttpResponse::Ok().cookie(session_cookie).finish() + HttpResponse::Ok().cookie(Session::empty_cookie()).finish() +} + +#[get("/session")] +async fn validate_session(req: HttpRequest) -> HttpResponse { + let ip_address = req.peer_addr().unwrap().ip().to_string(); + // Verify a session cookie exists + match req.cookie(SESSION_COOKIE_NAME) { + // Validate the session + Some(cookie) => { + let session_id = cookie.value().to_string(); + let session = match Session::replace(&session_id, &ip_address).await { + Ok(session) => session, + Err(err) => { + log::error!( + "Invalid session validate attempt [Session: {}] [IP Address: {}]", + session_id, + ip_address + ); + return ResponseError::error_response(&Error::new(500, err.to_string())); + } + }; + let email = &session.email; + let query_user = match User::select(&email).await { + Some(query_user) => query_user, + None => { + return HttpResponse::Unauthorized() + .cookie(Session::empty_cookie()) + .finish() + } + }; + + let user_response: UserResponse = query_user.into(); + let session_cookie = session.cookie(); + + log::info!( + "Successful session validate attempt [Email: {}] [IP Address: {}]", + email, + ip_address + ); + HttpResponse::Ok() + .cookie(session_cookie) + .json(user_response) + } + None => HttpResponse::Unauthorized() + .cookie(Session::empty_cookie()) + .finish(), + } } #[put("/password")] @@ -178,8 +215,8 @@ async fn change_password( } #[post("/password-reset")] -async fn password_reset(req: HttpRequest, auth: Auth) -> HttpResponse { - let ip_address = req.peer_addr().unwrap().ip().to_string(); +async fn password_reset(req: HttpRequest, _auth: Auth) -> HttpResponse { + let _ip_address = req.peer_addr().unwrap().ip().to_string(); HttpResponse::Ok().finish() } @@ -189,6 +226,7 @@ pub fn init_routes(config: &mut web::ServiceConfig) { .service(register) .service(login) .service(logout) - .service(change_password), + .service(change_password) + .service(validate_session), ); } diff --git a/api/src/auth/session.rs b/api/src/auth/session.rs index abe1aa9..f8e6f70 100644 --- a/api/src/auth/session.rs +++ b/api/src/auth/session.rs @@ -2,15 +2,14 @@ use actix_web::cookie::{time::Duration, Cookie}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use redis::{AsyncCommands, RedisResult}; - +use tokio::task; use crate::{ db::redis_async_connection, error::{Error, ApiResult}, }; - use super::{csprng, hash, verify_hash}; -pub const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours +const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours pub const SESSION_COOKIE_NAME: &str = "session"; #[derive(Debug, Serialize, Deserialize)] @@ -23,6 +22,10 @@ pub struct Session { } impl Session { + pub fn default(email: &str, ip_address: &str) -> Self { + Self::new(64, email, ip_address, Some(DEFAULT_SESSION_TTL)) + } + pub fn new(take: usize, email: &str, ip_address: &str, ttl: Option) -> Self { let now = Utc::now(); Self { @@ -53,16 +56,32 @@ impl Session { } } - pub async fn get(session_id: &str) -> ApiResult> { + pub async fn get(session_id: &str) -> ApiResult { let mut conn = redis_async_connection().await?; let result: RedisResult> = conn.get(session_id).await; match result { - Ok(Some(value)) => Ok(Some(serde_json::from_str(&value)?)), - Ok(None) => Ok(None), + Ok(Some(value)) => Ok(serde_json::from_str(&value)?), + Ok(None) => Err(Error::new(401, format!("Missing session {}", session_id))), Err(err) => Err(err.into()), } } + pub async fn replace(session_id: &str, ip_address: &str) -> ApiResult { + let mut session = Self::verify(session_id, ip_address).await?; + let session_id_owned = session_id.to_owned(); + task::spawn(async move { + if let Err(err) = Self::delete(&session_id_owned).await { + log::error!( + "Error deleting old session in replace session call: {}", + err + ); + }; + }); + session = Session::default(&session.email, ip_address); + session.store().await?; + Ok(session) + } + pub async fn delete(session_id: &str) -> ApiResult<()> { let mut conn = redis_async_connection().await?; let result: RedisResult<()> = conn.del(session_id).await; @@ -73,11 +92,7 @@ impl Session { } pub async fn verify(session_id: &str, ip_address: &str) -> ApiResult { - // Check if the session exists - let session = match Self::get(session_id).await? { - Some(session) => session, - None => return Err(Error::new(401, "Session does not exist".to_string())), - }; + let session = Self::get(session_id).await?; // Check if the IP Address matches the Session's IP Address if verify_hash(ip_address, &session.ip_address) { @@ -87,7 +102,7 @@ impl Session { } } - pub fn to_cookie(&self) -> Cookie { + pub fn cookie(&self) -> Cookie { let expires_at = match self.expires_at { Some(expires_at) => expires_at.timestamp(), None => DEFAULT_SESSION_TTL, @@ -96,14 +111,13 @@ impl Session { let mut cookie = Cookie::build(SESSION_COOKIE_NAME, self.session_id.clone()) .path("/") .max_age(Duration::seconds(ttl)) - // TODO: enable secure and http_only .secure(true) .http_only(true) .finish(); if let Ok(environment) = std::env::var("ENVIRONMENT") { if environment == "development" || environment == "dev" { - log::debug!( + log::trace!( "Development cookie [Email: {}]: {}", self.email, self.session_id @@ -115,4 +129,22 @@ impl Session { cookie } + + pub fn empty_cookie() -> Cookie<'static> { + let mut cookie = Cookie::build(SESSION_COOKIE_NAME, "") + .path("/") + .max_age(Duration::seconds(-1)) + .secure(true) + .http_only(true) + .finish(); + + if let Ok(environment) = std::env::var("ENVIRONMENT") { + if environment == "development" || environment == "dev" { + cookie.set_secure(false); + cookie.set_http_only(false); + } + } + + cookie + } } diff --git a/api/src/db/mod.rs b/api/src/db/mod.rs index 27cf352..981b975 100644 --- a/api/src/db/mod.rs +++ b/api/src/db/mod.rs @@ -23,10 +23,16 @@ pub async fn initialize() -> ApiResult<()> { let db_url = format!( "postgres://{}:{}@{}:{}/{}", - db_user, db_password, db_host, db_port, db_name + &db_user, &db_password, &db_host, &db_port, &db_name ); - log::info!("Connecting to database at {}...", &db_url); + log::info!( + "Connecting to database at postgres://{}:*****@{}:{}/{}...", + &db_user, + &db_host, + &db_port, + &db_name + ); // Setup Postgres pool connection let pool = PgPoolOptions::new() .max_connections(5) @@ -35,7 +41,7 @@ pub async fn initialize() -> ApiResult<()> { .await?; match POOL.set(pool) { Ok(_) => log::info!("Database connection established"), - Err(_) => log::warn!("Database pool already initialized") + Err(_) => log::warn!("Database pool already initialized"), } // Setup Redis connection @@ -47,7 +53,7 @@ pub async fn initialize() -> ApiResult<()> { }; match REDIS.set(redis) { Ok(_) => log::info!("Redis connection established"), - Err(_) => log::warn!("Redis client already initialized") + Err(_) => log::warn!("Redis client already initialized"), } let schema = std::env::var("MINIO_SCHEMA").unwrap_or("http".to_string()); @@ -76,7 +82,7 @@ pub async fn initialize() -> ApiResult<()> { match BUCKET.set(*bucket) { Ok(_) => log::info!("Bucket initialized"), - Err(_) => log::warn!("Bucket client already initialized") + Err(_) => log::warn!("Bucket client already initialized"), } // Run migrations diff --git a/api/src/main.rs b/api/src/main.rs index a93dd55..b409608 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -3,7 +3,6 @@ use std::env; use actix_cors::Cors; use actix_web::{App, HttpServer, middleware::Logger, web}; use dotenv::from_filename; -use moka::future::Cache; use crate::auth::hash; use crate::users::{User, ADMIN_ROLE}; @@ -63,14 +62,13 @@ async fn main() -> Result<(), Box> { .allow_any_header() .supports_credentials() .max_age(3600); - App::new() - .wrap(cors) - .wrap(Logger::default()) - .service(web::scope("api") + App::new().wrap(cors).wrap(Logger::default()).service( + web::scope("api") .configure(airports::init_routes) .configure(metars::init_routes) .configure(auth::init_routes) - .configure(users::init_routes)) + .configure(users::init_routes), + ) }) .bind(format!("{}:{}", host, port)) { diff --git a/api/src/metars/model.rs b/api/src/metars/model.rs index e6a29cf..1933d14 100644 --- a/api/src/metars/model.rs +++ b/api/src/metars/model.rs @@ -2,7 +2,6 @@ use crate::error::Error; use crate::{error::ApiResult, db}; use chrono::{DateTime, Datelike, Utc}; use std::collections::HashSet; -use moka::future::Cache; use redis::{AsyncCommands, RedisResult}; use serde::{Deserialize, Serialize}; use crate::db::redis_async_connection; @@ -294,7 +293,7 @@ impl Metar { Ok(day) => day, Err(err) => return Err(err.into()), }; - let mut observation_time_hour = match observation_time[2..4].parse::() { + let observation_time_hour = match observation_time[2..4].parse::() { Ok(hour) => hour, Err(err) => return Err(err.into()), }; diff --git a/api/src/metars/routes.rs b/api/src/metars/routes.rs index fbe07ab..4c147e6 100644 --- a/api/src/metars/routes.rs +++ b/api/src/metars/routes.rs @@ -1,4 +1,3 @@ -use crate::error::Error; use crate::metars::Metar; use actix_web::{get, web, HttpResponse, HttpRequest}; use log::error; diff --git a/api/src/scheduler.rs b/api/src/scheduler.rs index 6a75ccd..ef18a1f 100644 --- a/api/src/scheduler.rs +++ b/api/src/scheduler.rs @@ -1,7 +1,7 @@ -use tokio::time::{sleep, Duration}; +// use tokio::time::{sleep, Duration}; // use crate::airports::{AirportDb, AirportFilter}; -use crate::metars::Metar; +// use crate::metars::Metar; pub fn update_airports() { // tokio::spawn(async { diff --git a/api/src/users/routes.rs b/api/src/users/routes.rs index 87e6e21..eb4b7cc 100644 --- a/api/src/users/routes.rs +++ b/api/src/users/routes.rs @@ -152,7 +152,7 @@ // } // } -pub fn init_routes(config: &mut actix_web::web::ServiceConfig) { +pub fn init_routes(_config: &mut actix_web::web::ServiceConfig) { // config.service( // web::scope("users") // .service(get_favorites) diff --git a/bruno/Users/Validate Session.bru b/bruno/Users/Validate Session.bru new file mode 100644 index 0000000..8a80f4e --- /dev/null +++ b/bruno/Users/Validate Session.bru @@ -0,0 +1,11 @@ +meta { + name: Validate Session + type: http + seq: 5 +} + +get { + url: {{API_URL}}/account/session + body: none + auth: none +} diff --git a/ui/package-lock.json b/ui/package-lock.json index a35a9a8..4067f81 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -15,6 +15,7 @@ "@mantine/notifications": "^7.17.2", "@tabler/icons-react": "^3.31.0", "d3": "^7.9.0", + "js-cookie": "^3.0.5", "leaflet": "^1.9.4", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -23,6 +24,7 @@ "devDependencies": { "@eslint/js": "^9.21.0", "@types/d3": "^7.4.3", + "@types/js-cookie": "^3.0.6", "@types/leaflet": "^1.9.16", "@types/node": "^22.13.10", "@types/react": "^19.0.10", @@ -1847,6 +1849,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3494,6 +3503,15 @@ "dev": true, "license": "ISC" }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/ui/package.json b/ui/package.json index 5c903ec..0406d19 100644 --- a/ui/package.json +++ b/ui/package.json @@ -18,6 +18,7 @@ "@mantine/notifications": "^7.17.2", "@tabler/icons-react": "^3.31.0", "d3": "^7.9.0", + "js-cookie": "^3.0.5", "leaflet": "^1.9.4", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -26,6 +27,7 @@ "devDependencies": { "@eslint/js": "^9.21.0", "@types/d3": "^7.4.3", + "@types/js-cookie": "^3.0.6", "@types/leaflet": "^1.9.16", "@types/node": "^22.13.10", "@types/react": "^19.0.10", diff --git a/ui/src/components/AirportLayer.tsx b/ui/src/components/AirportLayer.tsx index 3c7df33..7ec0cb3 100644 --- a/ui/src/components/AirportLayer.tsx +++ b/ui/src/components/AirportLayer.tsx @@ -35,7 +35,6 @@ export default function AirportLayer({ setAirport }: { setAirport: (airport: Air limit: 200 }) .then((response) => { - console.log(response); setAirports(response.data); }) .catch((error) => { diff --git a/ui/src/components/Header/HeaderModal.tsx b/ui/src/components/Header/HeaderModal.tsx new file mode 100644 index 0000000..89fb8f5 --- /dev/null +++ b/ui/src/components/Header/HeaderModal.tsx @@ -0,0 +1,224 @@ +import { + Modal, + Container, + Title, + Anchor, + Paper, + TextInput, + Button, + PasswordInput, + Group, + Checkbox, + Text +} from '@mantine/core'; +import { useForm } from '@mantine/form'; +import Cookies from 'js-cookie'; + +interface HeaderModalProps { + type?: string; + toggle: (input: string | undefined) => void; + login: ({ email, password }: { email: string; password: string }) => Promise; + register: ({ + firstName, + lastName, + email, + password + }: { + firstName: string; + lastName: string; + email: string; + password: string; + }) => Promise; +} + +export function HeaderModal({ type, toggle, login, register }: HeaderModalProps) { + function passwordValidator(value: string) { + if (value.trim().length < 8) { + return 'Password must be at least 8 characters'; + } + if (value.trim().length >= 128) { + return 'Password must be at most 128 characters'; + } + if (!/(\d)/.test(value)) { + return 'Password must contain at least one number'; + } + if (!/[a-z]/.test(value)) { + return 'Password must contain at least one lowercase letter'; + } + if (!/[A-Z]/.test(value)) { + return 'Password must contain at least one uppercase letter'; + } + if (!/[!@#$%^&*]/.test(value)) { + return 'Password must contain at least one special character'; + } + return null; + } + + function emailValidator(value: string) { + if (value.trim().length == 0) { + return 'Email is required'; + } + if (!/^\S+@\S+$/.test(value)) { + return 'Invalid email'; + } + return null; + } + + const registerForm = useForm({ + initialValues: { + firstName: '', + lastName: '', + email: '', + password: '' + }, + validate: { + firstName: (value) => (value.trim().length > 0 ? null : 'First name is required'), + lastName: (value) => (value.trim().length > 0 ? null : 'Last name is required'), + email: emailValidator, + password: passwordValidator + } + }); + + const loginForm = useForm({ + initialValues: { + email: Cookies.get('email') || '', + password: '', + remember: Cookies.get('remember') === 'true' + } + }); + + const resetForm = useForm({ + initialValues: { + email: '' + } + }); + + function onClose() { + toggle(undefined); + registerForm.reset(); + resetForm.reset(); + if (!loginForm.values.remember) { + loginForm.reset(); + } + } + + return ( + + {type == 'reset' ? ( + + Reset password + + Enter your email and we will send you a link to reset your password.{' '} + toggle('login')}> + Go Back + + + +
console.log(values))}> + + + +
+
+ ) : type == 'register' ? ( + + Create account + + Already have an account?{' '} + toggle('login')}> + Sign in + + + + +
{ + const success = await register(values); + if (success) { + onClose(); + } + })} + > + + + + + + +
+
+ ) : ( + + Welcome back! + + Do not have an account yet?{' '} + toggle('register')}> + Create account + + + + +
{ + Cookies.set('remember', 'true', { expires: 365 }); + if (values.remember) { + Cookies.set('email', values.email, { expires: 365 }); + } else { + Cookies.remove('email'); + } + const success = await login(values); + if (success) { + onClose(); + } + })} + > + + + + + toggle('reset')}> + Forgot password? + + + + +
+
+ )} +
+ ); +} diff --git a/ui/src/components/Header/HeaderUser.tsx b/ui/src/components/Header/HeaderUser.tsx new file mode 100644 index 0000000..68ad8b1 --- /dev/null +++ b/ui/src/components/Header/HeaderUser.tsx @@ -0,0 +1,90 @@ +import { User } from '@/lib/account.types'; +// import { setPicture } from "@/api/users"; +import { Menu, UnstyledButton, Group, Avatar, Card, FileButton, Grid, Button, Text } from '@mantine/core'; +// import './styles.css'; + +interface HeaderUserProps { + user: User; + profilePicture: File | undefined; + logout: () => Promise; +} + +export default function HeaderUser({ user, profilePicture, logout }: HeaderUserProps) { + return ( + + + + + +
+ + {user.first_name} {user.last_name} + + + {user.role} + +
+
+
+
+ + + + { + if (payload) { + // TODO profile picture + // setPicture(payload).then((response: any) => { + // if (response) { + // + // } + // }); + } + }} + accept='image/png,image/jpeg,image/svg+xml,image/webp,image/gif,image/apng,image/avif' + multiple={false} + > + {(props) => ( + + )} + + + {user.first_name} {user.last_name} + + + {user.role} + + + + + + + + + {user.role == 'admin' && ( + + + + )} + + + +
+ ); +} diff --git a/ui/src/components/Header/index.tsx b/ui/src/components/Header/index.tsx index 23b1dc8..6e97eed 100644 --- a/ui/src/components/Header/index.tsx +++ b/ui/src/components/Header/index.tsx @@ -1,64 +1,163 @@ import { useState } from 'react'; import { Avatar, Box, Burger, Button, Group, Text } from '@mantine/core'; -import { useDisclosure } from '@mantine/hooks'; +import { useDisclosure, useToggle } from '@mantine/hooks'; import classes from './Header.module.css'; +import { HeaderModal } from '@components/Header/HeaderModal.tsx'; +import { notifications } from '@mantine/notifications'; +import Cookies from 'js-cookie'; +import { User } from '@lib/account.types.ts'; +import { login, logout, register } from '@lib/account.ts'; +import HeaderUser from '@components/Header/HeaderUser.tsx'; -const links = [ - { link: '/', label: 'Map' }, - { link: '/airports', label: 'Airports' }, - { link: '/metars', label: 'Metars' } -]; +// const links = [ +// { link: '/', label: 'Map' }, +// { link: '/airports', label: 'Airports' }, +// { link: '/metars', label: 'Metars' } +// ]; export function Header() { const [opened, { toggle }] = useDisclosure(false); - const [active, setActive] = useState(links[0].link); - const isSignedIn = false; + const [modalType, modalToggle] = useToggle([undefined, 'login', 'register', 'reset']); + const [user, setUser] = useState(undefined); + // const [active, setActive] = useState(links[0].link); - const navItems = links.map((link) => ( - { - event.preventDefault(); - setActive(link.link); - }} - > - {link.label} - - )); + // const navItems = links.map((link) => ( + // { + // event.preventDefault(); + // setActive(link.link); + // }} + // > + // {link.label} + // + // )); + + async function loginUser({ email, password }: { email: string; password: string }): Promise { + const loginResponse = await login(email, password); + if (loginResponse) { + setUser(loginResponse); + notifications.show({ + title: `Welcome back ${loginResponse.first_name}!`, + message: `You have been logged in.`, + color: 'green', + autoClose: 2000, + loading: false + }); + return true; + } else { + notifications.show({ + title: `Unable to Login`, + message: `Please try again.`, + color: 'red', + autoClose: 2000, + loading: false + }); + } + return false; + } + + async function logoutUser(): Promise { + await logout(); + Cookies.remove('logged_in'); + setUser(undefined); + } + + async function registerUser({ + firstName, + lastName, + email, + password + }: { + firstName: string; + lastName: string; + email: string; + password: string; + }): Promise { + const id = notifications.show({ + loading: true, + title: `Creating account`, + message: `Please wait...`, + autoClose: false, + withCloseButton: false + }); + const registerResponse = await register({ + first_name: firstName, + last_name: lastName, + email: email, + password: password + }); + if (registerResponse) { + const loginResponse = await login(email, password); + if (loginResponse) { + setUser(loginResponse); + notifications.update({ + id, + title: `Account created`, + message: `Welcome ${loginResponse.first_name}!`, + color: 'green', + autoClose: 2000, + loading: false + }); + return true; + } else { + notifications.update({ + id, + title: `Unable to Login`, + message: `Please try again.`, + color: 'red', + autoClose: 2000, + loading: false + }); + } + } else { + notifications.update({ + id, + title: `Unable to Register`, + message: `Please try again.`, + color: 'error', + autoClose: 2000, + loading: false + }); + } + return false; + } + + console.log(Cookies.get('logged_in')); + console.log(Cookies.get('session')); return ( - -
- - - - - Aviation + <> + +
+ + + + + FlightLink + + {/**/} + {/* {navItems}*/} + {/**/} + + {user ? ( + + ) : ( + + + + + )} + - - {navItems} - - - {isSignedIn ? ( - // Clickable avatar if signed in - - ) : ( - <> - - - - )} - - -
-
+
+
+ + ); } diff --git a/ui/src/lib/account.ts b/ui/src/lib/account.ts new file mode 100644 index 0000000..a091af7 --- /dev/null +++ b/ui/src/lib/account.ts @@ -0,0 +1,63 @@ +import Cookies from 'js-cookie'; +import { getRequest, postRequest } from '.'; +import { RegisterUser, ResponseAuth, User } from './account.types'; + +export async function login(email: string, password: string): Promise { + const response = await postRequest('account/login', { email, password }); + if (response?.status === 200) { + return response.json(); + } else { + return undefined; + } +} + +export async function register(user: RegisterUser): Promise { + const response = await postRequest('account/register', user); + if (response?.status === 201) { + return true; + } else { + return false; + } +} + +export async function logout() { + return await postRequest('account/logout', {}); +} + +export async function refresh(refresh_token_rotation?: boolean): Promise { + const response = await getRequest('account/refresh', { refresh_token_rotation }); + if (response?.status === 200) { + return response.json(); + } else { + return undefined; + } +} + +export async function me(): Promise { + const response = await getRequest('account/me'); + if (response?.status === 200) { + return response.json(); + } else { + return undefined; + } +} + +/** + * Refreshes the logged_in cookie every interval. By default, the interval is 14 minutes. + * @param interval + * @returns interval id + */ +export function refreshLoggedIn(interval = 840000) { + let loggedIn = Cookies.get('logged_in'); + const id = setInterval(async () => { + const cookie = Cookies.get('logged_in'); + if (cookie != loggedIn) { + loggedIn = cookie; + const response = await refresh(true); + if (!response) { + Cookies.remove('logged_in'); + } + } + }, interval); + return id; +} diff --git a/ui/src/lib/account.types.ts b/ui/src/lib/account.types.ts new file mode 100644 index 0000000..76ac70a --- /dev/null +++ b/ui/src/lib/account.types.ts @@ -0,0 +1,19 @@ +export interface ResponseAuth { + token: string; + user: User; +} + +export interface RegisterUser { + email: string; + password: string; + first_name: string; + last_name: string; +} + +export interface User { + email: string; + role: string; + first_name: string; + last_name: string; + profile_picture?: string; +} diff --git a/ui/src/lib/index.ts b/ui/src/lib/index.ts index 470dbee..6feff05 100644 --- a/ui/src/lib/index.ts +++ b/ui/src/lib/index.ts @@ -1,7 +1,8 @@ -// const serviceHost = process.env.SERVICE_HOST || 'http://localhost'; -// const servicePort = process.env.SERVICE_PORT || 5000;' -// const baseURL = `${serviceHost}:${servicePort}`; -const baseUrl = 'http://localhost:5000'; +// const protocol = process.env.HTTPD_PROTOCOL || 'http'; +// const host = process.env.HTTPD_HOST || 'localhost'; +// const port = process.env.HTTPD_PORT || 8080; +// const baseUrl = `${protocol}://${host}:${port}/api`; +const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8080/api'; export async function getRequest(endpoint: string, params: Record = {}): Promise { Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]); diff --git a/ui/src/main.tsx b/ui/src/main.tsx index 4aefeb4..6e34171 100644 --- a/ui/src/main.tsx +++ b/ui/src/main.tsx @@ -4,21 +4,15 @@ import './index.css'; import App from './App.tsx'; import { createTheme, MantineProvider } from '@mantine/core'; import { Notifications } from '@mantine/notifications'; -import {} from '@mantine/core'; const theme = createTheme({ fontFamily: 'Inter, sans-serif' }); -export const metadata = { - title: 'Aviation', - description: '' -}; - createRoot(document.getElementById('root')!).render( - +