Working on auth
This commit is contained in:
@@ -6,6 +6,9 @@ DATABASE_NAME=siren
|
|||||||
DATABASE_HOST=localhost
|
DATABASE_HOST=localhost
|
||||||
DATABASE_PORT=5432
|
DATABASE_PORT=5432
|
||||||
|
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
|
||||||
SERVICE_HOST=localhost
|
SERVICE_HOST=localhost
|
||||||
SERVICE_PORT=5000
|
SERVICE_PORT=5000
|
||||||
DATA_DIR_PATH=
|
DATA_DIR_PATH=
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ actix-web = "4.4.0"
|
|||||||
actix-rt = "2.9.0"
|
actix-rt = "2.9.0"
|
||||||
actix-cors = "0.6.4"
|
actix-cors = "0.6.4"
|
||||||
actix-web-httpauth = "0.8.1"
|
actix-web-httpauth = "0.8.1"
|
||||||
|
actix-identity = "0.6.0"
|
||||||
|
actix-session = { version = "0.8.0", features = ["redis-actor-session", "cookie-session"] }
|
||||||
chrono = { version = "0.4.31", features = ["serde"] }
|
chrono = { version = "0.4.31", features = ["serde"] }
|
||||||
dotenv = "0.15.0"
|
dotenv = "0.15.0"
|
||||||
serde_json = "1.0.107"
|
serde_json = "1.0.107"
|
||||||
@@ -25,6 +27,7 @@ diesel_migrations = { version = "2.1.0", features = ["postgres"] }
|
|||||||
r2d2 = "0.8.10"
|
r2d2 = "0.8.10"
|
||||||
lazy_static = "1.4.0"
|
lazy_static = "1.4.0"
|
||||||
uuid = { version = "1.4.1", features = ["serde", "v4"] }
|
uuid = { version = "1.4.1", features = ["serde", "v4"] }
|
||||||
|
argon2 = "0.5.2"
|
||||||
|
|
||||||
[dependencies.tokio]
|
[dependencies.tokio]
|
||||||
version = "1.32.0"
|
version = "1.32.0"
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ build: ## Build the docker image
|
|||||||
|
|
||||||
utils: ## Start the utils
|
utils: ## Start the utils
|
||||||
docker compose up -d db
|
docker compose up -d db
|
||||||
|
docker compose up -d redis
|
||||||
|
|
||||||
up: ## Start the app
|
up: ## Start the app
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
DATABASE_HOST: db
|
DATABASE_HOST: db
|
||||||
DATABASE_PORT: 5432
|
DATABASE_PORT: 5432
|
||||||
|
REDIS_HOST: redis
|
||||||
|
REDIS_PORT: 6379
|
||||||
SERVICE_HOST: service
|
SERVICE_HOST: service
|
||||||
SERVICE_PORT: 5000
|
SERVICE_PORT: 5000
|
||||||
DATA_DIR_PATH: /data
|
DATA_DIR_PATH: /data
|
||||||
@@ -45,6 +47,14 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- backend
|
- backend
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
redis:
|
||||||
|
image: redis:latest
|
||||||
|
container_name: siren-redis
|
||||||
|
ports:
|
||||||
|
- ${REDIS_PORT:-6379}:6379
|
||||||
|
networks:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
db:
|
db:
|
||||||
|
|||||||
1
service/migrations/000011_create_users/down.sql
Normal file
1
service/migrations/000011_create_users/down.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
DROP TABLE users;
|
||||||
7
service/migrations/000011_create_users/up.sql
Normal file
7
service/migrations/000011_create_users/up.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
email TEXT PRIMARY KEY NOT NULL,
|
||||||
|
hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL,
|
||||||
|
first_name TEXT NOT NULL,
|
||||||
|
last_name TEXT NOT NULL
|
||||||
|
);
|
||||||
@@ -37,4 +37,14 @@ diesel::table! {
|
|||||||
bot_id -> BigInt,
|
bot_id -> BigInt,
|
||||||
volume -> Integer,
|
volume -> Integer,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diesel::table! {
|
||||||
|
users (email) {
|
||||||
|
email -> Text,
|
||||||
|
hash -> Text,
|
||||||
|
role -> Text,
|
||||||
|
first_name -> Text,
|
||||||
|
last_name -> Text,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
mod model;
|
mod model;
|
||||||
|
mod routes;
|
||||||
|
|
||||||
pub use model::*;
|
pub use model::*;
|
||||||
|
pub use routes::init_routes;
|
||||||
|
|||||||
@@ -1,37 +1,140 @@
|
|||||||
use actix_web::{dev::ServiceRequest, Error};
|
use std::future::{ready, Ready};
|
||||||
use actix_web_httpauth::extractors::bearer::BearerAuth;
|
use actix_identity::Identity;
|
||||||
|
use actix_web::{FromRequest, Error as ActixError, HttpRequest, dev::Payload};
|
||||||
|
use argon2::{password_hash::{rand_core::OsRng, PasswordHasher, PasswordVerifier, SaltString, Error as HashError}, Argon2, PasswordHash};
|
||||||
|
use diesel::prelude::*;
|
||||||
|
use serde::{Serialize, Deserialize};
|
||||||
use siren::ServiceError;
|
use siren::ServiceError;
|
||||||
|
|
||||||
pub struct User {
|
use crate::db::schema::users;
|
||||||
pub id: i32,
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct RegisterUser {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
pub first_name: String,
|
pub first_name: String,
|
||||||
pub last_name: String,
|
pub last_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RegisterUser {
|
||||||
|
pub fn convert_to_insert(self) -> Result<InsertUser, ServiceError> {
|
||||||
|
let hash = hash(self.password.as_bytes())?;
|
||||||
|
Ok(InsertUser {
|
||||||
|
email: self.email,
|
||||||
|
hash,
|
||||||
|
role: "user".to_string(),
|
||||||
|
first_name: self.first_name,
|
||||||
|
last_name: self.last_name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct LoginAuth {
|
||||||
|
pub email: String,
|
||||||
|
pub password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct LoggedUser {
|
||||||
pub email: String
|
pub email: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl FromRequest for LoggedUser {
|
||||||
|
type Error = ActixError;
|
||||||
|
type Future = Ready<Result<LoggedUser, ActixError>>;
|
||||||
|
|
||||||
|
fn from_request(req: &HttpRequest, pl: &mut Payload) -> Self::Future {
|
||||||
|
if let Ok(identity) = Identity::from_request(req, pl).into_inner() {
|
||||||
|
if let Ok(user_json) = identity.id() {
|
||||||
|
if let Ok(user) = serde_json::from_str(&user_json) {
|
||||||
|
return ready(Ok(user));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
std::future::ready(Err(
|
||||||
|
ActixError::from(ServiceError {
|
||||||
|
status: 401,
|
||||||
|
message: "Unauthorized".to_string(),
|
||||||
|
})
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Queryable, QueryableByName, Serialize, Deserialize)]
|
||||||
|
#[diesel(table_name = users)]
|
||||||
|
pub struct QueryUser {
|
||||||
|
pub email: String,
|
||||||
|
pub hash: String,
|
||||||
|
pub role: String,
|
||||||
|
pub first_name: String,
|
||||||
|
pub last_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QueryUser {
|
||||||
|
pub fn get_by_email(email: &str) -> Result<QueryUser, ServiceError> {
|
||||||
|
let mut conn = crate::db::connection()?;
|
||||||
|
let user = users::table
|
||||||
|
.filter(users::email.eq(email))
|
||||||
|
.first(&mut conn)?;
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Insertable, AsChangeset, Serialize, Deserialize)]
|
||||||
|
#[diesel(table_name = users)]
|
||||||
|
pub struct InsertUser {
|
||||||
|
pub email: String,
|
||||||
|
pub hash: String,
|
||||||
|
pub role: String,
|
||||||
|
pub first_name: String,
|
||||||
|
pub last_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InsertUser {
|
||||||
|
pub fn insert(user: Self) -> Result<QueryUser, ServiceError> {
|
||||||
|
let mut conn = crate::db::connection()?;
|
||||||
|
let user = diesel::insert_into(users::table)
|
||||||
|
.values(user)
|
||||||
|
.get_result(&mut conn)?;
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// https://github.com/Sirneij/rust-auth/blob/main/backend/src/routes/users/login.rs
|
// https://github.com/Sirneij/rust-auth/blob/main/backend/src/routes/users/login.rs
|
||||||
// https://dev.to/sirneij/authentication-system-using-rust-actix-web-and-sveltekit-user-registration-580h
|
// https://dev.to/sirneij/authentication-system-using-rust-actix-web-and-sveltekit-user-registration-580h
|
||||||
// https://github.com/actix/actix-extras/blob/master/actix-session/examples/basic.rs
|
// https://github.com/actix/actix-extras/blob/master/actix-session/examples/basic.rs
|
||||||
// maybe https://github.com/actix/actix-extras/blob/master/actix-identity/examples/identity.rs
|
// maybe https://github.com/actix/actix-extras/blob/master/actix-identity/examples/identity.rs
|
||||||
|
// https://www.lpalmieri.com/posts/session-based-authentication-in-rust/#3-3-1-postgres
|
||||||
|
|
||||||
pub async fn validator(req: ServiceRequest, credentials: BearerAuth) -> Result<ServiceRequest, (Error, ServiceRequest)> {
|
// pub async fn validator(req: ServiceRequest, credentials: BearerAuth) -> Result<ServiceRequest, (ActixError, ServiceRequest)> {
|
||||||
let token = credentials.token();
|
// let token = credentials.token();
|
||||||
println!("{:?}", req);
|
// println!("{:?}", req);
|
||||||
match validate_token(token) {
|
// match validate_token(token) {
|
||||||
Ok(res) => {
|
// Ok(res) => {
|
||||||
if res {
|
// if res {
|
||||||
Ok(req)
|
// Ok(req)
|
||||||
} else {
|
// } else {
|
||||||
Err((Error::from(actix_web::error::ErrorUnauthorized("Invalid token")), req))
|
// Err((ActixError::from(actix_web::error::ErrorUnauthorized("Invalid token")), req))
|
||||||
}
|
// }
|
||||||
},
|
// },
|
||||||
Err(err) => {
|
// Err(err) => {
|
||||||
Err((Error::from(actix_web::error::ErrorUnauthorized(err)), req))
|
// Err((ActixError::from(actix_web::error::ErrorUnauthorized(err)), req))
|
||||||
}
|
// }
|
||||||
}
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// fn validate_token(token: &str) -> Result<bool, ServiceError> {
|
||||||
|
// println!("Validating token: {}", token);
|
||||||
|
// Ok(true)
|
||||||
|
// }
|
||||||
|
|
||||||
|
pub fn hash(password: &[u8]) -> Result<String, HashError> {
|
||||||
|
let salt = SaltString::generate(&mut OsRng);
|
||||||
|
Ok(Argon2::default().hash_password(password, &salt)?.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validate_token(token: &str) -> Result<bool, ServiceError> {
|
pub fn verify(hash: &str, password: &[u8]) -> Result<(), HashError> {
|
||||||
println!("Validating token: {}", token);
|
let parsed_hash = PasswordHash::new(hash)?;
|
||||||
Ok(true)
|
Ok(Argon2::default().verify_password(password, &parsed_hash)?)
|
||||||
}
|
}
|
||||||
|
|||||||
75
service/src/db/users/routes.rs
Normal file
75
service/src/db/users/routes.rs
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
use actix_identity::Identity;
|
||||||
|
use actix_web::{get, post, web, HttpResponse, HttpRequest, ResponseError, HttpMessage};
|
||||||
|
use siren::ServiceError;
|
||||||
|
|
||||||
|
use crate::db::users::{LoginAuth, RegisterUser, InsertUser, QueryUser, verify, LoggedUser};
|
||||||
|
|
||||||
|
#[post("/register")]
|
||||||
|
async fn register(user: web::Json<RegisterUser>) -> HttpResponse {
|
||||||
|
let register_user = user.0;
|
||||||
|
let insert_user: InsertUser = match register_user.convert_to_insert() {
|
||||||
|
Ok(user) => user,
|
||||||
|
Err(err) => return ResponseError::error_response(&err)
|
||||||
|
};
|
||||||
|
match InsertUser::insert(insert_user) {
|
||||||
|
Ok(_) => {
|
||||||
|
HttpResponse::Created().finish()
|
||||||
|
},
|
||||||
|
Err(err) => {
|
||||||
|
// Obfuscate the service error message to prevent leaking database details
|
||||||
|
if err.status == 409 {
|
||||||
|
return HttpResponse::Conflict().finish();
|
||||||
|
} else {
|
||||||
|
return ResponseError::error_response(&err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/login")]
|
||||||
|
async fn login(req: HttpRequest, auth: web::Json<LoginAuth>) -> HttpResponse {
|
||||||
|
let email = auth.email.clone();
|
||||||
|
|
||||||
|
match QueryUser::get_by_email(&email) {
|
||||||
|
Ok(query_user) => {
|
||||||
|
let hash = query_user.hash;
|
||||||
|
let password = auth.password.as_bytes();
|
||||||
|
match verify(&hash, password) {
|
||||||
|
Ok(_) => {
|
||||||
|
let user = LoggedUser {
|
||||||
|
email: email.clone()
|
||||||
|
};
|
||||||
|
let user_string = serde_json::to_string(&user).unwrap();
|
||||||
|
match Identity::login(&req.extensions(), user_string) {
|
||||||
|
Ok(_) => HttpResponse::Ok().finish(),
|
||||||
|
Err(err) => return ResponseError::error_response(&err)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(err) => ResponseError::error_response(&ServiceError {
|
||||||
|
status: 401,
|
||||||
|
message: err.to_string()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Err(err) => ResponseError::error_response(&err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/logout")]
|
||||||
|
async fn logout(id: Identity) -> HttpResponse {
|
||||||
|
id.logout();
|
||||||
|
HttpResponse::Ok().finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/me")]
|
||||||
|
async fn me(user: LoggedUser) -> HttpResponse {
|
||||||
|
HttpResponse::Ok().json(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init_routes(config: &mut web::ServiceConfig) {
|
||||||
|
config.service(web::scope("users")
|
||||||
|
.service(register)
|
||||||
|
.service(login)
|
||||||
|
.service(logout)
|
||||||
|
.service(me));
|
||||||
|
}
|
||||||
@@ -57,7 +57,14 @@ impl fmt::Display for ServiceError {
|
|||||||
impl From<DieselError> for ServiceError {
|
impl From<DieselError> for ServiceError {
|
||||||
fn from(error: DieselError) -> ServiceError {
|
fn from(error: DieselError) -> ServiceError {
|
||||||
match error {
|
match error {
|
||||||
DieselError::DatabaseError(_, err) => ServiceError::new(409, err.message().to_string()),
|
DieselError::DatabaseError(kind, err) => {
|
||||||
|
match kind {
|
||||||
|
diesel::result::DatabaseErrorKind::UniqueViolation => {
|
||||||
|
ServiceError::new(409, err.message().to_string())
|
||||||
|
},
|
||||||
|
_ => ServiceError::new(500, err.message().to_string())
|
||||||
|
}
|
||||||
|
},
|
||||||
DieselError::NotFound => {
|
DieselError::NotFound => {
|
||||||
ServiceError::new(404, "The record was not found".to_string())
|
ServiceError::new(404, "The record was not found".to_string())
|
||||||
},
|
},
|
||||||
@@ -87,6 +94,12 @@ impl From<serenity::Error> for ServiceError {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl From<argon2::password_hash::Error> for ServiceError {
|
||||||
|
fn from(error: argon2::password_hash::Error) -> ServiceError {
|
||||||
|
ServiceError::new(500, format!("Unknown argon2 error: {}", error))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl ResponseError for ServiceError {
|
impl ResponseError for ServiceError {
|
||||||
fn error_response(&self) -> HttpResponse {
|
fn error_response(&self) -> HttpResponse {
|
||||||
let status_code = match StatusCode::from_u16(self.status) {
|
let status_code = match StatusCode::from_u16(self.status) {
|
||||||
@@ -101,4 +114,4 @@ impl ResponseError for ServiceError {
|
|||||||
|
|
||||||
HttpResponse::build(status_code).json(serde_json::json!({ "status": status_code.as_u16(), "message": error_message }))
|
HttpResponse::build(status_code).json(serde_json::json!({ "status": status_code.as_u16(), "message": error_message }))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@ extern crate diesel_migrations;
|
|||||||
use std::env;
|
use std::env;
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use actix_web_httpauth::middleware::HttpAuthentication;
|
use actix_identity::IdentityMiddleware;
|
||||||
use db::users::validator;
|
use actix_session::{SessionMiddleware, storage::{RedisActorSessionStore, CookieSessionStore}, config::{PersistentSession, BrowserSession, CookieContentSecurity}};
|
||||||
|
// use db::users::validator;
|
||||||
use log::{error, warn, info};
|
use log::{error, warn, info};
|
||||||
use serenity::client::Cache;
|
use serenity::client::Cache;
|
||||||
use serenity::framework::StandardFramework;
|
use serenity::framework::StandardFramework;
|
||||||
@@ -15,7 +16,7 @@ use serenity::prelude::*;
|
|||||||
use songbird::{SerenityInit, Songbird};
|
use songbird::{SerenityInit, Songbird};
|
||||||
|
|
||||||
use actix_cors::Cors;
|
use actix_cors::Cors;
|
||||||
use actix_web::{HttpServer, App, web};
|
use actix_web::{HttpServer, App, web, cookie::{time::Duration, SameSite}};
|
||||||
use crate::bot::{commands::oai::GPTModel, handler::Handler};
|
use crate::bot::{commands::oai::GPTModel, handler::Handler};
|
||||||
|
|
||||||
use dotenv::dotenv;
|
use dotenv::dotenv;
|
||||||
@@ -113,18 +114,39 @@ async fn main() -> std::io::Result<()> {
|
|||||||
let port = env::var("SERVICE_PORT").unwrap_or("5000".to_string());
|
let port = env::var("SERVICE_PORT").unwrap_or("5000".to_string());
|
||||||
|
|
||||||
let server = match HttpServer::new(move || {
|
let server = match HttpServer::new(move || {
|
||||||
let auth = HttpAuthentication::bearer(validator);
|
// let auth = HttpAuthentication::bearer(validator);
|
||||||
|
let private_key = actix_web::cookie::Key::generate();
|
||||||
|
// let redis_host = env::var("REDIS_HOST").unwrap_or("localhost".to_string());
|
||||||
|
// let redis_port = env::var("REDIS_PORT").unwrap_or("6379".to_string());
|
||||||
|
let session = SessionMiddleware::builder(
|
||||||
|
// RedisActorSessionStore::new(format!("{}:{}", redis_host, redis_port)),
|
||||||
|
CookieSessionStore::default(),
|
||||||
|
private_key
|
||||||
|
)
|
||||||
|
.session_lifecycle(BrowserSession::default())
|
||||||
|
.cookie_name("auth".to_owned())
|
||||||
|
.cookie_secure(false)
|
||||||
|
.cookie_http_only(false)
|
||||||
|
// .cookie_content_security(CookieContentSecurity::Private)
|
||||||
|
.cookie_domain(Some("localhost".to_owned()))
|
||||||
|
.cookie_path("/".to_owned())
|
||||||
|
.build();
|
||||||
let cors = Cors::default()
|
let cors = Cors::default()
|
||||||
.allow_any_origin()
|
.allow_any_origin()
|
||||||
.allow_any_method()
|
.allow_any_method()
|
||||||
.allow_any_header()
|
.allow_any_header()
|
||||||
|
.supports_credentials()
|
||||||
.max_age(3600);
|
.max_age(3600);
|
||||||
|
// let cors = Cors::permissive();
|
||||||
App::new()
|
App::new()
|
||||||
.wrap(auth)
|
// .wrap(auth)
|
||||||
|
.wrap(IdentityMiddleware::default())
|
||||||
|
.wrap(session)
|
||||||
.wrap(cors)
|
.wrap(cors)
|
||||||
.app_data(web::Data::new(Arc::clone(&app_data)))
|
.app_data(web::Data::new(Arc::clone(&app_data)))
|
||||||
.configure(crate::db::messages::init_routes)
|
.configure(crate::db::messages::init_routes)
|
||||||
.configure(crate::db::spells::init_routes)
|
.configure(crate::db::spells::init_routes)
|
||||||
|
.configure(crate::db::users::init_routes)
|
||||||
.configure(crate::bot::api::init_routes)
|
.configure(crate::bot::api::init_routes)
|
||||||
})
|
})
|
||||||
.bind(format!("{}:{}", host, port)) {
|
.bind(format!("{}:{}", host, port)) {
|
||||||
|
|||||||
16
ui/package-lock.json
generated
16
ui/package-lock.json
generated
@@ -14,6 +14,7 @@
|
|||||||
"@mantine/modals": "^7.1.2",
|
"@mantine/modals": "^7.1.2",
|
||||||
"@mantine/notifications": "^7.1.2",
|
"@mantine/notifications": "^7.1.2",
|
||||||
"axios": "^1.5.1",
|
"axios": "^1.5.1",
|
||||||
|
"js-cookie": "^3.0.5",
|
||||||
"next": "^13.5.4",
|
"next": "^13.5.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
@@ -23,6 +24,7 @@
|
|||||||
"recoil": "^0.7.7"
|
"recoil": "^0.7.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/js-cookie": "^3.0.4",
|
||||||
"@types/node": "20.8.2",
|
"@types/node": "20.8.2",
|
||||||
"@types/react": "18.2.24",
|
"@types/react": "18.2.24",
|
||||||
"@types/react-dom": "18.2.8",
|
"@types/react-dom": "18.2.8",
|
||||||
@@ -586,6 +588,12 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.0.tgz",
|
||||||
"integrity": "sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g=="
|
"integrity": "sha512-HNB/9GHqu7Fo8AQiugyJbv6ZxYz58wef0esl4Mv828w1ZKpAshw/uFWVDUcIB9KKFeFKoxS3cHY07FFgtTRZ1g=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/js-cookie": {
|
||||||
|
"version": "3.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.4.tgz",
|
||||||
|
"integrity": "sha512-vMMnFF+H5KYqdd/myCzq6wLDlPpteJK+jGFgBus3Da7lw+YsDmx2C8feGTzY2M3Fo823yON+HC2CL240j4OV+w==",
|
||||||
|
"dev": true
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.13",
|
"version": "7.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.13.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.13.tgz",
|
||||||
@@ -3294,6 +3302,14 @@
|
|||||||
"reflect.getprototypeof": "^1.0.3"
|
"reflect.getprototypeof": "^1.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"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==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"@mantine/modals": "^7.1.2",
|
"@mantine/modals": "^7.1.2",
|
||||||
"@mantine/notifications": "^7.1.2",
|
"@mantine/notifications": "^7.1.2",
|
||||||
"axios": "^1.5.1",
|
"axios": "^1.5.1",
|
||||||
|
"js-cookie": "^3.0.5",
|
||||||
"next": "^13.5.4",
|
"next": "^13.5.4",
|
||||||
"react": "^18.2.0",
|
"react": "^18.2.0",
|
||||||
"react-dom": "^18.2.0",
|
"react-dom": "^18.2.0",
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
"recoil": "^0.7.7"
|
"recoil": "^0.7.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/js-cookie": "^3.0.4",
|
||||||
"@types/node": "20.8.2",
|
"@types/node": "20.8.2",
|
||||||
"@types/react": "18.2.24",
|
"@types/react": "18.2.24",
|
||||||
"@types/react-dom": "18.2.8",
|
"@types/react-dom": "18.2.8",
|
||||||
|
|||||||
@@ -1,18 +1,25 @@
|
|||||||
import axios, { AxiosResponse } from 'axios';
|
import axios, { AxiosRequestConfig, AxiosResponse } from 'axios';
|
||||||
|
|
||||||
const serviceHost = process.env.SERVICE_HOST || 'http://localhost';
|
const serviceHost = process.env.SERVICE_HOST || 'http://localhost';
|
||||||
const servicePort = process.env.SERVICE_PORT || 5000;
|
const servicePort = process.env.SERVICE_PORT || 5000;
|
||||||
|
|
||||||
export async function getRequest(endpoint: string, params: any): Promise<AxiosResponse<any, any> | undefined> {
|
export async function getRequest(
|
||||||
|
url: string,
|
||||||
|
params: AxiosRequestConfig<any>
|
||||||
|
): Promise<AxiosResponse<any, any> | undefined> {
|
||||||
const response = await axios
|
const response = await axios
|
||||||
.get(`${serviceHost}:${servicePort}/${endpoint}`, { params })
|
.get(`${serviceHost}:${servicePort}/${url}`, { params })
|
||||||
.catch((error) => console.error(error));
|
.catch((error) => console.error(error));
|
||||||
return response || undefined;
|
return response || undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function postRequest(endpoint: string, body: any): Promise<AxiosResponse<any, any> | undefined> {
|
export async function postRequest(
|
||||||
|
url: string,
|
||||||
|
data?: any,
|
||||||
|
config?: AxiosRequestConfig<any>
|
||||||
|
): Promise<AxiosResponse<any, any> | undefined> {
|
||||||
const response = await axios
|
const response = await axios
|
||||||
.post(`${serviceHost}:${servicePort}/${endpoint}`, body || {})
|
.post(`${serviceHost}:${servicePort}/${url}`, data, config)
|
||||||
.catch((error) => console.error(error));
|
.catch((error) => console.error(error));
|
||||||
return response || undefined;
|
return response || undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
9
ui/src/api/users.ts
Normal file
9
ui/src/api/users.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { postRequest } from '.';
|
||||||
|
|
||||||
|
export async function login(email: string, password: string) {
|
||||||
|
return await postRequest('users/login', { email, password }, { withCredentials: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout() {
|
||||||
|
return await postRequest('users/logout', {}, { withCredentials: true });
|
||||||
|
}
|
||||||
@@ -3,6 +3,25 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import './topbar.css';
|
import './topbar.css';
|
||||||
|
import {
|
||||||
|
Anchor,
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
Checkbox,
|
||||||
|
Container,
|
||||||
|
Group,
|
||||||
|
Menu,
|
||||||
|
Modal,
|
||||||
|
Paper,
|
||||||
|
PasswordInput,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Title
|
||||||
|
} from '@mantine/core';
|
||||||
|
import Cookies from 'js-cookie';
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useForm } from '@mantine/form';
|
||||||
|
import { login, logout } from '@/api/users';
|
||||||
|
|
||||||
const headerItems = [
|
const headerItems = [
|
||||||
{
|
{
|
||||||
@@ -41,21 +60,118 @@ const headerItems = [
|
|||||||
|
|
||||||
export default function Topbar() {
|
export default function Topbar() {
|
||||||
const pathName = usePathname();
|
const pathName = usePathname();
|
||||||
|
const [showLogin, setShowLogin] = useState(false);
|
||||||
|
const [authenticated, setAuthenticated] = useState(false);
|
||||||
|
// Check if the auth cookie is set
|
||||||
|
// If it is, show the user avatar
|
||||||
|
// If not, show the login button
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('cookies', Cookies.get());
|
||||||
|
if (Cookies.get('auth')) {
|
||||||
|
setAuthenticated(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className='navbar'>
|
<>
|
||||||
<div className='left'>
|
<nav className='navbar'>
|
||||||
<Link href={'/'} className='title'>
|
<div className='left'>
|
||||||
Siren
|
<Link href={'/'} className='title'>
|
||||||
</Link>
|
Siren
|
||||||
<div className='header-items'>
|
</Link>
|
||||||
{headerItems.map((item) => (
|
<div className='header-items'>
|
||||||
<Link className={`header-item ${pathName == item.link && 'active'}`} href={item.link} key={item.name}>
|
{headerItems.map((item) => (
|
||||||
{item.name}
|
<Link className={`header-item ${pathName == item.link && 'active'}`} href={item.link} key={item.name}>
|
||||||
</Link>
|
{item.name}
|
||||||
))}
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className='user'>
|
||||||
</nav>
|
<Menu shadow='md' width={200} trigger='hover' openDelay={100} closeDelay={400}>
|
||||||
|
<Menu.Target>
|
||||||
|
<Avatar style={{ cursor: 'pointer' }} />
|
||||||
|
</Menu.Target>
|
||||||
|
<Menu.Dropdown>
|
||||||
|
{!authenticated && <Menu.Item onClick={() => setShowLogin(true)}>Login</Menu.Item>}
|
||||||
|
{authenticated && (
|
||||||
|
<Menu.Item
|
||||||
|
onClick={async () => {
|
||||||
|
const response = await logout();
|
||||||
|
if (response?.status == 200) {
|
||||||
|
Cookies.remove('auth');
|
||||||
|
setAuthenticated(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</Menu.Item>
|
||||||
|
)}
|
||||||
|
</Menu.Dropdown>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
<LoginModal showLogin={showLogin} setShowLogin={setShowLogin} setAuthenticated={setAuthenticated} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginModal({
|
||||||
|
showLogin,
|
||||||
|
setShowLogin,
|
||||||
|
setAuthenticated
|
||||||
|
}: {
|
||||||
|
showLogin: boolean;
|
||||||
|
setShowLogin: (show: boolean) => void;
|
||||||
|
setAuthenticated: (authenticated: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
email: '',
|
||||||
|
password: ''
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Modal opened={showLogin} onClose={() => setShowLogin(false)} withCloseButton={false}>
|
||||||
|
<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='button'>
|
||||||
|
Create account
|
||||||
|
</Anchor>
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Paper withBorder shadow='md' p={30} mt={30} radius='md'>
|
||||||
|
<form
|
||||||
|
onSubmit={form.onSubmit(async (values) => {
|
||||||
|
const response = await login(values.email, values.password);
|
||||||
|
if (response?.status == 200) {
|
||||||
|
setShowLogin(false);
|
||||||
|
setAuthenticated(true);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<TextInput label='Email' placeholder='you@example.com' required {...form.getInputProps('email')} />
|
||||||
|
<PasswordInput
|
||||||
|
label='Password'
|
||||||
|
placeholder='Your password'
|
||||||
|
required
|
||||||
|
mt='md'
|
||||||
|
{...form.getInputProps('password')}
|
||||||
|
/>
|
||||||
|
<Group justify='space-between' mt='lg'>
|
||||||
|
<Checkbox label='Remember me' />
|
||||||
|
<Anchor component='button' size='sm'>
|
||||||
|
Forgot password?
|
||||||
|
</Anchor>
|
||||||
|
</Group>
|
||||||
|
<Button type='submit' fullWidth mt='xl'>
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
</Container>
|
||||||
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,11 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navbar .user {
|
||||||
|
padding-left: 1em;
|
||||||
|
padding-right: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
.navbar .title {
|
.navbar .title {
|
||||||
padding-left: 2em;
|
padding-left: 2em;
|
||||||
padding-right: 2em;
|
padding-right: 2em;
|
||||||
@@ -44,4 +49,4 @@
|
|||||||
|
|
||||||
.header-items .active {
|
.header-items .active {
|
||||||
border-bottom: 2px solid #5f5f5f;
|
border-bottom: 2px solid #5f5f5f;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user