Working on session validation
This commit is contained in:
1
.env
1
.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 #
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<RegisterRequest>, 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<LoginRequest>, 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<LoginRequest>, 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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<i64>) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
@@ -53,16 +56,32 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get(session_id: &str) -> ApiResult<Option<Self>> {
|
||||
pub async fn get(session_id: &str) -> ApiResult<Self> {
|
||||
let mut conn = redis_async_connection().await?;
|
||||
let result: RedisResult<Option<String>> = 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<Self> {
|
||||
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<Self> {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<dyn std::error::Error>> {
|
||||
.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))
|
||||
{
|
||||
|
||||
@@ -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::<u32>() {
|
||||
let observation_time_hour = match observation_time[2..4].parse::<u32>() {
|
||||
Ok(hour) => hour,
|
||||
Err(err) => return Err(err.into()),
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::error::Error;
|
||||
use crate::metars::Metar;
|
||||
use actix_web::{get, web, HttpResponse, HttpRequest};
|
||||
use log::error;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
11
bruno/Users/Validate Session.bru
Normal file
11
bruno/Users/Validate Session.bru
Normal file
@@ -0,0 +1,11 @@
|
||||
meta {
|
||||
name: Validate Session
|
||||
type: http
|
||||
seq: 5
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{API_URL}}/account/session
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
18
ui/package-lock.json
generated
18
ui/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -35,7 +35,6 @@ export default function AirportLayer({ setAirport }: { setAirport: (airport: Air
|
||||
limit: 200
|
||||
})
|
||||
.then((response) => {
|
||||
console.log(response);
|
||||
setAirports(response.data);
|
||||
})
|
||||
.catch((error) => {
|
||||
|
||||
224
ui/src/components/Header/HeaderModal.tsx
Normal file
224
ui/src/components/Header/HeaderModal.tsx
Normal file
@@ -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<boolean>;
|
||||
register: ({
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
password
|
||||
}: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}) => Promise<boolean>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<Modal opened={type !== undefined} onClose={onClose} withCloseButton={false} zIndex={1000}>
|
||||
{type == 'reset' ? (
|
||||
<Container>
|
||||
<Title ta='center'>Reset password</Title>
|
||||
<Text c='dimmed' size='sm' ta='center' mt={5}>
|
||||
Enter your email and we will send you a link to reset your password.{' '}
|
||||
<Anchor size='sm' component='a' onClick={() => toggle('login')}>
|
||||
Go Back
|
||||
</Anchor>
|
||||
</Text>
|
||||
<Paper withBorder shadow='md' p={30} mt={30} radius='md'>
|
||||
<form onSubmit={resetForm.onSubmit(async (values) => console.log(values))}>
|
||||
<TextInput label='Email' placeholder='you@example.com' required {...resetForm.getInputProps('email')} />
|
||||
<Button type='submit' fullWidth mt='xl'>
|
||||
Reset password
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
) : type == 'register' ? (
|
||||
<Container>
|
||||
<Title ta='center'>Create account</Title>
|
||||
<Text c='dimmed' size='sm' ta='center' mt={5}>
|
||||
Already have an account?{' '}
|
||||
<Anchor size='sm' component='a' onClick={() => toggle('login')}>
|
||||
Sign in
|
||||
</Anchor>
|
||||
</Text>
|
||||
|
||||
<Paper withBorder shadow='md' p={30} mt={30} radius='md'>
|
||||
<form
|
||||
onSubmit={registerForm.onSubmit(async (values) => {
|
||||
const success = await register(values);
|
||||
if (success) {
|
||||
onClose();
|
||||
}
|
||||
})}
|
||||
>
|
||||
<TextInput label='First name' placeholder='John' required {...registerForm.getInputProps('firstName')} />
|
||||
<TextInput
|
||||
label='Last name'
|
||||
placeholder='Smith'
|
||||
required
|
||||
mt='md'
|
||||
{...registerForm.getInputProps('lastName')}
|
||||
/>
|
||||
<TextInput
|
||||
label='Email'
|
||||
placeholder='you@example.com'
|
||||
required
|
||||
{...registerForm.getInputProps('email')}
|
||||
/>
|
||||
<PasswordInput
|
||||
label='Password'
|
||||
description='Passwords must be at least 10 characters long, contain at least one number, one uppercase letter, one lowercase letter, and one special character.'
|
||||
placeholder='Your password'
|
||||
required
|
||||
mt='md'
|
||||
{...registerForm.getInputProps('password')}
|
||||
/>
|
||||
<Button type='submit' fullWidth mt='xl'>
|
||||
Sign up
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
) : (
|
||||
<Container>
|
||||
<Title ta='center'>Welcome back!</Title>
|
||||
<Text c='dimmed' size='sm' ta='center' mt={5}>
|
||||
Do not have an account yet?{' '}
|
||||
<Anchor size='sm' component='a' onClick={() => toggle('register')}>
|
||||
Create account
|
||||
</Anchor>
|
||||
</Text>
|
||||
|
||||
<Paper withBorder shadow='md' p={30} mt={30} radius='md'>
|
||||
<form
|
||||
onSubmit={loginForm.onSubmit(async (values) => {
|
||||
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();
|
||||
}
|
||||
})}
|
||||
>
|
||||
<TextInput label='Email' placeholder='you@example.com' required {...loginForm.getInputProps('email')} />
|
||||
<PasswordInput
|
||||
label='Password'
|
||||
placeholder='Your password'
|
||||
required
|
||||
mt='md'
|
||||
{...loginForm.getInputProps('password')}
|
||||
/>
|
||||
<Group justify='space-between' mt='lg'>
|
||||
<Checkbox
|
||||
label='Remember me'
|
||||
defaultChecked={loginForm.values.remember}
|
||||
{...loginForm.getInputProps('remember')}
|
||||
/>
|
||||
<Anchor component='a' size='sm' onClick={() => toggle('reset')}>
|
||||
Forgot password?
|
||||
</Anchor>
|
||||
</Group>
|
||||
<Button type='submit' fullWidth mt='xl'>
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
</Paper>
|
||||
</Container>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
90
ui/src/components/Header/HeaderUser.tsx
Normal file
90
ui/src/components/Header/HeaderUser.tsx
Normal file
@@ -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<void>;
|
||||
}
|
||||
|
||||
export default function HeaderUser({ user, profilePicture, logout }: HeaderUserProps) {
|
||||
return (
|
||||
<Menu shadow='md' width={200} openDelay={100} closeDelay={400} zIndex={1000}>
|
||||
<Menu.Target>
|
||||
<UnstyledButton>
|
||||
<Group>
|
||||
<Avatar src={profilePicture ? URL.createObjectURL(profilePicture) : undefined} />
|
||||
<div style={{ flex: 1 }}>
|
||||
<Text size='sm' fw={500}>
|
||||
{user.first_name} {user.last_name}
|
||||
</Text>
|
||||
<Text c='dimmed' size='xs' style={{ textTransform: 'uppercase' }}>
|
||||
{user.role}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown p={0}>
|
||||
<Card>
|
||||
<Card.Section h={140} style={{ backgroundColor: '#4481e3' }} />
|
||||
<FileButton
|
||||
onChange={(payload) => {
|
||||
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) => (
|
||||
<Avatar
|
||||
{...props}
|
||||
component='button'
|
||||
size={80}
|
||||
radius={80}
|
||||
mx={'auto'}
|
||||
mt={-30}
|
||||
style={{ cursor: 'pointer' }}
|
||||
bg={profilePicture ? 'transparent' : 'white'}
|
||||
src={profilePicture ? URL.createObjectURL(profilePicture) : undefined}
|
||||
/>
|
||||
)}
|
||||
</FileButton>
|
||||
<Text ta='center' fz='lg' fw={500} mt='sm'>
|
||||
{user.first_name} {user.last_name}
|
||||
</Text>
|
||||
<Text ta='center' fz='sm' c='dimmed' style={{ textTransform: 'uppercase' }}>
|
||||
{user.role}
|
||||
</Text>
|
||||
<Grid mt='xl'>
|
||||
<Grid.Col span={6}>
|
||||
<Button fullWidth radius='md' size='xs' variant='default'>
|
||||
Profile
|
||||
</Button>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={6}>
|
||||
<Button fullWidth radius='md' size='xs' variant='default' onClick={logout}>
|
||||
Logout
|
||||
</Button>
|
||||
</Grid.Col>
|
||||
{user.role == 'admin' && (
|
||||
<Grid.Col span={12}>
|
||||
<Button fullWidth radius='md' size='xs' variant='default'>
|
||||
Administration
|
||||
</Button>
|
||||
</Grid.Col>
|
||||
)}
|
||||
</Grid>
|
||||
</Card>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
@@ -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<User | undefined>(undefined);
|
||||
// const [active, setActive] = useState(links[0].link);
|
||||
|
||||
const navItems = links.map((link) => (
|
||||
<a
|
||||
key={link.label}
|
||||
href={link.link}
|
||||
className={classes.link}
|
||||
data-active={active === link.link || undefined}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
setActive(link.link);
|
||||
}}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
));
|
||||
// const navItems = links.map((link) => (
|
||||
// <a
|
||||
// key={link.label}
|
||||
// href={link.link}
|
||||
// className={classes.link}
|
||||
// data-active={active === link.link || undefined}
|
||||
// onClick={(event) => {
|
||||
// event.preventDefault();
|
||||
// setActive(link.link);
|
||||
// }}
|
||||
// >
|
||||
// {link.label}
|
||||
// </a>
|
||||
// ));
|
||||
|
||||
async function loginUser({ email, password }: { email: string; password: string }): Promise<boolean> {
|
||||
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<void> {
|
||||
await logout();
|
||||
Cookies.remove('logged_in');
|
||||
setUser(undefined);
|
||||
}
|
||||
|
||||
async function registerUser({
|
||||
firstName,
|
||||
lastName,
|
||||
email,
|
||||
password
|
||||
}: {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
}): Promise<boolean> {
|
||||
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 (
|
||||
<>
|
||||
<Box>
|
||||
<header className={classes.header}>
|
||||
<Group justify='space-between' h='100%'>
|
||||
<Group align='center' gap='xs'>
|
||||
<Burger opened={opened} onClick={toggle} hiddenFrom='xs' size='sm' />
|
||||
<Avatar src='/logo.svg' alt='logo' />
|
||||
<Text>Aviation</Text>
|
||||
</Group>
|
||||
<Group gap={5} visibleFrom='xs' className={classes.navGroup}>
|
||||
{navItems}
|
||||
<Text>FlightLink</Text>
|
||||
</Group>
|
||||
{/*<Group gap={5} visibleFrom='xs' className={classes.navGroup}>*/}
|
||||
{/* {navItems}*/}
|
||||
{/*</Group>*/}
|
||||
<Group align='center' gap='xs'>
|
||||
{isSignedIn ? (
|
||||
// Clickable avatar if signed in
|
||||
<Avatar
|
||||
src='/user-avatar.jpg' // replace with dynamic source when available
|
||||
alt='User avatar'
|
||||
style={{ cursor: 'pointer' }}
|
||||
// Add click handler for user dropdown if needed
|
||||
/>
|
||||
{user ? (
|
||||
<HeaderUser user={user} profilePicture={undefined} logout={logoutUser} />
|
||||
) : (
|
||||
<>
|
||||
<Button variant='default'>Login</Button>
|
||||
<Button>Signup</Button>
|
||||
</>
|
||||
<Group className={'user'}>
|
||||
<Button variant='default' onClick={() => modalToggle('login')}>
|
||||
Login
|
||||
</Button>
|
||||
<Button onClick={() => modalToggle('register')}>Signup</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
</header>
|
||||
</Box>
|
||||
<HeaderModal type={modalType} toggle={modalToggle} login={loginUser} register={registerUser} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
63
ui/src/lib/account.ts
Normal file
63
ui/src/lib/account.ts
Normal file
@@ -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<User | undefined> {
|
||||
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<boolean> {
|
||||
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<ResponseAuth | undefined> {
|
||||
const response = await getRequest('account/refresh', { refresh_token_rotation });
|
||||
if (response?.status === 200) {
|
||||
return response.json();
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function me(): Promise<ResponseAuth | undefined> {
|
||||
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;
|
||||
}
|
||||
19
ui/src/lib/account.types.ts
Normal file
19
ui/src/lib/account.types.ts
Normal file
@@ -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;
|
||||
}
|
||||
@@ -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<string, any> = {}): Promise<Response> {
|
||||
Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]);
|
||||
|
||||
@@ -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(
|
||||
<StrictMode>
|
||||
<MantineProvider theme={theme} defaultColorScheme={'dark'}>
|
||||
<Notifications />
|
||||
<Notifications zIndex={2000} />
|
||||
<App />
|
||||
</MantineProvider>
|
||||
</StrictMode>
|
||||
|
||||
Reference in New Issue
Block a user