Header and login

This commit is contained in:
2023-11-18 08:45:52 -05:00
parent e2bd270d7c
commit 319f64bc16
30 changed files with 1434 additions and 776 deletions

View File

@@ -19,3 +19,5 @@ ACCESS_TOKEN_MAXAGE=5
REFRESH_TOKEN_PRIVATE_KEY=
REFRESH_TOKEN_PUBLIC_KEY=
REFRESH_TOKEN_MAXAGE=30
GOV_API_URL=https://aviationweather.gov/cgi-bin/data

9
service/Cargo.lock generated
View File

@@ -1139,9 +1139,9 @@ checksum = "89d92a4743f9a61002fae18374ed11e7973f530cb3a3255fb354818118b2203c"
[[package]]
name = "linux-raw-sys"
version = "0.4.7"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a9bad9f94746442c783ca431b22403b519cd7fbeed0533fdd6328b2f2212128"
checksum = "969488b55f8ac402214f3f5fd243ebb7206cf82de60d3172994707a4bcc2b829"
[[package]]
name = "local-channel"
@@ -1665,9 +1665,9 @@ dependencies = [
[[package]]
name = "rustix"
version = "0.38.13"
version = "0.38.24"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7db8590df6dfcd144d22afd1b83b36c21a18d7cbc1dc4bb5295a8712e9eb662"
checksum = "9ad981d6c340a49cdc40a1028d9c6084ec7e9fa33fcb839cab656a267071e234"
dependencies = [
"bitflags 2.4.0",
"errno",
@@ -1819,6 +1819,7 @@ dependencies = [
"r2d2",
"redis",
"reqwest",
"rustix",
"serde",
"serde_json",
"tokio",

View File

@@ -33,3 +33,4 @@ argon2 = "0.5.2"
jsonwebtoken = "9.0.0"
redis = { version = "0.23.3", features = ["tokio-comp", "connection-manager", "r2d2"] }
base64 = "0.21.4"
rustix = "0.38.19" # https://github.com/imsnif/bandwhich/issues/284

View File

@@ -7,5 +7,6 @@ CREATE TABLE IF NOT EXISTS users (
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
profile_picture TEXT,
favorites TEXT[] NOT NULL DEFAULT '{}',
verified BOOLEAN NOT NULL DEFAULT FALSE
);

View File

@@ -1,5 +1,5 @@
use crate::{airports::{InsertAirport, QueryAirport}, db::{self, Metadata}};
use actix_web::{delete, get, post, put, web, HttpResponse, HttpRequest};
use crate::{airports::{InsertAirport, QueryAirport}, db::{self, Metadata}, auth::{JwtAuth, verify_role}};
use actix_web::{delete, get, post, put, web, HttpResponse, HttpRequest, ResponseError};
use log::{error, warn};
use postgis_diesel::types::{Polygon, Point};
use serde::{Serialize, Deserialize};
@@ -14,7 +14,11 @@ struct GetAllParameters {
}
#[get("/import")]
async fn import() -> HttpResponse {
async fn import(auth: JwtAuth) -> HttpResponse {
let _ = match verify_role(&auth, "admin") {
Ok(_) => {},
Err(err) => return ResponseError::error_response(&err)
};
db::import_data();
HttpResponse::Ok().body({})
}
@@ -129,7 +133,11 @@ async fn get(icao: web::Path<String>) -> HttpResponse {
}
#[post("/airports")]
async fn create(airport: web::Json<InsertAirport>) -> HttpResponse {
async fn create(airport: web::Json<InsertAirport>, auth: JwtAuth) -> HttpResponse {
let _ = match verify_role(&auth, "admin") {
Ok(_) => {},
Err(err) => return ResponseError::error_response(&err)
};
match QueryAirport::create(airport.into_inner()) {
Ok(a) => HttpResponse::Created().json(a),
Err(err) => {
@@ -140,7 +148,11 @@ async fn create(airport: web::Json<InsertAirport>) -> HttpResponse {
}
#[put("/airports/{icao}")]
async fn update(icao: web::Path<i32>, airport: web::Json<InsertAirport>) -> HttpResponse {
async fn update(icao: web::Path<i32>, airport: web::Json<InsertAirport>, auth: JwtAuth) -> HttpResponse {
let _ = match verify_role(&auth, "admin") {
Ok(_) => {},
Err(err) => return ResponseError::error_response(&err)
};
match QueryAirport::update(icao.into_inner(), airport.into_inner()) {
Ok(a) => HttpResponse::Ok().json(a),
Err(err) => {
@@ -151,7 +163,11 @@ async fn update(icao: web::Path<i32>, airport: web::Json<InsertAirport>) -> Http
}
#[delete("/airports/{icao}")]
async fn delete(icao: web::Path<i32>) -> HttpResponse {
async fn delete(icao: web::Path<i32>, auth: JwtAuth) -> HttpResponse {
let _ = match verify_role(&auth, "admin") {
Ok(_) => {},
Err(err) => return ResponseError::error_response(&err)
};
match QueryAirport::delete(icao.into_inner()) {
Ok(_) => HttpResponse::NoContent().finish(),
Err(err) => {

View File

@@ -30,6 +30,7 @@ impl RegisterUser {
updated_at: chrono::Utc::now().naive_utc(),
created_at: chrono::Utc::now().naive_utc(),
profile_picture: None,
favorites: vec![],
verified: false,
})
}
@@ -52,6 +53,7 @@ pub struct QueryUser {
pub updated_at: chrono::NaiveDateTime,
pub created_at: chrono::NaiveDateTime,
pub profile_picture: Option<String>,
pub favorites: Vec<String>,
pub verified: bool,
}
@@ -78,6 +80,7 @@ pub struct InsertUser {
pub updated_at: chrono::NaiveDateTime,
pub created_at: chrono::NaiveDateTime,
pub profile_picture: Option<String>,
pub favorites: Vec<String>,
pub verified: bool,
}
@@ -90,7 +93,7 @@ impl InsertUser {
Ok(user)
}
pub fn update_profile(email: &str, profile_picture: Option<&str>) -> Result<QueryUser, ServiceError> {
pub fn update_profile_picture(email: &str, profile_picture: Option<&str>) -> Result<QueryUser, ServiceError> {
let mut conn = connection()?;
let user = diesel::update(users::table)
.filter(users::email.eq(&email))
@@ -98,6 +101,15 @@ impl InsertUser {
.get_result(&mut conn)?;
Ok(user)
}
pub fn update_favorites(email: &str, favorites: Vec<String>) -> Result<QueryUser, ServiceError> {
let mut conn = connection()?;
let user = diesel::update(users::table)
.filter(users::email.eq(&email))
.set(users::favorites.eq(favorites))
.get_result(&mut conn)?;
Ok(user)
}
}
#[derive(Debug, Serialize, Deserialize)]

View File

@@ -4,7 +4,7 @@ use redis::{Client as RedisClient, aio::Connection as RedisConnection};
use serde::{Deserialize, Serialize};
use crate::diesel_migrations::MigrationHarness;
use lazy_static::lazy_static;
use log::{error, debug, info, warn};
use log::{error, debug, info};
use r2d2;
use std::env;

View File

@@ -57,6 +57,7 @@ diesel::table! {
updated_at -> Timestamp,
created_at -> Timestamp,
profile_picture -> Nullable<Text>,
favorites -> Array<Text>,
verified -> Bool,
}
}

View File

@@ -20,7 +20,7 @@ mod scheduler;
#[actix_web::main]
async fn main() -> std::io::Result<()> {
dotenv().ok();
env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", "warn,siren=info"));
env_logger::init_from_env(env_logger::Env::default().filter_or("RUST_LOG", "warn,service=info"));
db::init();
scheduler::update_airports();

View File

@@ -139,7 +139,8 @@ impl Metar {
}
async fn get_remote_metars(icaos: String) -> Vec<Metar> {
let url = format!("https://beta.aviationweather.gov/cgi-bin/data/metar.php?ids={}&format=xml", icaos);
let gov_api_url = std::env::var("GOV_API_URL").expect("GOV_API_URL must be set");
let url = format!("{}/metar.php?ids={}&format=xml", gov_api_url, icaos);
match reqwest::get(url).await {
Ok(r) => match r.text().await {
Ok(r) => {
@@ -290,7 +291,7 @@ impl Metar {
return Ok(db_metars);
}
trace!("Retrieving missing METAR data for {:?}", missing_icaos);
let missing_icaos_string: Vec<String> = missing_icaos.iter().map(|icao| format!("'{}'", icao.to_string())).collect();
let missing_icaos_string: Vec<String> = missing_icaos.iter().map(|icao| format!("{}", icao.to_string())).collect();
let mut missing_metars = Self::get_remote_metars(missing_icaos_string.join(",")).await;
if missing_metars.len() > 0 {
let insert_metars = Self::to_insert(&missing_metars);

View File

@@ -1,18 +1,59 @@
use actix_web::{get, post, delete, web, HttpResponse};
use actix_web::{get, post, delete, web, HttpResponse, ResponseError};
use crate::auth::{JwtAuth, QueryUser, InsertUser};
#[get("users/favorites")]
async fn get_favorites() -> HttpResponse {
HttpResponse::NotImplemented().finish()
async fn get_favorites(auth: JwtAuth) -> HttpResponse {
println!("{:?}", auth);
match QueryUser::get_by_email(&auth.user.email) {
Ok(user) => {
return HttpResponse::Ok().json(user.favorites)
},
Err(err) => return ResponseError::error_response(&err)
}
}
#[post("users/favorites")]
async fn add_favorite() -> HttpResponse {
HttpResponse::NotImplemented().finish()
#[post("users/favorites/{icao}")]
async fn add_favorite(icao: web::Path<String>, auth: JwtAuth) -> HttpResponse {
match QueryUser::get_by_email(&auth.user.email) {
Ok(user) => {
if user.favorites.contains(&icao) {
// Check if the airport ICAO is already in the user's favorites
return HttpResponse::Conflict().finish()
} else {
// Add the airport ICAO to the user's favorites
let mut favorites = user.favorites;
favorites.push(icao.into_inner());
match InsertUser::update_favorites(&user.email, favorites) {
Ok(_) => return HttpResponse::Ok().finish(),
Err(err) => return ResponseError::error_response(&err)
}
}
},
Err(err) => return ResponseError::error_response(&err)
}
}
#[delete("users/favorites")]
async fn delete_favorite() -> HttpResponse {
HttpResponse::NotImplemented().finish()
#[delete("users/favorites/{icao}")]
async fn delete_favorite(icao: web::Path<String>, auth: JwtAuth) -> HttpResponse {
let icao: String = icao.into_inner();
match QueryUser::get_by_email(&auth.user.email) {
Ok(user) => {
if user.favorites.contains(&icao) {
// Check if the airport ICAO is already in the user's favorites
let mut favorites = user.favorites;
favorites.retain(|x| x != &icao);
match InsertUser::update_favorites(&user.email, favorites) {
Ok(_) => return HttpResponse::Ok().finish(),
Err(err) => return ResponseError::error_response(&err)
}
} else {
// Remove the airport ICAO from the user's favorites
return HttpResponse::Conflict().finish()
}
},
Err(err) => return ResponseError::error_response(&err)
}
}
pub fn init_routes(config: &mut web::ServiceConfig) {

View File

@@ -1,5 +1,5 @@
SERVICE_HOST=service
SERVICE_PORT=5000
UI_PORT=8080
UI_PORT=3000
NODE_ENV=development

View File

@@ -9,7 +9,7 @@ services:
environment:
- NODE_ENV=${NODE_ENV:-development}
ports:
- ${UI_PORT:-8080}:3000
- ${UI_PORT:-3000}:3000
build:
context: ./
target: dev
@@ -23,4 +23,4 @@ services:
restart: unless-stopped
networks:
weather-frontend: {}
weather-frontend:

1300
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,9 +10,12 @@
},
"dependencies": {
"@mantine/core": "^7.1.2",
"@mantine/form": "^7.1.2",
"@mantine/hooks": "^7.1.2",
"@mantine/modals": "^7.1.2",
"@mantine/notifications": "^7.1.2",
"axios": "^1.5.1",
"js-cookie": "^3.0.5",
"leaflet": "^1.9.4",
"next": "^13.5.4",
"react": "^18.2.0",
@@ -23,6 +26,7 @@
"recoil": "^0.7.7"
},
"devDependencies": {
"@types/js-cookie": "^3.0.5",
"@types/leaflet": "^1.9.6",
"@types/node": "20.8.2",
"@types/react": "18.2.24",

View File

@@ -7,7 +7,7 @@ interface GetAirportProps {
export async function getAirport({ icao }: GetAirportProps): Promise<GetAirportResponse> {
const response = await getRequest(`airports/${icao}`, {});
return response?.data || { data: undefined };
return response?.json() || { data: undefined };
}
interface GetAirportsProps {
@@ -34,5 +34,5 @@ export async function getAirports({
limit,
page
});
return response?.data || { data: [] };
return response?.json() || { data: [] };
}

63
ui/src/api/auth.ts Normal file
View File

@@ -0,0 +1,63 @@
import Cookies from 'js-cookie';
import { getRequest, postRequest } from '.';
import { RegisterUser, ResponseAuth } from './auth.types';
export async function login(email: string, password: string): Promise<ResponseAuth | undefined> {
const response = await postRequest('auth/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('auth/register', user);
if (response?.status === 201) {
return true;
} else {
return false;
}
}
export async function logout() {
return await postRequest('auth/logout', {});
}
export async function refresh(refresh_token_rotation?: boolean): Promise<ResponseAuth | undefined> {
const response = await getRequest('auth/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('auth/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/api/auth.types.ts Normal file
View 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;
}

View File

@@ -1,18 +1,58 @@
import axios, { AxiosResponse } from 'axios';
const serviceHost = process.env.SERVICE_HOST || 'http://localhost';
const servicePort = process.env.SERVICE_PORT || 5000;
const baseURL = `${serviceHost}:${servicePort}`;
export async function getRequest(endpoint: string, params: any): Promise<AxiosResponse<any, any> | undefined> {
const response = await axios
.get(`${serviceHost}:${servicePort}/${endpoint}`, { params })
.catch((error) => console.error(error));
return response || undefined;
export async function getRequest(endpoint: string, params: Record<string, any> = {}): Promise<Response> {
// Remove undefined params
Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]);
const urlParams = new URLSearchParams(params);
const url = urlParams && urlParams.size > 0 ? `${baseURL}/${endpoint}?${urlParams}` : `${baseURL}/${endpoint}`;
const response = await fetch(url, {
method: 'GET',
credentials: 'include'
});
return response;
}
export async function postRequest(endpoint: string, body: any): Promise<AxiosResponse<any, any> | undefined> {
const response = await axios
.post(`${serviceHost}:${servicePort}/${endpoint}`, { body })
.catch((error) => console.error(error));
return response || undefined;
interface PostOptions {
headers?: Record<string, any>;
type?: 'json' | 'form';
}
export async function postRequest(endpoint: string, body?: any, options?: PostOptions): Promise<Response> {
const url = `${baseURL}/${endpoint}`;
let response;
if (body && (!options?.type || options.type === 'json')) {
response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
credentials: 'include',
body: JSON.stringify(body)
});
} else {
response = await fetch(url, {
method: 'POST',
credentials: 'include',
body
});
}
return response;
}
export async function deleteRequest(endpoint: string): Promise<Response> {
const url = `${baseURL}/${endpoint}`;
const response = await fetch(url, {
method: 'DELETE',
credentials: 'include'
});
return response;
}
export interface Metadata {
limit: number;
page: number;
pages: number;
total: number;
}

View File

@@ -11,5 +11,5 @@ export async function getMetars(icaos: string[]): Promise<GetMetarsResponse> {
}
const stationICAOs: string = icaos.map((icao) => icao).join(',');
const response = await getRequest(`metars/${stationICAOs}`, {});
return response?.data || { data: [] };
return response?.json() || { data: [] };
}

51
ui/src/api/users.ts Normal file
View File

@@ -0,0 +1,51 @@
import { deleteRequest, getRequest, postRequest } from '.';
export async function getPicture(): Promise<Blob | undefined> {
const response = await getRequest('users/picture');
if (response?.status === 200) {
return response.blob();
} else {
return undefined;
}
}
export async function setPicture(payload: File): Promise<boolean> {
const data = new FormData();
data.append('data', payload);
// TODO: Figure out why the form data object is empty
const response = await postRequest('users/picture', data, {
type: 'form'
});
if (response?.status === 200) {
return true;
} else {
return false;
}
}
export async function getFavorites(): Promise<string[]> {
const response = await getRequest('users/favorites');
if (response?.status === 200) {
return response.json();
} else {
return [];
}
}
export async function addFavorite(icao: string): Promise<boolean> {
const response = await postRequest(`users/favorites/${icao}`);
if (response?.status === 200) {
return true;
} else {
return false;
}
}
export async function removeFavorite(icao: string): Promise<boolean> {
const response = await deleteRequest(`users/favorites/${icao}`);
if (response?.status === 200) {
return true;
} else {
return false;
}
}

View File

@@ -1,7 +1,7 @@
import React from 'react';
import RecoilRootWrapper from '@app/recoil-root-wrapper';
import Sidebar from '@/components/Sidebar';
import Topbar from '@/components/Topbar';
import Header from '@/components/Header';
import { Inter } from 'next/font/google';
import { MantineProvider } from '@mantine/core';
import { ModalsProvider } from '@mantine/modals';
@@ -26,7 +26,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
<RecoilRootWrapper>
<MantineProvider>
<ModalsProvider>
<Topbar />
<Header />
<Sidebar />
{children}
</ModalsProvider>

View File

@@ -0,0 +1,260 @@
'use client';
import { login, register, refreshLoggedIn } from '@/api/auth';
import { User } from '@/api/auth.types';
import {
Modal,
Container,
Title,
Anchor,
Paper,
TextInput,
Button,
PasswordInput,
Group,
Checkbox,
Text
} from '@mantine/core';
import { useForm } from '@mantine/form';
import { notifications } from '@mantine/notifications';
interface HeaderModalProps {
type?: string;
toggle: any;
setUser: (user: User) => void;
setRefreshId: (id: NodeJS.Timeout) => void;
}
export function HeaderModal({ type, toggle, setUser, setRefreshId }: HeaderModalProps) {
function passwordValidator(value: string) {
if (value.trim().length < 10) {
return 'Password must be at least 10 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: '',
password: '',
remember: false
}
});
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}>
{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 id = notifications.show({
loading: true,
title: `Creating account`,
message: `Please wait...`,
autoClose: false,
withCloseButton: false
});
const registerResponse = await register({
first_name: values.firstName,
last_name: values.lastName,
email: values.email,
password: values.password
});
if (registerResponse) {
const loginResponse = await login(values.email, values.password);
if (loginResponse) {
setUser(loginResponse.user);
setRefreshId(refreshLoggedIn());
onClose();
notifications.update({
id,
title: `Account created`,
message: `Welcome ${loginResponse.user.first_name}!`,
color: 'green',
autoClose: 2000,
loading: false
});
} 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
});
}
})}
>
<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) => {
const response = await login(values.email, values.password);
if (response) {
setUser(response.user);
setRefreshId(refreshLoggedIn());
onClose();
} else {
notifications.show({
title: `Unable to Login`,
message: `Please try again.`,
color: 'red',
autoClose: 2000
});
}
})}
>
<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' {...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>
);
}

View File

@@ -0,0 +1,215 @@
'use client';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { getAirports } from '@/api/airport';
import { useRouter } from 'next/navigation';
import { Autocomplete, Avatar, Button, Card, FileButton, Grid, Group, Menu, Text, UnstyledButton } from '@mantine/core';
import './header.css';
import { refresh, refreshLoggedIn, logout } from '@/api/auth';
import Cookies from 'js-cookie';
import { useRecoilState } from 'recoil';
import { userState } from '@/state/auth';
import { getFavorites, getPicture, setPicture } from '@/api/users';
import { useToggle } from '@mantine/hooks';
import { HeaderModal } from './HeaderModal';
import { favoritesState } from '@/state/user';
export default function Header() {
const [searchValue, setSearchValue] = useState('');
const [airports, setAirports] = useState<{ key: string; value: string; label: string }[]>([]);
const [modalType, toggle] = useToggle([undefined, 'login', 'register', 'reset']);
const [user, setUser] = useRecoilState(userState);
const [favorites, setFavorites] = useRecoilState(favoritesState);
const [refreshId, setRefreshId] = useState<NodeJS.Timeout | undefined>(undefined);
const [profilePicture, setProfilePicture] = useState<File | null>(null);
const router = useRouter();
useEffect(() => {
if (!user || !Cookies.get('logged_in')) {
refresh().then((response) => {
if (response) {
setRefreshId(refreshLoggedIn());
setUser(response.user);
getFavorites().then((response) => {
if (response) {
setFavorites(response);
}
});
if (response.user.profile_picture) {
getPicture().then((response) => {
if (response) {
setProfilePicture(response as File);
}
});
}
}
});
}
}, [user]);
async function onChange(value: string) {
setSearchValue(value);
const airportData = await getAirports({ filter: value });
setAirports(
airportData.data.map((airport) => ({
key: airport.icao,
value: airport.icao,
label: `${airport.icao} - ${airport.full_name}`
}))
);
}
function onClick(value: string) {
router.push(`/airport/${value}`);
setSearchValue('');
}
return (
<>
<nav className='navbar'>
<div className='left'>
<Link href={'/'} className='title'>
<span>Aviation Weather</span>
</Link>
<div className='search'>
<Autocomplete
radius='xl'
placeholder='Search Airports...'
data={airports}
limit={10}
value={searchValue}
onChange={onChange}
onOptionSubmit={onClick}
onBlur={() => setSearchValue('')}
/>
</div>
</div>
<div className='user-section'>
{user ? (
<Menu shadow='md' width={200} openDelay={100} closeDelay={400}>
<Menu.Target>
<UnstyledButton className='user user-button'>
<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) {
setPicture(payload).then((response) => {
if (response) {
setProfilePicture(payload);
}
});
}
}}
accept='image/png,image/jpeg,image/jpg'
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}>
<Link href='/profile'>
<Button fullWidth radius='md' size='xs' variant='default'>
Profile
</Button>
</Link>
</Grid.Col>
<Grid.Col span={6}>
<Button
fullWidth
radius='md'
size='xs'
variant='default'
onClick={async () => {
await logout();
Cookies.remove('logged_in');
setUser(undefined);
setFavorites([]);
setProfilePicture(null);
if (refreshId) {
clearInterval(refreshId);
}
}}
>
Logout
</Button>
</Grid.Col>
{user.role == 'admin' && (
<Grid.Col span={12}>
<Link href='/admin'>
<Button fullWidth radius='md' size='xs' variant='default'>
Administration
</Button>
</Link>
</Grid.Col>
)}
</Grid>
</Card>
</Menu.Dropdown>
</Menu>
) : (
<Group className='user'>
<Button onClick={() => toggle('login')}>Login</Button>
<Button variant='outline' onClick={() => toggle('register')}>
Sign up
</Button>
</Group>
)}
</div>
</nav>
<HeaderModal
type={modalType}
toggle={toggle}
setUser={(u) => {
setUser(u);
getFavorites().then((response) => {
if (response) {
setFavorites(response);
}
});
if (u.profile_picture) {
getPicture().then((response) => {
if (response) {
setProfilePicture(response as File);
}
});
}
}}
setRefreshId={setRefreshId}
/>
</>
);
}

View File

@@ -65,7 +65,7 @@ export default function MapTiles() {
return new DivIcon({
html: ReactDOMServer.renderToString(
<MantineProvider>
<Avatar variant='filled' color={color} radius='xl' size={size}>
<Avatar variant='filled' color={color} radius={'xl'} size={size}>
{tag}
</Avatar>
</MantineProvider>

View File

@@ -16,10 +16,13 @@ import {
BsFillCloudSnowFill,
BsQuestionLg
} from 'react-icons/bs';
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { Card, Divider, Grid, Modal, Tooltip } from '@mantine/core';
import './metars.css';
import SkyConditions from './SkyConditions';
import { addFavorite, getFavorites, removeFavorite } from '@/api/users';
import { favoritesState } from '@/state/user';
import { useRecoilValue } from 'recoil';
interface MetarModalProps {
airport: Airport;
@@ -28,10 +31,20 @@ interface MetarModalProps {
}
export default function MetarModal({ airport, isOpen, onClose }: MetarModalProps) {
const favorites = useRecoilValue(favoritesState);
const [isFavorite, setIsFavorite] = useState(false);
useEffect(() => {
setIsFavorite(favorites.includes(airport.icao));
}, [favorites, airport]);
function handleFavorite(value: boolean) {
setIsFavorite(value);
if (value) {
addFavorite(airport.icao);
} else {
removeFavorite(airport.icao);
}
}
return (

View File

@@ -1,59 +0,0 @@
'use client';
import Link from 'next/link';
import { AiOutlineUser } from 'react-icons/ai';
import { useState } from 'react';
import { getAirports } from '@/api/airport';
import { useRouter } from 'next/navigation';
import { Autocomplete, Avatar } from '@mantine/core';
import './topbar.css';
export default function Topbar() {
const [searchValue, setSearchValue] = useState('');
const [airports, setAirports] = useState<{ key: string; value: string; label: string }[]>([]);
const router = useRouter();
async function onChange(value: string) {
setSearchValue(value);
const airportData = await getAirports({ filter: value });
setAirports(
airportData.data.map((airport) => ({
key: airport.icao,
value: airport.icao,
label: `${airport.icao} - ${airport.full_name}`
}))
);
}
function onClick(value: string) {
router.push(`/airport/${value}`);
setSearchValue('');
}
return (
<nav className='navbar'>
<div className='left'>
<Link href={'/'} className='title'>
<span>Aviation Weather</span>
</Link>
<div className='search'>
<Autocomplete
radius='xl'
placeholder='Search Airports...'
data={airports}
limit={10}
value={searchValue}
onChange={onChange}
onOptionSubmit={onClick}
onBlur={() => setSearchValue('')}
/>
</div>
</div>
<Link className='avatar' href={'/profile'}>
<Avatar variant='filled'>
<AiOutlineUser />
</Avatar>
</Link>
</nav>
);
}

12
ui/src/state/auth.ts Normal file
View File

@@ -0,0 +1,12 @@
import { User } from '@/api/auth.types';
import { atom } from 'recoil';
export const userState = atom({
key: 'userState',
default: undefined as User | undefined
});
export const isAuthenticatedState = atom({
key: 'isAuthenticatedState',
default: false
});

6
ui/src/state/user.ts Normal file
View File

@@ -0,0 +1,6 @@
import { atom } from 'recoil';
export const favoritesState = atom({
key: 'favoritesState',
default: [] as string[]
});