Updated UI with accounts and fixed routing

This commit is contained in:
2025-04-13 21:35:08 -04:00
parent d5bc4cafb8
commit 592de030c8
24 changed files with 256 additions and 108 deletions

View File

@@ -1,9 +1,6 @@
#!make
SHELL := /bin/bash
export API_VERSION = $(shell sed -n 's/^version *= *"\([^"]*\)".*/\1/p' api/Cargo.toml)
export UI_VERSION=$(shell sed -n 's/.*"version": *"\([^"]*\)".*/\1/p' ui/package.json)
include .env
-include .env.local
export

View File

@@ -4,7 +4,10 @@
</div>
## Makefile
`make help` to list all commands
* `make` or `make help` to list all commands
* `make docker-up` to start all containers
* `make docker-clean` to stop and delete all containers, volumes, and networks related
to the application
## Setup
@@ -17,6 +20,31 @@
* Running just `make cert` will generate `localhost` certificates
4. Run the application with `make up`
### Production Environment
The most likely to change environment variables are the following:
* `UI_PORT`
* `API_PORT`
* `POSTGRES_PORT`
* `POSTGRES_PASSWORD` - Please change in production environments
* `MINIO_HOST` - Match to the `NGINX_HOST` value (see below)
* `MINIO_ROOT_PASSWORD` - Please change in production environments
* `NGINX_HOST` - The IP address of the system
* `NGINX_INTERNAL_HOST` - Typically `host.docker.internal` or `172.17.0.1`
to allow communication within the docker network
* `ENVIRONMENT` - Change to `production`
* `ADMIN_EMAIL` - Please change in production environments
* `ADMIN_PASSWORD` - Please change in production environments
* `VITE_API_URL` - Change to the FQDN of the URL that is reachable through the internet.
For example: `https://aviaation.bensherriff.com`
* `__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS` - Change to the domain of the `VITE_API_URL`.
For example: `aviation.bensherriff.com`
If the App is not directly exposed to the internet (i.e., behind another reverse proxy or similar),
then `NGINX_SSL_ENABLED` most likely should be `false`. The `NGINX_SSL_ENABLED` should only be
enabled when you need to setup SSL directly. However, the SSL configuration is incomplete, and may
require additional configuration that is not included in this README.
* Additionally, run `make cert` to generate certificates.
## Data Sources
### Airport Data

2
api/Cargo.lock generated
View File

@@ -366,7 +366,7 @@ dependencies = [
[[package]]
name = "api"
version = "0.1.2"
version = "0.1.3"
dependencies = [
"actix-cors",
"actix-multipart",

View File

@@ -1,9 +1,9 @@
[package]
name = "api"
version = "0.1.2"
version = "0.1.3"
edition = "2021"
authors = ["Ben Sherriff <hello@bensherriff.com>"]
repository = "https://github.com/bensherriff/aviation-weather"
authors = ["Ben Sherriff <ben@bensherriff.com>"]
repository = "https://gitea.bensherriff.com/bsherriff/aviation"
readme = "../README.md"
license = "GPL-3.0-or-later"

View File

@@ -140,7 +140,9 @@ async fn validate_session(req: HttpRequest) -> HttpResponse {
session_id,
ip_address
);
return ResponseError::error_response(&Error::new(500, err.to_string()));
return HttpResponse::Unauthorized()
.cookie(Session::empty_cookie())
.finish();
}
};
let email = &session.email;

View File

