Working on auth

This commit is contained in:
Benjamin Sherriff
2023-10-17 20:49:27 -04:00
parent 140488c925
commit 3b15f520c8
18 changed files with 454 additions and 49 deletions

View File

@@ -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=

View File

@@ -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"

View File

@@ -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

View File

@@ -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:

View File

@@ -0,0 +1 @@
DROP TABLE users;

View 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
);

View File

@@ -38,3 +38,13 @@ diesel::table! {
volume -> Integer, volume -> Integer,
} }
} }
diesel::table! {
users (email) {
email -> Text,
hash -> Text,
role -> Text,
first_name -> Text,
last_name -> Text,
}
}

View File

@@ -1,3 +1,5 @@
mod model; mod model;
mod routes;
pub use model::*; pub use model::*;
pub use routes::init_routes;

View File

@@ -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)?)
} }

View 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));
}

View File

@@ -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) {

View File

@@ -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
View File

@@ -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",

View File

@@ -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",

View File

@@ -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
View 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 });
}

View File

@@ -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>
); );
} }

View File

@@ -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;