@@ -59,20 +59,26 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
}
let certificate_path = env::var("SSL_CA_PATH")?;
let certificate_data = std::fs::read(certificate_path)?;
let certificate = Certificate::from_pem(&certificate_data)?;
let client = reqwest::Client::builder()
let mut client_builder = reqwest::Client::builder()
.timeout(Duration::from_secs(10))
.add_root_certificate(certificate)
.tls_built_in_root_certs(true)
.tls_built_in_root_certs(true);
if let Ok(val) = env::var("NGINX_SSL_ENABLED") {
if val == "true" {
let certificate_path = env::var("SSL_CA_PATH")?;
let certificate_data = std::fs::read(certificate_path)?;
let certificate = Certificate::from_pem(&certificate_data)?;
client_builder = client_builder.add_root_certificate(certificate);
}
}
let client = client_builder
.build()
.expect("Failed to create reqwest client");
let state = AppState { client };
let host = "0.0.0.0";
let port = "5000";
let port = env::var("API_PORT").unwrap_or("5000".to_string());
let server = match HttpServer::new(move || {
let cors = Cors::default()

View File

@@ -11,7 +11,7 @@ pub struct SystemInfo {
#[get("/info")]
async fn info() -> HttpResponse {
let mut healthy = true;
let version = match env::var("API_VERSION") {
let version = match env::var("CARGO_PKG_VERSION") {
Ok(v) => v,
Err(_) => {
healthy = false;
@@ -19,8 +19,6 @@ async fn info() -> HttpResponse {
}
};
dbg!(&version);
let info = SystemInfo { version, healthy };
HttpResponse::Ok().json(info)

View File

@@ -87,7 +87,7 @@ services:
<<: *default_restart
api:
image: gitea.bensherriff.com/bsherriff/aviation-api:0.1.2
image: gitea.bensherriff.com/bsherriff/aviation-api:0.1.3
container_name: aviation-api
build:
context: ./api
@@ -95,6 +95,7 @@ services:
env_file: *env
environment:
SSL_CA_PATH: /ssl/ca.pem
API_PORT: 5000
POSTGRES_HOST: aviation-postgres
POSTGRES_PORT: 5432
REDIS_HOST: aviation-redis
@@ -117,7 +118,7 @@ services:
<<: *default_restart
ui:
image: gitea.bensherriff.com/bsherriff/aviation-ui:latest
image: gitea.bensherriff.com/bsherriff/aviation-ui:0.1.2
container_name: aviation-ui
build:
context: ./ui

54
ui/package-lock.json generated
View File

@@ -19,7 +19,8 @@
"leaflet": "^1.9.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-leaflet": "^5.0.0"
"react-leaflet": "^5.0.0",
"react-router": "^7.5.0"
},
"devDependencies": {
"@eslint/js": "^9.21.0",
@@ -1551,6 +1552,12 @@
"@babel/types": "^7.20.7"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT"
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@@ -2417,6 +2424,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -4222,6 +4238,30 @@
}
}
},
"node_modules/react-router": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.5.0.tgz",
"integrity": "sha512-estOHrRlDMKdlQa6Mj32gIks4J+AxNsYoE0DbTTxiMy2mPzZuWSDU+N85/r1IlNR7kGfznF3VCUlvc5IUO+B9g==",
"license": "MIT",
"dependencies": {
"@types/cookie": "^0.6.0",
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0",
"turbo-stream": "2.4.0"
},
"engines": {
"node": ">=20.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
}
},
"node_modules/react-style-singleton": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz",
@@ -4446,6 +4486,12 @@
"semver": "bin/semver.js"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"license": "MIT"
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -4573,6 +4619,12 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/turbo-stream": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz",
"integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==",
"license": "ISC"
},
"node_modules/type-check": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",

View File

@@ -22,7 +22,8 @@
"leaflet": "^1.9.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-leaflet": "^5.0.0"
"react-leaflet": "^5.0.0",
"react-router": "^7.5.0"
},
"devDependencies": {
"@eslint/js": "^9.21.0",

View File

@@ -1,14 +1,3 @@
/* Ensure that the html and body take up the full height */
html,
body,
#root,
.App {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
/* Set up Flexbox layout */
.App {
display: flex;
@@ -16,11 +5,6 @@ body,
height: 100%;
}
.app-header {
background-color: #333;
color: #fff;
}
.map-wrapper {
flex: 1;
}

View File

@@ -12,7 +12,6 @@ import { useEffect, useState } from 'react';
import { Airport } from '@lib/airport.types.ts';
import AirportDrawer from '@components/AirportDrawer.tsx';
import { getWeatherMapUrl } from '@lib/rainViewer.ts';
// import { IconRadar } from '@tabler/icons-react';
import Cookies from 'js-cookie';
import { UnstyledButton } from '@mantine/core';
// Fix Leaflet's default icon path issues with Webpack
@@ -107,7 +106,7 @@ function App() {
style={{ bottom: '80px' }}
className={`map-button ${showRadar ? 'active' : ''}`}
>
Radar
R
</UnstyledButton>
</div>
</div>

View File

@@ -0,0 +1,18 @@
import { Header } from '@components/Header';
import { Navigate } from 'react-router';
import { useUserContext } from '@components/context/UserContext.tsx';
export function Administration() {
const { user } = useUserContext();
if (user == undefined) {
return <Navigate to={'/'} />;
}
return (
<>
<Header />
Todo: administration {user?.email}
</>
);
}

View File

@@ -34,18 +34,18 @@ export default function AirportMarker({
);
}
function getMarkerColor(flightCategory: 'VFR' | 'MVFR' | 'LIFR' | 'IFR' | 'UNKN'): string {
function getMarkerInfo(flightCategory: 'VFR' | 'MVFR' | 'LIFR' | 'IFR' | 'UNKN'): [string, number] {
switch (flightCategory) {
case 'IFR':
return '#ff0100';
return ['#ff0100', 5];
case 'LIFR':
return '#7f007f';
return ['#7f007f', 6];
case 'MVFR':
return '#00f';
return ['#00f', 7];
case 'VFR':
return '#018000';
return ['#018000', 8];
case 'UNKN':
return '#696969';
return ['#696969', 4];
}
}
@@ -61,7 +61,8 @@ function createCustomIcon(airport: Airport): L.DivIcon {
background-color: white;
display: flex;
align-items: center;
justify-content: center;">
justify-content: center;
z-index: {info[1]}">
<span style="color: black; font-size: 8px; font-weight: bold;">H</span>
</div>
`,
@@ -72,15 +73,16 @@ function createCustomIcon(airport: Airport): L.DivIcon {
} else {
// Default to a filled circle.
const flightCategory = airport.latest_metar?.flight_category || 'UNKN';
const color = getMarkerColor(flightCategory);
const info = getMarkerInfo(flightCategory);
if (flightCategory == 'UNKN') {
return L.divIcon({
html: `
<div style="
background-color: ${color};
background-color: ${info[0]};
width: 10px;
height: 10px;
border-radius: 50%;">
border-radius: 50%;
z-index: {info[1]}">
</div>
`,
className: '',
@@ -91,11 +93,12 @@ function createCustomIcon(airport: Airport): L.DivIcon {
return L.divIcon({
html: `
<div style="
background-color: ${color};
background-color: ${info[0]};
width: 18px;
height: 18px;
border-radius: 50%;
border: 2px solid #fff;">
border: 2px solid #fff;
z-index: {info[1]}">
</div>
`,
className: '',

View File

@@ -157,7 +157,7 @@ export function HeaderModal({ type, toggle, login, register }: HeaderModalProps)
/>
<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.'
description='Passwords must be at least 8 characters long, contain at least one number, one uppercase letter, one lowercase letter, and one special character.'
placeholder='Your password'
required
mt='md'

View File

@@ -1,6 +1,7 @@
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 { useNavigate } from 'react-router';
// import './styles.css';
interface HeaderUserProps {
@@ -10,6 +11,8 @@ interface HeaderUserProps {
}
export default function HeaderUser({ user, profilePicture, logout }: HeaderUserProps) {
const navigate = useNavigate();
return (
<Menu shadow='md' width={200} openDelay={100} closeDelay={400} zIndex={1000}>
<Menu.Target>
@@ -66,7 +69,7 @@ export default function HeaderUser({ user, profilePicture, logout }: HeaderUserP
</Text>
<Grid mt='xl'>
<Grid.Col span={6}>
<Button fullWidth radius='md' size='xs' variant='default'>
<Button fullWidth radius='md' size='xs' variant='default' onClick={() => navigate('/profile')}>
Profile
</Button>
</Grid.Col>
@@ -75,9 +78,9 @@ export default function HeaderUser({ user, profilePicture, logout }: HeaderUserP
Logout
</Button>
</Grid.Col>
{user.role == 'admin' && (
{user.role == 'ADMIN' && (
<Grid.Col span={12}>
<Button fullWidth radius='md' size='xs' variant='default'>
<Button fullWidth radius='md' size='xs' variant='default' onClick={() => navigate('/administration')}>
Administration
</Button>
</Grid.Col>

View File

@@ -1,13 +1,12 @@
import { useState } from 'react';
import { Avatar, Box, Burger, Button, Group, Text } from '@mantine/core';
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';
import { useUserContext } from '@components/context/UserContext.tsx';
import { Link } from 'react-router';
// const links = [
// { link: '/', label: 'Map' },
@@ -16,9 +15,9 @@ import HeaderUser from '@components/Header/HeaderUser.tsx';
// ];
export function Header() {
const { user, setUser } = useUserContext();
const [opened, { toggle }] = useDisclosure(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) => (
@@ -62,8 +61,8 @@ export function Header() {
async function logoutUser(): Promise<void> {
await logout();
Cookies.remove('logged_in');
setUser(undefined);
window.location.reload();
}
async function registerUser({
@@ -131,12 +130,14 @@ export function Header() {
<Box>
<header className={classes.header}>
<Group justify='space-between' h='100%'>
<Burger opened={opened} onClick={toggle} hiddenFrom='sm' size='sm' />
<Group align='center' gap='xs'>
<Burger opened={opened} onClick={toggle} hiddenFrom='xs' size='sm' />
<Avatar src='/logo.svg' alt='logo' />
<Link to='/'>
<Avatar src='/logo.svg' alt='logo' onClick={toggle} />
</Link>
<Text>Aviation Data</Text>
</Group>
{/*<Group gap={5} visibleFrom='xs' className={classes.navGroup}>*/}
{/*<Group gap={5} visibleFrom='sm' className={classes.navGroup}>*/}
{/* {navItems}*/}
{/*</Group>*/}
<Group align='center' gap='xs'>

View File

@@ -0,0 +1,18 @@
import { Header } from '@components/Header';
import { useUserContext } from '@components/context/UserContext.tsx';
import { Navigate } from 'react-router';
export function Profile() {
const { user } = useUserContext();
if (user == undefined) {
return <Navigate to={'/'} />;
}
return (
<>
<Header />
Todo: profile {user?.email}
</>
);
}

View File

@@ -0,0 +1,18 @@
import { User } from '@lib/account.types.ts';
import { createContext, useContext } from 'react';
interface UserContextType {
user?: User;
setUser: (user: User | undefined) => void;
loading: boolean;
}
export const UserContext = createContext<UserContextType>({
user: undefined,
setUser: () => {},
loading: true
});
export function useUserContext(): UserContextType {
return useContext(UserContext);
}

View File

@@ -0,0 +1,33 @@
import { ReactNode, useEffect, useState } from 'react';
import { UserContext } from './UserContext.tsx';
import { refresh } from '@lib/account.ts';
import { User } from '@lib/account.types.ts';
import { Center, Loader } from '@mantine/core';
export function UserProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | undefined>(undefined);
const [loading, setLoading] = useState(true);
useEffect(() => {
refresh().then((refreshUser) => {
if (refreshUser) {
setUser(refreshUser);
} else {
setUser(undefined);
}
setLoading(false);
});
}, []);
return (
<UserContext.Provider value={{ user, setUser, loading }}>
{loading ? (
<Center style={{ height: '100vh' }}>
<Loader size='xl' />
</Center>
) : (
<>{children}</>
)}
</UserContext.Provider>
);
}

View File

@@ -0,0 +1,9 @@
/* Ensure that the html and body take up the full height */
html,
body,
#root {
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}

View File

@@ -1,6 +1,5 @@
import Cookies from 'js-cookie';
import { getRequest, postRequest } from '.';
import { RegisterUser, ResponseAuth, User } from './account.types';
import { RegisterUser, User } from './account.types';
export async function login(email: string, password: string): Promise<User | undefined> {
const response = await postRequest('account/login', { email, password });
@@ -24,40 +23,11 @@ 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 });
export async function refresh(): Promise<User | undefined> {
const response = await getRequest('account/session');
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;
}

View File

@@ -1,8 +1,3 @@
export interface ResponseAuth {
token: string;
user: User;
}
export interface RegisterUser {
email: string;
password: string;

View File

@@ -4,6 +4,10 @@ import './index.css';
import App from './App.tsx';
import { createTheme, MantineProvider } from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import { UserProvider } from '@components/context/UserProvider.tsx';
import { BrowserRouter, Route, Routes } from 'react-router';
import { Profile } from '@components/Profile.tsx';
import { Administration } from '@components/Administration.tsx';
const theme = createTheme({
fontFamily: 'Inter, sans-serif'
@@ -11,9 +15,17 @@ const theme = createTheme({
createRoot(document.getElementById('root')!).render(
<StrictMode>
<MantineProvider theme={theme} defaultColorScheme={'dark'}>
<Notifications zIndex={2000} />
<App />
</MantineProvider>
<BrowserRouter>
<MantineProvider theme={theme} defaultColorScheme={'dark'}>
<Notifications zIndex={2000} />
<UserProvider>
<Routes>
<Route path='/' element={<App />} />
<Route path='/profile' element={<Profile />} />
<Route path='/administration' element={<Administration />} />
</Routes>
</UserProvider>
</MantineProvider>
</BrowserRouter>
</StrictMode>
);