Updates to account, ui, etc
This commit is contained in:
4
.env
4
.env
@@ -11,7 +11,7 @@ NGINX_INTERNAL_HOST=host.docker.internal
|
|||||||
POSTGRES_HOST=localhost
|
POSTGRES_HOST=localhost
|
||||||
POSTGRES_USER=aviation
|
POSTGRES_USER=aviation
|
||||||
POSTGRES_PASSWORD=changeme
|
POSTGRES_PASSWORD=changeme
|
||||||
POSTGRES_NAME=aviation
|
POSTGRES_DB=aviation_db
|
||||||
POSTGRES_PORT=5432
|
POSTGRES_PORT=5432
|
||||||
|
|
||||||
REDIS_HOST=localhost
|
REDIS_HOST=localhost
|
||||||
@@ -37,7 +37,7 @@ SSL_CERT_KEY_PATH=../ssl/localhost.key
|
|||||||
|
|
||||||
VITE_API_URL=${NGINX_PROTOCOL}://${NGINX_HOST}:${NGINX_HTTP_PORT}/api
|
VITE_API_URL=${NGINX_PROTOCOL}://${NGINX_HOST}:${NGINX_HTTP_PORT}/api
|
||||||
VITE_DEFAULT_LIMIT=200
|
VITE_DEFAULT_LIMIT=200
|
||||||
__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS:${NGINX_HOST}
|
__VITE_ADDITIONAL_SERVER_ALLOWED_HOSTS=${NGINX_HOST}
|
||||||
|
|
||||||
ENVIRONMENT=development
|
ENVIRONMENT=development
|
||||||
ADMIN_EMAIL=admin@example.com
|
ADMIN_EMAIL=admin@example.com
|
||||||
|
|||||||
2
Makefile
2
Makefile
@@ -15,7 +15,7 @@ help: ## This info
|
|||||||
format: format-api format-ui format-adsb ## Format code
|
format: format-api format-ui format-adsb ## Format code
|
||||||
|
|
||||||
psql: ## Connect to the PSQL DB
|
psql: ## Connect to the PSQL DB
|
||||||
@docker exec -it aviation-postgres psql -U ${POSTGRES_USER} -P pager=off
|
@docker exec -it aviation-postgres psql -U ${POSTGRES_USER} -d ${POSTGRES_DB} -P pager=off
|
||||||
|
|
||||||
#################
|
#################
|
||||||
# API Commands #
|
# API Commands #
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ Debug using `export LIBUSB_DEBUG=4`
|
|||||||
`lsusb -v -d 0bda:2832`
|
`lsusb -v -d 0bda:2832`
|
||||||
|
|
||||||
## Simulation Mode
|
## Simulation Mode
|
||||||
`cargo run -p adsb_sim --`
|
`cargo run -- --connect`
|
||||||
|
|
||||||
`cargo run -p adsb_recv -- --net`
|
|
||||||
|
|
||||||
## Decode
|
## Decode
|
||||||
`cargo run -p adsb_recv -- --decode 8D4840D6202CC371C32CE0576098`
|
`cargo run -- --decode 8D4840D6202CC371C32CE0576098`
|
||||||
|
|||||||
@@ -21,25 +21,42 @@ pub const DEVICE_RTL2832U: DeviceInfo = DeviceInfo {
|
|||||||
pid: 0x2832,
|
pid: 0x2832,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Timeout
|
|
||||||
pub const TIMEOUT: Duration = Duration::from_secs(1);
|
pub const TIMEOUT: Duration = Duration::from_secs(1);
|
||||||
|
pub const FIR_LENGTH: usize = 16;
|
||||||
// pub const DEFAULT_BUFFER_LENGTH: usize = 4096;
|
|
||||||
pub const DEFAULT_BUFFER_LENGTH: usize = 64;
|
|
||||||
|
|
||||||
// Request Types
|
// Request Types
|
||||||
pub const REQ_CTRL_OUT: u8 =
|
pub const REQ_CTRL_OUT: u8 =
|
||||||
rusb::constants::LIBUSB_ENDPOINT_OUT | rusb::constants::LIBUSB_REQUEST_TYPE_VENDOR;
|
rusb::constants::LIBUSB_ENDPOINT_OUT | rusb::constants::LIBUSB_REQUEST_TYPE_VENDOR;
|
||||||
|
|
||||||
// Blocks
|
// Blocks
|
||||||
|
pub const BLOCK_DEMOD: u16 = 0;
|
||||||
pub const BLOCK_USB: u16 = 1;
|
pub const BLOCK_USB: u16 = 1;
|
||||||
|
pub const BLOCK_SYS: u16 = 2;
|
||||||
|
pub const BLOCK_TUN: u16 = 3;
|
||||||
|
pub const BLOCK_ROM: u16 = 4;
|
||||||
|
pub const BLOCK_IRB: u16 = 5;
|
||||||
|
pub const BLOCK_IIC: u16 = 6;
|
||||||
|
|
||||||
|
// Registers
|
||||||
|
pub const DEMOD_CTL: u16 = 0x3000;
|
||||||
|
pub const DEMOD_CTL_1: u16 = 0x300b;
|
||||||
|
|
||||||
// USB
|
// USB
|
||||||
pub const USB_EPA_CTL: u16 = 0x2148;
|
pub const USB_EPA_CTL: u16 = 0x2148;
|
||||||
pub const USB_SYSCTL: u16 = 0x2000;
|
pub const USB_SYSCTL: u16 = 0x2000;
|
||||||
|
pub const USB_EPA_MAXPKT: u16 = 0x2158;
|
||||||
|
|
||||||
/// ADS-B downlink frequency (1090 MHz)
|
/// ADS-B downlink frequency (1090 MHz)
|
||||||
pub const ADSB_FREQUENCY_HZ: u32 = 1_090_000_000;
|
pub const ADSB_FREQUENCY_HZ: u32 = 1_090_000_000;
|
||||||
|
|
||||||
/// RTL-SDR sample rate in samples/second.
|
/// RTL-SDR sample rate in samples/second.
|
||||||
pub const SAMPLE_RATE_HZ: u32 = 2_048_000;
|
pub const SAMPLE_RATE_HZ: u32 = 2_048_000;
|
||||||
|
|
||||||
|
pub const DEFAULT_FIR: &'static [i32; FIR_LENGTH] = &[
|
||||||
|
-54, -36, -41, -40, -32, -14, 14, 53, 101, 156, 215, 273, 327, 372, 404, 421,
|
||||||
|
];
|
||||||
|
// pub const DEFAULT_BUFFER_LENGTH: usize = 4096;
|
||||||
|
pub const DEFAULT_BUFFER_LENGTH: usize = 64;
|
||||||
|
pub const DEFAULT_RTL_XTAL_FREQ: u32 = 28_800_000;
|
||||||
|
pub const MIN_RTL_XTAL_FREQ: u32 = DEFAULT_RTL_XTAL_FREQ - 1000;
|
||||||
|
pub const MAX_RTL_XTAL_FREQ: u32 = DEFAULT_RTL_XTAL_FREQ + 1000;
|
||||||
|
|||||||
@@ -7,8 +7,9 @@ use rusb::{
|
|||||||
};
|
};
|
||||||
use crate::error::{Error, Result};
|
use crate::error::{Error, Result};
|
||||||
use crate::constants::{
|
use crate::constants::{
|
||||||
ADSB_FREQUENCY_HZ, BLOCK_USB, DEFAULT_BUFFER_LENGTH, REQ_CTRL_OUT, SAMPLE_RATE_HZ, TIMEOUT,
|
ADSB_FREQUENCY_HZ, BLOCK_SYS, BLOCK_USB, DEFAULT_BUFFER_LENGTH, DEFAULT_FIR,
|
||||||
USB_EPA_CTL, USB_SYSCTL,
|
DEFAULT_RTL_XTAL_FREQ, DEMOD_CTL, DEMOD_CTL_1, REQ_CTRL_OUT, SAMPLE_RATE_HZ, TIMEOUT,
|
||||||
|
USB_EPA_CTL, USB_EPA_MAXPKT, USB_SYSCTL,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// rusb/libusb implementation of `RtlSdrDevice`
|
/// rusb/libusb implementation of `RtlSdrDevice`
|
||||||
@@ -83,7 +84,7 @@ impl RtlSdrDevice {
|
|||||||
Some(e) => e,
|
Some(e) => e,
|
||||||
None => return Err(Error::new("Unable to find endpoint on device")),
|
None => return Err(Error::new("Unable to find endpoint on device")),
|
||||||
};
|
};
|
||||||
log::debug!("Found readable endpoint: {:?}", endpoint.to_string());
|
log::debug!("Found readable endpoint: {}", endpoint.to_string());
|
||||||
|
|
||||||
let mut sdr = Self::new(handle, endpoint);
|
let mut sdr = Self::new(handle, endpoint);
|
||||||
|
|
||||||
@@ -158,6 +159,10 @@ impl RtlSdrDevice {
|
|||||||
|
|
||||||
self.test_write()?;
|
self.test_write()?;
|
||||||
|
|
||||||
|
self.initialize_baseband()?;
|
||||||
|
|
||||||
|
self.set_i2c_repeater(true)?;
|
||||||
|
|
||||||
// Reset the internal USB buffer
|
// Reset the internal USB buffer
|
||||||
self.reset_buffer()?;
|
self.reset_buffer()?;
|
||||||
|
|
||||||
@@ -191,6 +196,7 @@ impl RtlSdrDevice {
|
|||||||
.map_err(|err| Error::new(format!("Failed to set alternate setting: {:?}", err)))
|
.map_err(|err| Error::new(format!("Failed to set alternate setting: {:?}", err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Attempt to write a test message, and reset the device on a failure
|
||||||
fn test_write(&self) -> Result<()> {
|
fn test_write(&self) -> Result<()> {
|
||||||
log::trace!("Testing write to device...");
|
log::trace!("Testing write to device...");
|
||||||
let length = ctrl_write_register(&self.handle, BLOCK_USB, USB_SYSCTL, 0x89, 1)?;
|
let length = ctrl_write_register(&self.handle, BLOCK_USB, USB_SYSCTL, 0x89, 1)?;
|
||||||
@@ -215,6 +221,66 @@ impl RtlSdrDevice {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn reset_demod(&self) -> Result<()> {
|
||||||
|
log::trace!("Resetting demod...");
|
||||||
|
demod_ctrl_write_register(&self.handle, 1, 0x01, 0x14, 1)
|
||||||
|
.map_err(|err| Error::new(format!("Failed to reset the internal demod: {:?}", err)))?;
|
||||||
|
demod_ctrl_write_register(&self.handle, 1, 0x01, 0x10, 1)
|
||||||
|
.map_err(|err| Error::new(format!("Failed to reset the internal demod: {:?}", err)))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn initialize_baseband(&self) -> Result<()> {
|
||||||
|
// Initialize the USB
|
||||||
|
ctrl_write_register(&self.handle, BLOCK_USB, USB_SYSCTL, 0x09, 1)?;
|
||||||
|
ctrl_write_register(&self.handle, BLOCK_USB, USB_EPA_MAXPKT, 0x0002, 2)?;
|
||||||
|
ctrl_write_register(&self.handle, BLOCK_USB, USB_EPA_CTL, 0x1002, 2)?;
|
||||||
|
|
||||||
|
// Power on demod
|
||||||
|
ctrl_write_register(&self.handle, BLOCK_SYS, DEMOD_CTL_1, 0x22, 1)?;
|
||||||
|
ctrl_write_register(&self.handle, BLOCK_SYS, DEMOD_CTL, 0xe8, 1)?;
|
||||||
|
|
||||||
|
// Reset demod
|
||||||
|
self.reset_demod()?;
|
||||||
|
|
||||||
|
// Disable spectrum inversion and adjust channel rejection
|
||||||
|
ctrl_write_register(&self.handle, 1, 0x15, 0x00, 1)?;
|
||||||
|
ctrl_write_register(&self.handle, 1, 0x16, 0x00, 2)?;
|
||||||
|
|
||||||
|
// Clear DDC shift and IF registers
|
||||||
|
for i in 0..5 {
|
||||||
|
demod_ctrl_write_register(&self.handle, 1, 0x16 + i, 0x00, 1)?;
|
||||||
|
}
|
||||||
|
self.set_fir(DEFAULT_FIR)?;
|
||||||
|
|
||||||
|
// info!("Enable SDR mode, disable DAGC (bit 5)");
|
||||||
|
demod_ctrl_write_register(&self.handle, 0, 0x19, 0x05, 1)?;
|
||||||
|
|
||||||
|
// info!("Init FSM state-holding register");
|
||||||
|
demod_ctrl_write_register(&self.handle, 1, 0x93, 0xf0, 1)?;
|
||||||
|
demod_ctrl_write_register(&self.handle, 1, 0x94, 0x0f, 1)?;
|
||||||
|
|
||||||
|
// Disable AGC (en_dagc, bit 0) (seems to have no effect)
|
||||||
|
demod_ctrl_write_register(&self.handle, 1, 0x11, 0x00, 1)?;
|
||||||
|
|
||||||
|
// Disable RF and IF AGC loop
|
||||||
|
demod_ctrl_write_register(&self.handle, 1, 0x04, 0x00, 1)?;
|
||||||
|
|
||||||
|
// Disable PID filter
|
||||||
|
demod_ctrl_write_register(&self.handle, 0, 0x61, 0x60, 1)?;
|
||||||
|
|
||||||
|
// opt_adc_iq = 0, default ADC_I/ADC_Q datapath
|
||||||
|
demod_ctrl_write_register(&self.handle, 0, 0x06, 0x80, 1)?;
|
||||||
|
|
||||||
|
// Enable Zero-IF mode, DC cancellation, and IQ estimation/compensation
|
||||||
|
demod_ctrl_write_register(&self.handle, 1, 0xb1, 0x1b, 1)?;
|
||||||
|
|
||||||
|
// Disable 4.096 MHz clock output on pin TP_CK0
|
||||||
|
demod_ctrl_write_register(&self.handle, 0, 0x0d, 0x83, 1)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn set_center_frequency(&mut self, frequency: u32) -> Result<()> {
|
fn set_center_frequency(&mut self, frequency: u32) -> Result<()> {
|
||||||
log::trace!("Setting center_frequency to {}Hz", frequency);
|
log::trace!("Setting center_frequency to {}Hz", frequency);
|
||||||
self.frequency = frequency;
|
self.frequency = frequency;
|
||||||
@@ -223,7 +289,77 @@ impl RtlSdrDevice {
|
|||||||
|
|
||||||
fn set_sample_rate(&mut self, rate: u32) -> Result<()> {
|
fn set_sample_rate(&mut self, rate: u32) -> Result<()> {
|
||||||
log::trace!("Setting sample_rate to {}Hz", rate);
|
log::trace!("Setting sample_rate to {}Hz", rate);
|
||||||
self.rate = rate;
|
if rate <= 225_000 || rate > 3_200_000 || (rate > 300000 && rate <= 900000) {
|
||||||
|
return Err(Error::new(format!("Invalid sample rate: {} Hz", rate)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let rsamp_ratio =
|
||||||
|
((DEFAULT_RTL_XTAL_FREQ as u128 * 2_u128.pow(22) / rate as u128) & 0x0ffffffc) as u128;
|
||||||
|
log::trace!(
|
||||||
|
"Sample rate: {}, xtal: {}, rsamp_ratio: {}",
|
||||||
|
rate,
|
||||||
|
DEFAULT_RTL_XTAL_FREQ,
|
||||||
|
rsamp_ratio
|
||||||
|
);
|
||||||
|
let real_resamp_ratio = rsamp_ratio | ((rsamp_ratio & 0x08000000) << 1);
|
||||||
|
let real_rate =
|
||||||
|
(DEFAULT_RTL_XTAL_FREQ as u128 * 2_u128.pow(22)) as f64 / real_resamp_ratio as f64;
|
||||||
|
if rate as f64 != real_rate {
|
||||||
|
log::trace!("Exact sample rate is {} Hz", real_rate);
|
||||||
|
}
|
||||||
|
|
||||||
|
self.rate = real_rate as u32;
|
||||||
|
|
||||||
|
let mut tmp: u16 = (rsamp_ratio >> 16) as u16;
|
||||||
|
demod_ctrl_write_register(&self.handle, 1, 0x9f, tmp, 2)?;
|
||||||
|
tmp = (rsamp_ratio & 0xffff) as u16;
|
||||||
|
demod_ctrl_write_register(&self.handle, 1, 0xa1, tmp, 2)?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_fir(&self, fir: &[i32; 16]) -> Result<()> {
|
||||||
|
log::trace!("Setting fir to {:?}", fir);
|
||||||
|
const TMP_LEN: usize = 20;
|
||||||
|
let mut tmp: [u8; TMP_LEN] = [0; TMP_LEN];
|
||||||
|
// First 8 values are i8
|
||||||
|
for i in 0..8 {
|
||||||
|
let val = fir[i];
|
||||||
|
if val < -128 || val > 127 {
|
||||||
|
panic!("i8 FIR coefficient out of bounds! {}", val);
|
||||||
|
}
|
||||||
|
tmp[i] = val as u8;
|
||||||
|
}
|
||||||
|
// Next 12 are i12, so don't line up with byte boundaries and need to unpack
|
||||||
|
// 12 i12 values from 4 pairs of bytes in fir. Example:
|
||||||
|
// fir: 4b5, 7f8, 3e8, 619
|
||||||
|
// tmp: 4b, 57, f8, 3e, 86, 19
|
||||||
|
for i in (0..8).step_by(2) {
|
||||||
|
let val0 = fir[8 + i];
|
||||||
|
let val1 = fir[8 + i + 1];
|
||||||
|
if val0 < -2048 || val0 > 2047 {
|
||||||
|
panic!("i12 FIR coefficient out of bounds: {}", val0)
|
||||||
|
} else if val1 < -2048 || val1 > 2047 {
|
||||||
|
panic!("i12 FIR coefficient out of bounds: {}", val1)
|
||||||
|
}
|
||||||
|
tmp[8 + i * 3 / 2] = (val0 >> 4) as u8;
|
||||||
|
tmp[8 + i * 3 / 2 + 1] = ((val0 << 4) | ((val1 >> 8) & 0x0f)) as u8;
|
||||||
|
tmp[8 + i * 3 / 2 + 2] = val1 as u8;
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 0..TMP_LEN {
|
||||||
|
demod_ctrl_write_register(&self.handle, 1, 0x1c + i as u16, tmp[i] as u16, 1)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_i2c_repeater(&self, enabled: bool) -> Result<()> {
|
||||||
|
let value = match enabled {
|
||||||
|
true => 0x18,
|
||||||
|
false => 0x10,
|
||||||
|
};
|
||||||
|
|
||||||
|
demod_ctrl_write_register(&self.handle, 1, 0x01, value, 1)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,5 +533,18 @@ fn demod_ctrl_write_register<T: UsbContext>(
|
|||||||
let buffer = if length == 1 { &data[1..2] } else { &data };
|
let buffer = if length == 1 { &data[1..2] } else { &data };
|
||||||
let index = 0x10 | page;
|
let index = 0x10 | page;
|
||||||
let address = (address << 8) | 0x20;
|
let address = (address << 8) | 0x20;
|
||||||
|
log::trace!(
|
||||||
|
"Received page {}, address 0x{:04X}, value 0x{:04X}, length {} \
|
||||||
|
- writing control register: {} 0x{:04X} 0x{:04X} {:?}",
|
||||||
|
page,
|
||||||
|
address,
|
||||||
|
value,
|
||||||
|
length,
|
||||||
|
REQ_CTRL_OUT,
|
||||||
|
address,
|
||||||
|
index,
|
||||||
|
buffer
|
||||||
|
);
|
||||||
|
|
||||||
handle.write_control(REQ_CTRL_OUT, 0x00, address, index, buffer, TIMEOUT)
|
handle.write_control(REQ_CTRL_OUT, 0x00, address, index, buffer, TIMEOUT)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,16 +38,20 @@ CREATE TABLE IF NOT EXISTS runways (
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX ON runways (icao);
|
CREATE INDEX ON runways (icao);
|
||||||
|
CREATE INDEX ON runways (runway_id);
|
||||||
CREATE INDEX ON runways (surface);
|
CREATE INDEX ON runways (surface);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS frequencies (
|
CREATE TABLE IF NOT EXISTS frequencies (
|
||||||
id UUID PRIMARY KEY NOT NULL,
|
id UUID PRIMARY KEY NOT NULL,
|
||||||
icao TEXT NOT NULL,
|
icao TEXT NOT NULL,
|
||||||
frequency_id TEXT NOT NULL,
|
frequency_id TEXT NOT NULL,
|
||||||
|
frequency_name TEXT,
|
||||||
frequency_mhz REAL NOT NULL
|
frequency_mhz REAL NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX ON frequencies (icao);
|
CREATE INDEX ON frequencies (icao);
|
||||||
|
CREATE INDEX ON frequencies (frequency_id);
|
||||||
|
CREATE INDEX ON frequencies (frequency_name);
|
||||||
CREATE INDEX ON frequencies (frequency_mhz);
|
CREATE INDEX ON frequencies (frequency_mhz);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS metars (
|
CREATE TABLE IF NOT EXISTS metars (
|
||||||
@@ -61,7 +65,8 @@ CREATE TABLE IF NOT EXISTS metars (
|
|||||||
CREATE INDEX ON metars (observation_time DESC);
|
CREATE INDEX ON metars (observation_time DESC);
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
email TEXT PRIMARY KEY NOT NULL,
|
id UUID NOT NULL,
|
||||||
|
email TEXT NOT NULL,
|
||||||
email_verified BOOLEAN NOT NULL DEFAULT false,
|
email_verified BOOLEAN NOT NULL DEFAULT false,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
role TEXT NOT NULL,
|
role TEXT NOT NULL,
|
||||||
@@ -69,5 +74,6 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
last_name TEXT NOT NULL,
|
last_name TEXT NOT NULL,
|
||||||
avatar TEXT,
|
avatar TEXT,
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
PRIMARY KEY(email)
|
||||||
);
|
);
|
||||||
@@ -34,13 +34,13 @@ impl FromRequest for Auth {
|
|||||||
return Err(Error::new(401, "API Key does not exist".to_string()).into());
|
return Err(Error::new(401, "API Key does not exist".to_string()).into());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
match User::select(&api_key.email).await {
|
match User::select(&api_key.id).await {
|
||||||
Some(user) => Ok(Auth {
|
Some(user) => Ok(Auth {
|
||||||
session_id: None,
|
session_id: None,
|
||||||
api_key: Some(key_id),
|
api_key: Some(key_id),
|
||||||
user,
|
user,
|
||||||
}),
|
}),
|
||||||
None => Err(Error::new(404, format!("User {} not found", api_key.email)).into()),
|
None => Err(Error::new(404, format!("User {} not found", api_key.id)).into()),
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
return Box::pin(fut);
|
return Box::pin(fut);
|
||||||
@@ -79,13 +79,13 @@ impl FromRequest for Auth {
|
|||||||
// Verify the session
|
// Verify the session
|
||||||
let fut = async move {
|
let fut = async move {
|
||||||
match Session::verify(&session_id, &ip_address).await {
|
match Session::verify(&session_id, &ip_address).await {
|
||||||
Ok(session) => match User::select(&session.email).await {
|
Ok(session) => match User::select(&session.id).await {
|
||||||
Some(user) => Ok(Auth {
|
Some(user) => Ok(Auth {
|
||||||
session_id: Some(session_id),
|
session_id: Some(session_id),
|
||||||
api_key: None,
|
api_key: None,
|
||||||
user,
|
user,
|
||||||
}),
|
}),
|
||||||
None => Err(Error::new(404, format!("User {} not found", session.email)).into()),
|
None => Err(Error::new(404, format!("User {} not found", session.id)).into()),
|
||||||
},
|
},
|
||||||
Err(err) => Err(err.into()),
|
Err(err) => Err(err.into()),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,15 +50,16 @@ async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpRespon
|
|||||||
let email = &request.email;
|
let email = &request.email;
|
||||||
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
||||||
|
|
||||||
let query_user = match User::select(&email).await {
|
let query_user = match User::select_by_email(&email).await {
|
||||||
Some(query_user) => query_user,
|
Some(query_user) => query_user,
|
||||||
None => return HttpResponse::Unauthorized().finish(),
|
None => return HttpResponse::Unauthorized().finish(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if verify_hash(&request.password, &query_user.password_hash) {
|
if verify_hash(&request.password, &query_user.password_hash) {
|
||||||
// Create a session
|
// Create a session
|
||||||
let session = Session::default(&email, &ip_address);
|
let session = Session::default(&query_user.id, &ip_address);
|
||||||
let session_cookie = session.cookie();
|
let session_cookie = session.cookie();
|
||||||
|
let session_exp_cookie = session.expiration_cookie();
|
||||||
// Save the session to the database
|
// Save the session to the database
|
||||||
if let Err(err) = session.store().await {
|
if let Err(err) = session.store().await {
|
||||||
log::error!(
|
log::error!(
|
||||||
@@ -77,6 +78,7 @@ async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpRespon
|
|||||||
let user_response: UserResponse = query_user.into();
|
let user_response: UserResponse = query_user.into();
|
||||||
HttpResponse::Ok()
|
HttpResponse::Ok()
|
||||||
.cookie(session_cookie)
|
.cookie(session_cookie)
|
||||||
|
.cookie(session_exp_cookie)
|
||||||
.json(user_response)
|
.json(user_response)
|
||||||
} else {
|
} else {
|
||||||
log::error!(
|
log::error!(
|
||||||
@@ -84,7 +86,10 @@ async fn login(request: web::Json<LoginRequest>, req: HttpRequest) -> HttpRespon
|
|||||||
email,
|
email,
|
||||||
ip_address
|
ip_address
|
||||||
);
|
);
|
||||||
HttpResponse::Unauthorized().finish()
|
HttpResponse::Unauthorized()
|
||||||
|
.cookie(Session::empty_cookie())
|
||||||
|
.cookie(Session::empty_expiration_cookie())
|
||||||
|
.finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,11 +126,68 @@ async fn logout(req: HttpRequest, auth: Auth) -> HttpResponse {
|
|||||||
email,
|
email,
|
||||||
ip_address
|
ip_address
|
||||||
);
|
);
|
||||||
HttpResponse::Ok().cookie(Session::empty_cookie()).finish()
|
HttpResponse::Ok()
|
||||||
|
.cookie(Session::empty_cookie())
|
||||||
|
.cookie(Session::empty_expiration_cookie())
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/profile")]
|
||||||
|
async fn get_profile(req: HttpRequest) -> HttpResponse {
|
||||||
|
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
||||||
|
// Verify a session cookie exists
|
||||||
|
match req.cookie(SESSION_COOKIE_NAME) {
|
||||||
|
// Validate the session
|
||||||
|
Some(cookie) => {
|
||||||
|
let session_id = cookie.value().to_string();
|
||||||
|
let session = match Session::get(&session_id).await {
|
||||||
|
Ok(session) => session,
|
||||||
|
Err(_) => {
|
||||||
|
log::error!(
|
||||||
|
"Invalid profile attempt [Session: {}] [IP Address: {}]",
|
||||||
|
session_id,
|
||||||
|
ip_address
|
||||||
|
);
|
||||||
|
return HttpResponse::Unauthorized()
|
||||||
|
.cookie(Session::empty_cookie())
|
||||||
|
.cookie(Session::empty_expiration_cookie())
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let id = &session.id;
|
||||||
|
let query_user = match User::select(&id).await {
|
||||||
|
Some(query_user) => query_user,
|
||||||
|
None => {
|
||||||
|
return HttpResponse::Unauthorized()
|
||||||
|
.cookie(Session::empty_cookie())
|
||||||
|
.cookie(Session::empty_expiration_cookie())
|
||||||
|
.finish();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let user_response: UserResponse = query_user.into();
|
||||||
|
let session_cookie = session.cookie();
|
||||||
|
let session_exp_cookie = session.expiration_cookie();
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"Successful profile attempt [ID: {}] [IP Address: {}]",
|
||||||
|
id,
|
||||||
|
ip_address
|
||||||
|
);
|
||||||
|
HttpResponse::Ok()
|
||||||
|
.cookie(session_cookie)
|
||||||
|
.cookie(session_exp_cookie)
|
||||||
|
.json(user_response)
|
||||||
|
}
|
||||||
|
None => HttpResponse::Unauthorized()
|
||||||
|
.cookie(Session::empty_cookie())
|
||||||
|
.cookie(Session::empty_expiration_cookie())
|
||||||
|
.finish(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[get("/session")]
|
#[get("/session")]
|
||||||
async fn validate_session(req: HttpRequest) -> HttpResponse {
|
async fn session_refresh(req: HttpRequest) -> HttpResponse {
|
||||||
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
||||||
// Verify a session cookie exists
|
// Verify a session cookie exists
|
||||||
match req.cookie(SESSION_COOKIE_NAME) {
|
match req.cookie(SESSION_COOKIE_NAME) {
|
||||||
@@ -142,33 +204,27 @@ async fn validate_session(req: HttpRequest) -> HttpResponse {
|
|||||||
);
|
);
|
||||||
return HttpResponse::Unauthorized()
|
return HttpResponse::Unauthorized()
|
||||||
.cookie(Session::empty_cookie())
|
.cookie(Session::empty_cookie())
|
||||||
|
.cookie(Session::empty_expiration_cookie())
|
||||||
.finish();
|
.finish();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let email = &session.email;
|
let id = &session.id;
|
||||||
let query_user = match User::select(&email).await {
|
|
||||||
Some(query_user) => query_user,
|
|
||||||
None => {
|
|
||||||
return HttpResponse::Unauthorized()
|
|
||||||
.cookie(Session::empty_cookie())
|
|
||||||
.finish();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let user_response: UserResponse = query_user.into();
|
|
||||||
let session_cookie = session.cookie();
|
let session_cookie = session.cookie();
|
||||||
|
let session_exp_cookie = session.expiration_cookie();
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"Successful session validate attempt [Email: {}] [IP Address: {}]",
|
"Successful session validate attempt [ID: {}] [IP Address: {}]",
|
||||||
email,
|
id,
|
||||||
ip_address
|
ip_address
|
||||||
);
|
);
|
||||||
HttpResponse::Ok()
|
HttpResponse::Ok()
|
||||||
.cookie(session_cookie)
|
.cookie(session_cookie)
|
||||||
.json(user_response)
|
.cookie(session_exp_cookie)
|
||||||
|
.finish()
|
||||||
}
|
}
|
||||||
None => HttpResponse::Unauthorized()
|
None => HttpResponse::Unauthorized()
|
||||||
.cookie(Session::empty_cookie())
|
.cookie(Session::empty_cookie())
|
||||||
|
.cookie(Session::empty_expiration_cookie())
|
||||||
.finish(),
|
.finish(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -180,9 +236,9 @@ async fn change_password(
|
|||||||
auth: Auth,
|
auth: Auth,
|
||||||
) -> HttpResponse {
|
) -> HttpResponse {
|
||||||
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
let ip_address = req.peer_addr().unwrap().ip().to_string();
|
||||||
let email = auth.user.email;
|
let id = auth.user.id;
|
||||||
|
|
||||||
if let None = User::select(&email).await {
|
if let None = User::select(&id).await {
|
||||||
return HttpResponse::Unauthorized().finish();
|
return HttpResponse::Unauthorized().finish();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -196,20 +252,20 @@ async fn change_password(
|
|||||||
avatar: None,
|
avatar: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
match update_user.update(&email).await {
|
match update_user.update(&id).await {
|
||||||
Ok(user) => {
|
Ok(user) => {
|
||||||
let response: UserResponse = user.into();
|
let response: UserResponse = user.into();
|
||||||
log::info!(
|
log::info!(
|
||||||
"Successful password change attempt [Email: {}] [IP Address: {}]",
|
"Successful password change attempt [ID: {}] [IP Address: {}]",
|
||||||
&email,
|
&id,
|
||||||
ip_address
|
ip_address
|
||||||
);
|
);
|
||||||
HttpResponse::Ok().json(response)
|
HttpResponse::Ok().json(response)
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
log::error!(
|
log::error!(
|
||||||
"Invalid password change attempt [Email: {}] [IP Address: {}]: {}",
|
"Invalid password change attempt [ID: {}] [IP Address: {}]: {}",
|
||||||
&email,
|
&id,
|
||||||
ip_address,
|
ip_address,
|
||||||
err
|
err
|
||||||
);
|
);
|
||||||
@@ -231,6 +287,7 @@ pub fn init_routes(config: &mut web::ServiceConfig) {
|
|||||||
.service(login)
|
.service(login)
|
||||||
.service(logout)
|
.service(logout)
|
||||||
.service(change_password)
|
.service(change_password)
|
||||||
.service(validate_session),
|
.service(get_profile)
|
||||||
|
.service(session_refresh),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use redis::{AsyncCommands, RedisResult};
|
use redis::{AsyncCommands, RedisResult};
|
||||||
use tokio::task;
|
use tokio::task;
|
||||||
|
use uuid::Uuid;
|
||||||
use crate::{
|
use crate::{
|
||||||
db::redis_async_connection,
|
db::redis_async_connection,
|
||||||
error::{Error, ApiResult},
|
error::{Error, ApiResult},
|
||||||
@@ -11,26 +12,27 @@ use super::{csprng, hash, verify_hash};
|
|||||||
|
|
||||||
const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours
|
const DEFAULT_SESSION_TTL: i64 = 86400; // (In seconds) 24 hours
|
||||||
pub const SESSION_COOKIE_NAME: &str = "session";
|
pub const SESSION_COOKIE_NAME: &str = "session";
|
||||||
|
pub const SESSION_EXPIRATION_COOKIE_NAME: &str = "session_expiration";
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
pub email: String,
|
pub id: Uuid,
|
||||||
pub ip_address: String,
|
pub ip_address: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub expires_at: Option<DateTime<Utc>>,
|
pub expires_at: Option<DateTime<Utc>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
pub fn default(email: &str, ip_address: &str) -> Self {
|
pub fn default(id: &Uuid, ip_address: &str) -> Self {
|
||||||
Self::new(64, email, ip_address, Some(DEFAULT_SESSION_TTL))
|
Self::new(64, id, ip_address, Some(DEFAULT_SESSION_TTL))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn new(take: usize, email: &str, ip_address: &str, ttl: Option<i64>) -> Self {
|
pub fn new(take: usize, id: &Uuid, ip_address: &str, ttl: Option<i64>) -> Self {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
Self {
|
Self {
|
||||||
session_id: csprng(take),
|
session_id: csprng(take),
|
||||||
email: email.to_string(),
|
id: id.clone(),
|
||||||
ip_address: hash(&ip_address).unwrap(),
|
ip_address: hash(&ip_address).unwrap(),
|
||||||
expires_at: match ttl {
|
expires_at: match ttl {
|
||||||
Some(ttl) => Some(now + chrono::Duration::seconds(ttl)),
|
Some(ttl) => Some(now + chrono::Duration::seconds(ttl)),
|
||||||
@@ -77,7 +79,7 @@ impl Session {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
session = Session::default(&session.email, ip_address);
|
session = Session::default(&session.id, ip_address);
|
||||||
session.store().await?;
|
session.store().await?;
|
||||||
Ok(session)
|
Ok(session)
|
||||||
}
|
}
|
||||||
@@ -118,8 +120,8 @@ impl Session {
|
|||||||
if let Ok(environment) = std::env::var("ENVIRONMENT") {
|
if let Ok(environment) = std::env::var("ENVIRONMENT") {
|
||||||
if environment == "development" || environment == "dev" {
|
if environment == "development" || environment == "dev" {
|
||||||
log::trace!(
|
log::trace!(
|
||||||
"Development cookie [Email: {}]: {}",
|
"Development cookie [ID: {}]: {}",
|
||||||
self.email,
|
self.id,
|
||||||
self.session_id
|
self.session_id
|
||||||
);
|
);
|
||||||
cookie.set_secure(false);
|
cookie.set_secure(false);
|
||||||
@@ -130,6 +132,28 @@ impl Session {
|
|||||||
cookie
|
cookie
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn expiration_cookie(&self) -> Cookie {
|
||||||
|
let expires_at = match self.expires_at {
|
||||||
|
Some(expires_at) => expires_at.timestamp(),
|
||||||
|
None => DEFAULT_SESSION_TTL,
|
||||||
|
};
|
||||||
|
let ttl = expires_at - Utc::now().timestamp();
|
||||||
|
let mut cookie = Cookie::build(SESSION_EXPIRATION_COOKIE_NAME, expires_at.to_string())
|
||||||
|
.path("/")
|
||||||
|
.max_age(Duration::seconds(ttl))
|
||||||
|
.secure(true)
|
||||||
|
.http_only(false)
|
||||||
|
.finish();
|
||||||
|
|
||||||
|
if let Ok(environment) = std::env::var("ENVIRONMENT") {
|
||||||
|
if environment == "development" || environment == "dev" {
|
||||||
|
cookie.set_secure(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie
|
||||||
|
}
|
||||||
|
|
||||||
pub fn empty_cookie() -> Cookie<'static> {
|
pub fn empty_cookie() -> Cookie<'static> {
|
||||||
let mut cookie = Cookie::build(SESSION_COOKIE_NAME, "")
|
let mut cookie = Cookie::build(SESSION_COOKIE_NAME, "")
|
||||||
.path("/")
|
.path("/")
|
||||||
@@ -147,4 +171,21 @@ impl Session {
|
|||||||
|
|
||||||
cookie
|
cookie
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn empty_expiration_cookie() -> Cookie<'static> {
|
||||||
|
let mut cookie = Cookie::build(SESSION_EXPIRATION_COOKIE_NAME, "")
|
||||||
|
.path("/")
|
||||||
|
.max_age(Duration::seconds(-1))
|
||||||
|
.secure(true)
|
||||||
|
.http_only(false)
|
||||||
|
.finish();
|
||||||
|
|
||||||
|
if let Ok(environment) = std::env::var("ENVIRONMENT") {
|
||||||
|
if environment == "development" || environment == "dev" {
|
||||||
|
cookie.set_secure(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
cookie
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const TABLE_NAME: &str = "frequencies";
|
|||||||
pub struct Frequency {
|
pub struct Frequency {
|
||||||
#[serde(rename = "id")]
|
#[serde(rename = "id")]
|
||||||
pub frequency_id: String,
|
pub frequency_id: String,
|
||||||
|
#[serde(rename = "name")]
|
||||||
|
pub frequency_name: Option<String>,
|
||||||
pub frequency_mhz: f32,
|
pub frequency_mhz: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,6 +21,7 @@ pub struct FrequencyRow {
|
|||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub icao: String,
|
pub icao: String,
|
||||||
pub frequency_id: String,
|
pub frequency_id: String,
|
||||||
|
pub frequency_name: Option<String>,
|
||||||
pub frequency_mhz: f32,
|
pub frequency_mhz: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -28,6 +31,8 @@ pub struct UpdateFrequency {
|
|||||||
pub icao: Option<String>,
|
pub icao: Option<String>,
|
||||||
#[serde(rename = "id", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "id", skip_serializing_if = "Option::is_none")]
|
||||||
pub frequency_id: Option<String>,
|
pub frequency_id: Option<String>,
|
||||||
|
#[serde(rename = "name", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub frequency_name: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub frequency_mhz: Option<f32>,
|
pub frequency_mhz: Option<f32>,
|
||||||
}
|
}
|
||||||
@@ -36,6 +41,7 @@ impl From<FrequencyRow> for Frequency {
|
|||||||
fn from(frequency: FrequencyRow) -> Self {
|
fn from(frequency: FrequencyRow) -> Self {
|
||||||
Self {
|
Self {
|
||||||
frequency_id: frequency.frequency_id.clone(),
|
frequency_id: frequency.frequency_id.clone(),
|
||||||
|
frequency_name: frequency.frequency_name.clone(),
|
||||||
frequency_mhz: frequency.frequency_mhz,
|
frequency_mhz: frequency.frequency_mhz,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,6 +53,7 @@ impl Frequency {
|
|||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
icao: icao.to_string(),
|
icao: icao.to_string(),
|
||||||
frequency_id: frequency.frequency_id.clone(),
|
frequency_id: frequency.frequency_id.clone(),
|
||||||
|
frequency_name: frequency.frequency_name.clone(),
|
||||||
frequency_mhz: frequency.frequency_mhz.clone(),
|
frequency_mhz: frequency.frequency_mhz.clone(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -96,13 +103,14 @@ impl Frequency {
|
|||||||
|
|
||||||
for chunk in frequencies.chunks(chunk_size) {
|
for chunk in frequencies.chunks(chunk_size) {
|
||||||
let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(&format!(
|
let mut query_builder: QueryBuilder<Postgres> = QueryBuilder::new(&format!(
|
||||||
"INSERT INTO {} (id, icao, frequency_id, frequency_mhz) ",
|
"INSERT INTO {} (id, icao, frequency_id, frequency_name, frequency_mhz) ",
|
||||||
TABLE_NAME
|
TABLE_NAME
|
||||||
));
|
));
|
||||||
query_builder.push_values(chunk, |mut b, row| {
|
query_builder.push_values(chunk, |mut b, row| {
|
||||||
b.push_bind(&row.id)
|
b.push_bind(&row.id)
|
||||||
.push_bind(&row.icao)
|
.push_bind(&row.icao)
|
||||||
.push_bind(&row.frequency_id)
|
.push_bind(&row.frequency_id)
|
||||||
|
.push_bind(&row.frequency_name)
|
||||||
.push_bind(&row.frequency_mhz);
|
.push_bind(&row.frequency_mhz);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ pub async fn initialize() -> ApiResult<()> {
|
|||||||
let password = std::env::var("POSTGRES_PASSWORD").expect("POSTGRES_PASSWORD must be set");
|
let password = std::env::var("POSTGRES_PASSWORD").expect("POSTGRES_PASSWORD must be set");
|
||||||
let host: String = std::env::var("POSTGRES_HOST").expect("POSTGRES_HOST must be set");
|
let host: String = std::env::var("POSTGRES_HOST").expect("POSTGRES_HOST must be set");
|
||||||
let port = std::env::var("POSTGRES_PORT").unwrap_or("5432".to_string());
|
let port = std::env::var("POSTGRES_PORT").unwrap_or("5432".to_string());
|
||||||
let name = std::env::var("POSTGRES_NAME").unwrap_or("aviation".to_string());
|
let name = std::env::var("POSTGRES_DB").unwrap_or("aviation_db".to_string());
|
||||||
|
|
||||||
let db_url = format!(
|
let db_url = format!(
|
||||||
"postgres://{}:{}@{}:{}/{}",
|
"postgres://{}:{}@{}:{}/{}",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ use actix_cors::Cors;
|
|||||||
use actix_web::{App, HttpServer, middleware::Logger, web};
|
use actix_web::{App, HttpServer, middleware::Logger, web};
|
||||||
use dotenv::from_filename;
|
use dotenv::from_filename;
|
||||||
use reqwest::Certificate;
|
use reqwest::Certificate;
|
||||||
|
use uuid::Uuid;
|
||||||
use crate::account::hash;
|
use crate::account::hash;
|
||||||
use crate::users::{User, ADMIN_ROLE};
|
use crate::users::{User, ADMIN_ROLE};
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
let admin_password = env::var("ADMIN_PASSWORD");
|
let admin_password = env::var("ADMIN_PASSWORD");
|
||||||
if admin_email.is_ok() && admin_password.is_ok() {
|
if admin_email.is_ok() && admin_password.is_ok() {
|
||||||
let email = admin_email.unwrap();
|
let email = admin_email.unwrap();
|
||||||
if User::select(&email).await.is_none() {
|
if User::select_by_email(&email).await.is_none() {
|
||||||
log::debug!("Creating default administrator");
|
log::debug!("Creating default administrator");
|
||||||
let password = admin_password.unwrap();
|
let password = admin_password.unwrap();
|
||||||
let password_hash = hash(&password)?;
|
let password_hash = hash(&password)?;
|
||||||
@@ -42,6 +43,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
let admin_user = User {
|
let admin_user = User {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
email,
|
email,
|
||||||
email_verified: true,
|
email_verified: true,
|
||||||
password_hash,
|
password_hash,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ impl RegisterRequest {
|
|||||||
pub fn to_user(self) -> ApiResult<User> {
|
pub fn to_user(self) -> ApiResult<User> {
|
||||||
let password_hash = hash(&self.password)?;
|
let password_hash = hash(&self.password)?;
|
||||||
Ok(User {
|
Ok(User {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
email: self.email.to_lowercase(),
|
email: self.email.to_lowercase(),
|
||||||
email_verified: false,
|
email_verified: false,
|
||||||
password_hash,
|
password_hash,
|
||||||
@@ -42,17 +43,19 @@ pub struct LoginRequest {
|
|||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct UserResponse {
|
pub struct UserResponse {
|
||||||
pub email_verified: bool,
|
pub id: Uuid,
|
||||||
pub role: String,
|
pub role: String,
|
||||||
pub first_name: String,
|
pub first_name: String,
|
||||||
pub last_name: String,
|
pub last_name: String,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub avatar: Option<String>,
|
pub avatar: Option<String>,
|
||||||
|
pub email_verified: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<User> for UserResponse {
|
impl From<User> for UserResponse {
|
||||||
fn from(user: User) -> Self {
|
fn from(user: User) -> Self {
|
||||||
UserResponse {
|
UserResponse {
|
||||||
|
id: user.id,
|
||||||
email_verified: user.email_verified,
|
email_verified: user.email_verified,
|
||||||
role: user.role,
|
role: user.role,
|
||||||
first_name: user.first_name,
|
first_name: user.first_name,
|
||||||
@@ -74,7 +77,7 @@ pub struct UpdateUser {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl UpdateUser {
|
impl UpdateUser {
|
||||||
pub async fn update(&self, email: &str) -> ApiResult<User> {
|
pub async fn update(&self, id: &Uuid) -> ApiResult<User> {
|
||||||
let pool = db::pool();
|
let pool = db::pool();
|
||||||
|
|
||||||
let mut query_builder: QueryBuilder<Postgres> =
|
let mut query_builder: QueryBuilder<Postgres> =
|
||||||
@@ -130,8 +133,8 @@ impl UpdateUser {
|
|||||||
query_builder.push("updated_at = ");
|
query_builder.push("updated_at = ");
|
||||||
query_builder.push_bind(Utc::now());
|
query_builder.push_bind(Utc::now());
|
||||||
|
|
||||||
query_builder.push(" WHERE email = ");
|
query_builder.push(" WHERE id = ");
|
||||||
query_builder.push_bind(email.to_string());
|
query_builder.push_bind(id);
|
||||||
query_builder.push(" RETURNING *");
|
query_builder.push(" RETURNING *");
|
||||||
|
|
||||||
let query = query_builder.build_query_as::<User>();
|
let query = query_builder.build_query_as::<User>();
|
||||||
@@ -143,6 +146,7 @@ impl UpdateUser {
|
|||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
|
||||||
pub struct User {
|
pub struct User {
|
||||||
|
pub id: Uuid,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub email_verified: bool,
|
pub email_verified: bool,
|
||||||
pub password_hash: String,
|
pub password_hash: String,
|
||||||
@@ -155,7 +159,26 @@ pub struct User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl User {
|
impl User {
|
||||||
pub async fn select(email: &str) -> Option<Self> {
|
pub async fn select(id: &Uuid) -> Option<Self> {
|
||||||
|
let pool = db::pool();
|
||||||
|
let user: Option<Self> = sqlx::query_as::<_, Self>(&format!(
|
||||||
|
r#"
|
||||||
|
SELECT * FROM {} WHERE id = $1
|
||||||
|
"#,
|
||||||
|
TABLE_NAME
|
||||||
|
))
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|err| {
|
||||||
|
log::error!("Unable to find user by id '{}': {}", id, err);
|
||||||
|
None
|
||||||
|
});
|
||||||
|
|
||||||
|
user
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn select_by_email(email: &str) -> Option<Self> {
|
||||||
let pool = db::pool();
|
let pool = db::pool();
|
||||||
let user: Option<Self> = sqlx::query_as::<_, Self>(&format!(
|
let user: Option<Self> = sqlx::query_as::<_, Self>(&format!(
|
||||||
r#"
|
r#"
|
||||||
@@ -167,7 +190,7 @@ impl User {
|
|||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
.await
|
.await
|
||||||
.unwrap_or_else(|err| {
|
.unwrap_or_else(|err| {
|
||||||
log::error!("Unable to find user '{}': {}", email, err);
|
log::error!("Unable to find user by email '{}': {}", email, err);
|
||||||
None
|
None
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -193,6 +216,7 @@ impl User {
|
|||||||
let user: User = sqlx::query_as::<_, Self>(&format!(
|
let user: User = sqlx::query_as::<_, Self>(&format!(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO {} (
|
INSERT INTO {} (
|
||||||
|
id,
|
||||||
email,
|
email,
|
||||||
email_verified,
|
email_verified,
|
||||||
password_hash,
|
password_hash,
|
||||||
@@ -203,11 +227,12 @@ impl User {
|
|||||||
created_at,
|
created_at,
|
||||||
updated_at
|
updated_at
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"#,
|
"#,
|
||||||
TABLE_NAME,
|
TABLE_NAME,
|
||||||
))
|
))
|
||||||
|
.bind(&self.id)
|
||||||
.bind(&self.email)
|
.bind(&self.email)
|
||||||
.bind(&self.email_verified)
|
.bind(&self.email_verified)
|
||||||
.bind(&self.password_hash)
|
.bind(&self.password_hash)
|
||||||
|
|||||||
@@ -11,5 +11,5 @@ post {
|
|||||||
}
|
}
|
||||||
|
|
||||||
body:multipart-form {
|
body:multipart-form {
|
||||||
: @file(/Users/bsherriff/git/private/aviation/data/airports_2023-12-21.json)
|
: @file(/Users/bsherriff/git/private/aviation/data/2025-05-13_airports.json)
|
||||||
}
|
}
|
||||||
|
|||||||
11
bruno/Users/Get Profile.bru
Normal file
11
bruno/Users/Get Profile.bru
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
meta {
|
||||||
|
name: Get Profile
|
||||||
|
type: http
|
||||||
|
seq: 6
|
||||||
|
}
|
||||||
|
|
||||||
|
get {
|
||||||
|
url: {{API_URL}}/account/profile
|
||||||
|
body: none
|
||||||
|
auth: none
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
meta {
|
meta {
|
||||||
name: Validate Session
|
name: Refresh Session
|
||||||
type: http
|
type: http
|
||||||
seq: 5
|
seq: 5
|
||||||
}
|
}
|
||||||
@@ -309321,7 +309321,7 @@
|
|||||||
"iso_country": "US",
|
"iso_country": "US",
|
||||||
"iso_region": "US-VA",
|
"iso_region": "US-VA",
|
||||||
"municipality": "Leesburg",
|
"municipality": "Leesburg",
|
||||||
"elevation_ft": 389.0,
|
"elevation_ft": 389.5,
|
||||||
"latitude": 39.077999,
|
"latitude": 39.077999,
|
||||||
"longitude": -77.557503,
|
"longitude": -77.557503,
|
||||||
"has_tower": true,
|
"has_tower": true,
|
||||||
@@ -309332,13 +309332,34 @@
|
|||||||
"id": "17/35",
|
"id": "17/35",
|
||||||
"length_ft": 5500.0,
|
"length_ft": 5500.0,
|
||||||
"width_ft": 100.0,
|
"width_ft": 100.0,
|
||||||
"surface": "A"
|
"surface": "ASPH"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"frequencies": [
|
"frequencies": [
|
||||||
{
|
{
|
||||||
"id": "LCL/P",
|
"id": "LCL/P",
|
||||||
|
"name": "Tower",
|
||||||
"frequency_mhz": 127.5
|
"frequency_mhz": 127.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "WX AWOS-3",
|
||||||
|
"name": "Weather",
|
||||||
|
"frequency_mhz": 125.225
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "GND/P",
|
||||||
|
"name": "Ground",
|
||||||
|
"frequency_mhz": 120.5
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "CD/P",
|
||||||
|
"name": "Potomac Clearance Delivery",
|
||||||
|
"frequency_mhz": 118.55
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "APCH/P DEP/P",
|
||||||
|
"name": "Potomac Approach",
|
||||||
|
"frequency_mhz": 125.05
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -36,7 +36,7 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER}
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
POSTGRES_DB: ${POSTGRES_NAME}
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres:/var/lib/postgresql/data
|
- postgres:/var/lib/postgresql/data
|
||||||
- postgres_logs:/var/log
|
- postgres_logs:/var/log
|
||||||
|
|||||||
@@ -14,40 +14,15 @@
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-button {
|
.custom-control a.active {
|
||||||
position: absolute;
|
|
||||||
right: 12px;
|
|
||||||
z-index: 1000;
|
|
||||||
|
|
||||||
color: #000;
|
|
||||||
background: #fff;
|
|
||||||
border-radius: 3px;
|
|
||||||
border: 1px solid #ccc;
|
|
||||||
border-bottom-width: 2px;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.15);
|
|
||||||
|
|
||||||
height: 30px;
|
|
||||||
width: 30px;
|
|
||||||
text-align: center;
|
|
||||||
line-height: 30px; /* Vertically center text */
|
|
||||||
font-weight: bold;
|
|
||||||
|
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
transition:
|
|
||||||
background-color 0.2s,
|
|
||||||
color 0.2s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-button.active {
|
|
||||||
background-color: #228be6;
|
background-color: #228be6;
|
||||||
color: #fff;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
.map-button.active:hover {
|
.custom-control a {
|
||||||
background-color: #187ed7;
|
padding: 0;
|
||||||
}
|
line-height: 1;
|
||||||
|
display: flex;
|
||||||
.map-button:hover {
|
align-items: center;
|
||||||
background: #e6e6e6;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { LayersControl, MapContainer, TileLayer, useMapEvents, ZoomControl } from 'react-leaflet';
|
import { LayersControl, MapContainer, ScaleControl, TileLayer, useMapEvents, ZoomControl } from 'react-leaflet';
|
||||||
import '@mantine/core/styles.css';
|
import '@mantine/core/styles.css';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
@@ -13,9 +13,8 @@ import { Airport } from '@lib/airport.types.ts';
|
|||||||
import Index from '@components/AirportDrawer';
|
import Index from '@components/AirportDrawer';
|
||||||
import { getWeatherMapUrl } from '@lib/rainViewer.ts';
|
import { getWeatherMapUrl } from '@lib/rainViewer.ts';
|
||||||
import Cookies from 'js-cookie';
|
import Cookies from 'js-cookie';
|
||||||
// import { createRoot } from 'react-dom/client';
|
|
||||||
import { UnstyledButton } from '@mantine/core';
|
|
||||||
import { IconBuildingAirport, IconRadar } from '@tabler/icons-react';
|
import { IconBuildingAirport, IconRadar } from '@tabler/icons-react';
|
||||||
|
import { GroupControl } from '@components/GroupControl.tsx';
|
||||||
// Fix Leaflet's default icon path issues with Webpack
|
// Fix Leaflet's default icon path issues with Webpack
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||||
// @ts-expect-error
|
// @ts-expect-error
|
||||||
@@ -43,63 +42,6 @@ const layerMap: LayerInfo[] = [
|
|||||||
const defaultZoom = 6;
|
const defaultZoom = 6;
|
||||||
const defaultCenter: L.LatLngExpression = [38.944444, -77.455833];
|
const defaultCenter: L.LatLngExpression = [38.944444, -77.455833];
|
||||||
|
|
||||||
// function CustomControl({ toggleRadar, showRadar, toggleShowNoMetar, showNoMetar }) {
|
|
||||||
// const map = useMap();
|
|
||||||
//
|
|
||||||
// useEffect(() => {
|
|
||||||
// const CustomLeafletControl = L.Control.extend({
|
|
||||||
// options: { position: 'bottomright' },
|
|
||||||
// onAdd: function () {
|
|
||||||
// // Create a container for the control
|
|
||||||
// const container = L.DomUtil.create('div', 'leaflet-bar custom-control');
|
|
||||||
//
|
|
||||||
// // Radar button
|
|
||||||
// const radarButton = L.DomUtil.create('button', 'control-button radar-button', container);
|
|
||||||
// // radarButton.innerHTML = 'Radar';
|
|
||||||
// // if (showRadar) {
|
|
||||||
// // radarButton.classList.add('active');
|
|
||||||
// // }
|
|
||||||
// const radarRoot = createRoot(radarButton);
|
|
||||||
// radarRoot.render(
|
|
||||||
// <IconRadar
|
|
||||||
// style={{ width: '24px', height: '24px', color: showRadar ? 'blue' : 'black' }}
|
|
||||||
// />
|
|
||||||
// );
|
|
||||||
// // Prevent click events from propagating to the map
|
|
||||||
// L.DomEvent.disableClickPropagation(radarButton);
|
|
||||||
// L.DomEvent.on(radarButton, 'click', (e) => {
|
|
||||||
// L.DomEvent.stopPropagation(e);
|
|
||||||
// toggleRadar();
|
|
||||||
// });
|
|
||||||
//
|
|
||||||
// // Airports (no METARs) button
|
|
||||||
// const airportButton = L.DomUtil.create('button', 'control-button', container);
|
|
||||||
// airportButton.innerHTML = 'Airports';
|
|
||||||
// if (showNoMetar) {
|
|
||||||
// airportButton.classList.add('active');
|
|
||||||
// }
|
|
||||||
// L.DomEvent.disableClickPropagation(airportButton);
|
|
||||||
// L.DomEvent.on(airportButton, 'click', (e) => {
|
|
||||||
// L.DomEvent.stopPropagation(e);
|
|
||||||
// toggleShowNoMetar();
|
|
||||||
// });
|
|
||||||
//
|
|
||||||
// return container;
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
//
|
|
||||||
// const customControl = new CustomLeafletControl();
|
|
||||||
// customControl.addTo(map);
|
|
||||||
//
|
|
||||||
// // Remove control on cleanup
|
|
||||||
// return () => {
|
|
||||||
// customControl.remove();
|
|
||||||
// };
|
|
||||||
// }, [map, toggleRadar, toggleShowNoMetar, showRadar, showNoMetar]);
|
|
||||||
//
|
|
||||||
// return null;
|
|
||||||
// }
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [airport, setAirport] = useState<Airport | null>(null);
|
const [airport, setAirport] = useState<Airport | null>(null);
|
||||||
const [rainViewerUrl, setRainViewerUrl] = useState<string | null>(null);
|
const [rainViewerUrl, setRainViewerUrl] = useState<string | null>(null);
|
||||||
@@ -172,31 +114,28 @@ function App() {
|
|||||||
</LayersControl.BaseLayer>
|
</LayersControl.BaseLayer>
|
||||||
))}
|
))}
|
||||||
</LayersControl>
|
</LayersControl>
|
||||||
|
<ScaleControl />
|
||||||
{rainViewerUrl && showRadar && <TileLayer url={rainViewerUrl} opacity={0.5} zIndex={10} />}
|
{rainViewerUrl && showRadar && <TileLayer url={rainViewerUrl} opacity={0.5} zIndex={10} />}
|
||||||
<ZoomControl position={'bottomright'} />
|
<ZoomControl position={'bottomright'} />
|
||||||
<AirportLayer setAirport={setAirport} selectedLayer={selectedLayer} showNoMetar={showNoMetar} />
|
<AirportLayer setAirport={setAirport} selectedLayer={selectedLayer} showNoMetar={showNoMetar} />
|
||||||
<BaseLayerChangeHandler />
|
<BaseLayerChangeHandler />
|
||||||
{/*<CustomControl*/}
|
<GroupControl
|
||||||
{/* toggleRadar={toggleRadar}*/}
|
buttons={[
|
||||||
{/* showRadar={showRadar}*/}
|
{
|
||||||
{/* toggleShowNoMetar={toggleShowNoMetar}*/}
|
title: 'Toggle radar',
|
||||||
{/* showNoMetar={showNoMetar}*/}
|
active: showRadar,
|
||||||
{/*/>*/}
|
onClick: toggleRadar,
|
||||||
|
icon: <IconRadar />
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Toggle non‐METAR airports',
|
||||||
|
active: showNoMetar,
|
||||||
|
onClick: toggleShowNoMetar,
|
||||||
|
icon: <IconBuildingAirport />
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
/>
|
||||||
</MapContainer>
|
</MapContainer>
|
||||||
<UnstyledButton
|
|
||||||
onClick={toggleShowNoMetar}
|
|
||||||
style={{ bottom: '120px' }}
|
|
||||||
className={`map-button ${showNoMetar ? 'active' : ''}`}
|
|
||||||
>
|
|
||||||
<IconBuildingAirport />
|
|
||||||
</UnstyledButton>
|
|
||||||
<UnstyledButton
|
|
||||||
onClick={toggleRadar}
|
|
||||||
style={{ bottom: '80px' }}
|
|
||||||
className={`map-button ${showRadar ? 'active' : ''}`}
|
|
||||||
>
|
|
||||||
<IconRadar />
|
|
||||||
</UnstyledButton>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Header } from '@components/Header';
|
import { Header } from '@components/Header';
|
||||||
import { Navigate } from 'react-router';
|
|
||||||
import { useUserContext } from '@components/context/UserContext.tsx';
|
import { useUserContext } from '@components/context/UserContext.tsx';
|
||||||
import { AirportTable } from '@components/AirportTable';
|
import { AirportTable } from '@components/AirportTable';
|
||||||
import { AirportDrop } from '@components/AirportDrop';
|
import { AirportDrop } from '@components/AirportDrop';
|
||||||
|
import { NotFound } from '@components/NotFound';
|
||||||
|
|
||||||
export function Administration() {
|
export function Administration() {
|
||||||
const { user } = useUserContext();
|
const { user } = useUserContext();
|
||||||
|
|
||||||
if (user == undefined) {
|
if (user == undefined || user.role != 'ADMIN') {
|
||||||
return <Navigate to={'/'} />;
|
return <NotFound />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
25
ui/src/components/AirportDrawer/FrequencyTable.tsx
Normal file
25
ui/src/components/AirportDrawer/FrequencyTable.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { Table } from '@mantine/core';
|
||||||
|
import { Frequency } from '@lib/airport.types.ts';
|
||||||
|
|
||||||
|
export default function FrequencyTable({ frequencies }: { frequencies: Frequency[] }) {
|
||||||
|
const rows = frequencies.map((frequency) => (
|
||||||
|
<Table.Tr key={frequency.id}>
|
||||||
|
<Table.Td>{frequency.id}</Table.Td>
|
||||||
|
<Table.Td>{frequency.name}</Table.Td>
|
||||||
|
<Table.Td>{frequency.frequency_mhz}</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>ID</Table.Th>
|
||||||
|
<Table.Th>Name</Table.Th>
|
||||||
|
<Table.Th>MHz</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>{rows}</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
27
ui/src/components/AirportDrawer/RunwayTable.tsx
Normal file
27
ui/src/components/AirportDrawer/RunwayTable.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Table } from '@mantine/core';
|
||||||
|
import { Runway } from '@lib/airport.types.ts';
|
||||||
|
|
||||||
|
export default function RunwayTable({ runways }: { runways: Runway[] }) {
|
||||||
|
const rows = runways.map((runway) => (
|
||||||
|
<Table.Tr key={runway.id}>
|
||||||
|
<Table.Td>{runway.id}</Table.Td>
|
||||||
|
<Table.Td>{runway.surface}</Table.Td>
|
||||||
|
<Table.Td>{runway.length_ft}</Table.Td>
|
||||||
|
<Table.Td>{runway.width_ft}</Table.Td>
|
||||||
|
</Table.Tr>
|
||||||
|
));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table>
|
||||||
|
<Table.Thead>
|
||||||
|
<Table.Tr>
|
||||||
|
<Table.Th>ID</Table.Th>
|
||||||
|
<Table.Th>Surface</Table.Th>
|
||||||
|
<Table.Th>Length (ft)</Table.Th>
|
||||||
|
<Table.Th>Width (ft)</Table.Th>
|
||||||
|
</Table.Tr>
|
||||||
|
</Table.Thead>
|
||||||
|
<Table.Tbody>{rows}</Table.Tbody>
|
||||||
|
</Table>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,24 @@
|
|||||||
import { Badge, Box, Divider, Drawer, Group, Tabs, TabsList, Text, Tooltip, UnstyledButton } from '@mantine/core';
|
import {
|
||||||
|
Accordion,
|
||||||
|
Badge,
|
||||||
|
Box,
|
||||||
|
Divider,
|
||||||
|
Drawer,
|
||||||
|
Group,
|
||||||
|
Tabs,
|
||||||
|
TabsList,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
UnstyledButton
|
||||||
|
} from '@mantine/core';
|
||||||
import { Airport, AirportCategory } from '@lib/airport.types.ts';
|
import { Airport, AirportCategory } from '@lib/airport.types.ts';
|
||||||
import { getMarkerColor, Metar } from '@lib/metar.types.ts';
|
import { getMarkerColor, Metar } from '@lib/metar.types.ts';
|
||||||
import { CSSProperties, forwardRef, ReactNode, useEffect, useState } from 'react';
|
import { CSSProperties, forwardRef, ReactNode, useEffect, useState } from 'react';
|
||||||
import { getMetars } from '@lib/metar.ts';
|
import { getMetars } from '@lib/metar.ts';
|
||||||
import { useMediaQuery } from '@mantine/hooks';
|
import { useMediaQuery } from '@mantine/hooks';
|
||||||
import { IconViewfinder } from '@tabler/icons-react';
|
import { IconViewfinder } from '@tabler/icons-react';
|
||||||
|
import RunwayTable from '@components/AirportDrawer/RunwayTable.tsx';
|
||||||
|
import FrequencyTable from '@components/AirportDrawer/FrequencyTable.tsx';
|
||||||
|
|
||||||
export default function Index({
|
export default function Index({
|
||||||
airport,
|
airport,
|
||||||
@@ -155,6 +169,28 @@ function AirportInfo({ airport }: { airport: Airport }) {
|
|||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
</AirportInfoSlot>
|
</AirportInfoSlot>
|
||||||
</AirportInfoRow>
|
</AirportInfoRow>
|
||||||
|
<Accordion chevronPosition={'right'} variant={'contained'}>
|
||||||
|
{airport.runways != null && airport.runways.length > 0 && (
|
||||||
|
<Accordion.Item value={'runways'}>
|
||||||
|
<Accordion.Control>
|
||||||
|
Runways
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<RunwayTable runways={airport.runways} />
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
)}
|
||||||
|
{airport.frequencies != null && airport.frequencies.length > 0 && (
|
||||||
|
<Accordion.Item value={'frequencies'}>
|
||||||
|
<Accordion.Control>
|
||||||
|
Frequencies
|
||||||
|
</Accordion.Control>
|
||||||
|
<Accordion.Panel>
|
||||||
|
<FrequencyTable frequencies={airport.frequencies} />
|
||||||
|
</Accordion.Panel>
|
||||||
|
</Accordion.Item>
|
||||||
|
)}
|
||||||
|
</Accordion>
|
||||||
<Divider />
|
<Divider />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -20,9 +20,9 @@ export function AirportDrop() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
files.forEach(file => {
|
files.forEach((file) => {
|
||||||
formData.append('files', file, file.name);
|
formData.append('files', file, file.name);
|
||||||
})
|
});
|
||||||
await importAirports(formData);
|
await importAirports(formData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Upload error:', error);
|
console.error('Upload error:', error);
|
||||||
@@ -31,12 +31,12 @@ export function AirportDrop() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className={classes.dropzone}
|
className={classes.dropzone}
|
||||||
radius="md"
|
radius='md'
|
||||||
accept={['application/JSON']}
|
accept={['application/JSON']}
|
||||||
maxSize={30 * 1024 ** 2}
|
maxSize={30 * 1024 ** 2}
|
||||||
>
|
>
|
||||||
<div style={{ pointerEvents: 'none' }}>
|
<div style={{ pointerEvents: 'none' }}>
|
||||||
<Group justify="center">
|
<Group justify='center'>
|
||||||
<Dropzone.Accept>
|
<Dropzone.Accept>
|
||||||
<IconDownload size={50} color={theme.colors.blue[6]} stroke={1.5} />
|
<IconDownload size={50} color={theme.colors.blue[6]} stroke={1.5} />
|
||||||
</Dropzone.Accept>
|
</Dropzone.Accept>
|
||||||
@@ -48,20 +48,20 @@ export function AirportDrop() {
|
|||||||
</Dropzone.Idle>
|
</Dropzone.Idle>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Text ta="center" fw={700} fz="lg" mt="xl">
|
<Text ta='center' fw={700} fz='lg' mt='xl'>
|
||||||
<Dropzone.Accept>Drop files here</Dropzone.Accept>
|
<Dropzone.Accept>Drop files here</Dropzone.Accept>
|
||||||
<Dropzone.Reject>Json file less than 30mb</Dropzone.Reject>
|
<Dropzone.Reject>Json file less than 30mb</Dropzone.Reject>
|
||||||
<Dropzone.Idle>Upload JSON</Dropzone.Idle>
|
<Dropzone.Idle>Upload JSON</Dropzone.Idle>
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Text className={classes.description}>
|
<Text className={classes.description}>
|
||||||
Drag'n'drop files here to upload. We can accept only <i>.json</i> files that
|
Drag'n'drop files here to upload. We can accept only <i>.json</i> files that are less than 30mb in
|
||||||
are less than 30mb in size.
|
size.
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Dropzone>
|
</Dropzone>
|
||||||
|
|
||||||
<Button className={classes.control} size="md" radius="xl" onClick={() => openRef.current?.()}>
|
<Button className={classes.control} size='md' radius='xl' onClick={() => openRef.current?.()}>
|
||||||
Select files
|
Select files
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ export function AirportTable() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const limit = 1000;
|
const limit = 1000;
|
||||||
getAirports({ page, limit }).then(r => {
|
getAirports({ page, limit }).then((r) => {
|
||||||
setData(r.data);
|
setData(r.data);
|
||||||
setTotalPages(r.total / r.data.length);
|
setTotalPages(r.total / r.data.length);
|
||||||
});
|
});
|
||||||
},[page]);
|
}, [page]);
|
||||||
|
|
||||||
const rows = data.map((row, idx) => (
|
const rows = data.map((row, idx) => (
|
||||||
<Table.Tr key={idx}>
|
<Table.Tr key={idx}>
|
||||||
@@ -43,14 +43,8 @@ export function AirportTable() {
|
|||||||
<Table.Tbody>{rows}</Table.Tbody>
|
<Table.Tbody>{rows}</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
<Center mt="sm">
|
<Center mt='sm'>
|
||||||
<Pagination
|
<Pagination value={page} onChange={setPage} total={totalPages} siblings={1} boundaries={1} />
|
||||||
value={page}
|
|
||||||
onChange={setPage}
|
|
||||||
total={totalPages}
|
|
||||||
siblings={1}
|
|
||||||
boundaries={1}
|
|
||||||
/>
|
|
||||||
</Center>
|
</Center>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
75
ui/src/components/CustomControl.tsx
Normal file
75
ui/src/components/CustomControl.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { ReactNode, useEffect, useRef } from 'react';
|
||||||
|
import * as L from 'leaflet';
|
||||||
|
import { useMap } from 'react-leaflet';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
position?: L.ControlPosition;
|
||||||
|
onClick: () => void;
|
||||||
|
active?: boolean;
|
||||||
|
title?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CustomControl({ position = 'bottomright', onClick, active = false, title = '', children }: Props) {
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
// Create references
|
||||||
|
const buttonRef = useRef<HTMLAnchorElement | null>(null);
|
||||||
|
const reactRootRef = useRef<Root | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ctrl = new L.Control({ position });
|
||||||
|
|
||||||
|
ctrl.onAdd = () => {
|
||||||
|
const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control custom-control');
|
||||||
|
const button = L.DomUtil.create('a', '', container) as HTMLAnchorElement;
|
||||||
|
button.href = '#';
|
||||||
|
button.title = title;
|
||||||
|
|
||||||
|
// Prevent clicks/scrolls on the control from hitting the map
|
||||||
|
L.DomEvent.disableClickPropagation(container);
|
||||||
|
L.DomEvent.disableScrollPropagation(container);
|
||||||
|
|
||||||
|
// Wire up the handler
|
||||||
|
L.DomEvent.on(button, 'click', L.DomEvent.stop);
|
||||||
|
L.DomEvent.on(button, 'click', L.DomEvent.preventDefault);
|
||||||
|
L.DomEvent.on(button, 'click', () => onClick());
|
||||||
|
|
||||||
|
buttonRef.current = button;
|
||||||
|
|
||||||
|
// Initial active status
|
||||||
|
if (active) {
|
||||||
|
button.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render children
|
||||||
|
if (children) {
|
||||||
|
reactRootRef.current = createRoot(button);
|
||||||
|
reactRootRef.current.render(children);
|
||||||
|
}
|
||||||
|
return container;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add component to the map
|
||||||
|
ctrl.addTo(map);
|
||||||
|
|
||||||
|
// On unmount, remove component
|
||||||
|
return () => {
|
||||||
|
ctrl.remove();
|
||||||
|
if (reactRootRef.current) {
|
||||||
|
reactRootRef.current.unmount();
|
||||||
|
reactRootRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [map, position, onClick, children, active, title]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const btn = buttonRef.current;
|
||||||
|
if (!btn) return;
|
||||||
|
if (active) btn.classList.add('active');
|
||||||
|
else btn.classList.remove('active');
|
||||||
|
}, [active]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
75
ui/src/components/GroupControl.tsx
Normal file
75
ui/src/components/GroupControl.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
import * as L from 'leaflet';
|
||||||
|
import { useMap } from 'react-leaflet';
|
||||||
|
import { createRoot, Root } from 'react-dom/client';
|
||||||
|
|
||||||
|
export interface ButtonDef {
|
||||||
|
title: string;
|
||||||
|
active: boolean;
|
||||||
|
onClick: () => void;
|
||||||
|
icon: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GroupControlProps {
|
||||||
|
position?: L.ControlPosition;
|
||||||
|
buttons: ButtonDef[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupControl({ position = 'bottomright', buttons }: GroupControlProps) {
|
||||||
|
const map = useMap();
|
||||||
|
// References
|
||||||
|
const buttonRefs = useRef<HTMLAnchorElement[]>([]);
|
||||||
|
const reactRootRefs = useRef<Root[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const ctrl = new L.Control({ position });
|
||||||
|
const current = reactRootRefs.current;
|
||||||
|
|
||||||
|
ctrl.onAdd = () => {
|
||||||
|
const container = L.DomUtil.create('div', 'leaflet-bar leaflet-control custom-control');
|
||||||
|
buttons.forEach((btnDef, i) => {
|
||||||
|
const btn = L.DomUtil.create('a', '', container) as HTMLAnchorElement;
|
||||||
|
btn.href = '#';
|
||||||
|
btn.title = btnDef.title;
|
||||||
|
|
||||||
|
// standard leaflet click‐blocking magic
|
||||||
|
L.DomEvent.disableClickPropagation(btn);
|
||||||
|
L.DomEvent.disableScrollPropagation(btn);
|
||||||
|
L.DomEvent.on(btn, 'click', L.DomEvent.stop)
|
||||||
|
.on(btn, 'click', L.DomEvent.preventDefault)
|
||||||
|
.on(btn, 'click', btnDef.onClick);
|
||||||
|
|
||||||
|
// Initial active status
|
||||||
|
if (btnDef.active) btn.classList.add('active');
|
||||||
|
|
||||||
|
// Render root
|
||||||
|
const rootRef = createRoot(btn);
|
||||||
|
rootRef.render(btnDef.icon);
|
||||||
|
reactRootRefs.current[i] = rootRef;
|
||||||
|
buttonRefs.current[i] = btn;
|
||||||
|
});
|
||||||
|
return container;
|
||||||
|
};
|
||||||
|
|
||||||
|
ctrl.addTo(map);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
ctrl.remove();
|
||||||
|
// unmount React roots
|
||||||
|
current.forEach((r) => r.unmount());
|
||||||
|
};
|
||||||
|
}, [map, buttons, position]);
|
||||||
|
|
||||||
|
// if you want to toggle “.active” live when props change
|
||||||
|
useEffect(() => {
|
||||||
|
buttons.forEach((b, i) => {
|
||||||
|
const btn = buttonRefs.current[i];
|
||||||
|
if (!btn) return;
|
||||||
|
if (b.active) btn.classList.add('active');
|
||||||
|
else btn.classList.remove('active');
|
||||||
|
});
|
||||||
|
}, [buttons]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
12
ui/src/components/NotFound/Illustration.tsx
Normal file
12
ui/src/components/NotFound/Illustration.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { ComponentPropsWithoutRef } from 'react';
|
||||||
|
|
||||||
|
export function Illustration(props: ComponentPropsWithoutRef<'svg'>) {
|
||||||
|
return (
|
||||||
|
<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 362 145' {...props}>
|
||||||
|
<path
|
||||||
|
fill='currentColor'
|
||||||
|
d='M62.6 142c-2.133 0-3.2-1.067-3.2-3.2V118h-56c-2 0-3-1-3-3V92.8c0-1.333.4-2.733 1.2-4.2L58.2 4c.8-1.333 2.067-2 3.8-2h28c2 0 3 1 3 3v85.4h11.2c.933 0 1.733.333 2.4 1 .667.533 1 1.267 1 2.2v21.2c0 .933-.333 1.733-1 2.4-.667.533-1.467.8-2.4.8H93v20.8c0 2.133-1.067 3.2-3.2 3.2H62.6zM33 90.4h26.4V51.2L33 90.4zM181.67 144.6c-7.333 0-14.333-1.333-21-4-6.666-2.667-12.866-6.733-18.6-12.2-5.733-5.467-10.266-13-13.6-22.6-3.333-9.6-5-20.667-5-33.2 0-12.533 1.667-23.6 5-33.2 3.334-9.6 7.867-17.133 13.6-22.6 5.734-5.467 11.934-9.533 18.6-12.2 6.667-2.8 13.667-4.2 21-4.2 7.467 0 14.534 1.4 21.2 4.2 6.667 2.667 12.8 6.733 18.4 12.2 5.734 5.467 10.267 13 13.6 22.6 3.334 9.6 5 20.667 5 33.2 0 12.533-1.666 23.6-5 33.2-3.333 9.6-7.866 17.133-13.6 22.6-5.6 5.467-11.733 9.533-18.4 12.2-6.666 2.667-13.733 4-21.2 4zm0-31c9.067 0 15.6-3.733 19.6-11.2 4.134-7.6 6.2-17.533 6.2-29.8s-2.066-22.2-6.2-29.8c-4.133-7.6-10.666-11.4-19.6-11.4-8.933 0-15.466 3.8-19.6 11.4-4 7.6-6 17.533-6 29.8s2 22.2 6 29.8c4.134 7.467 10.667 11.2 19.6 11.2zM316.116 142c-2.134 0-3.2-1.067-3.2-3.2V118h-56c-2 0-3-1-3-3V92.8c0-1.333.4-2.733 1.2-4.2l56.6-84.6c.8-1.333 2.066-2 3.8-2h28c2 0 3 1 3 3v85.4h11.2c.933 0 1.733.333 2.4 1 .666.533 1 1.267 1 2.2v21.2c0 .933-.334 1.733-1 2.4-.667.533-1.467.8-2.4.8h-11.2v20.8c0 2.133-1.067 3.2-3.2 3.2h-27.2zm-29.6-51.6h26.4V51.2l-26.4 39.2z'
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
ui/src/components/NotFound/NotFound.module.css
Normal file
43
ui/src/components/NotFound/NotFound.module.css
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
.root {
|
||||||
|
padding-top: 80px;
|
||||||
|
padding-bottom: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inner {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0.75;
|
||||||
|
color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding-top: 220px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
|
@media (max-width: $mantine-breakpoint-sm) {
|
||||||
|
padding-top: 120px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-family: Outfit, var(--mantine-font-family);
|
||||||
|
text-align: center;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 38px;
|
||||||
|
|
||||||
|
@media (max-width: $mantine-breakpoint-sm) {
|
||||||
|
font-size: 32px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.description {
|
||||||
|
max-width: 540px;
|
||||||
|
margin: auto;
|
||||||
|
margin-top: var(--mantine-spacing-xl);
|
||||||
|
margin-bottom: calc(var(--mantine-spacing-xl) * 1.5);
|
||||||
|
}
|
||||||
27
ui/src/components/NotFound/index.tsx
Normal file
27
ui/src/components/NotFound/index.tsx
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Button, Container, Group, Text, Title } from '@mantine/core';
|
||||||
|
import { Illustration } from './Illustration';
|
||||||
|
import classes from './NotFound.module.css';
|
||||||
|
import { useNavigate } from 'react-router';
|
||||||
|
|
||||||
|
export function NotFound() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<Container className={classes.root}>
|
||||||
|
<div className={classes.inner}>
|
||||||
|
<Illustration className={classes.image} />
|
||||||
|
<div className={classes.content}>
|
||||||
|
<Title className={classes.title}>Nothing to see here</Title>
|
||||||
|
<Text c='dimmed' size='lg' ta='center' className={classes.description}>
|
||||||
|
Page you are trying to open does not exist. You may have mistyped the address, or the page has been moved to
|
||||||
|
another URL. If you think this is an error contact support.
|
||||||
|
</Text>
|
||||||
|
<Group justify='center'>
|
||||||
|
<Button size='md' onClick={() => navigate('/')}>
|
||||||
|
Take me back to home page
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
import { Header } from '@components/Header';
|
import { Header } from '@components/Header';
|
||||||
import { useUserContext } from '@components/context/UserContext.tsx';
|
import { useUserContext } from '@components/context/UserContext.tsx';
|
||||||
import { Navigate } from 'react-router';
|
import { NotFound } from '@components/NotFound';
|
||||||
|
|
||||||
export function Profile() {
|
export function Profile() {
|
||||||
const { user } = useUserContext();
|
const { user } = useUserContext();
|
||||||
|
|
||||||
if (user == undefined) {
|
if (user == undefined) {
|
||||||
return <Navigate to={'/'} />;
|
return <NotFound />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { ReactNode, useEffect, useState } from 'react';
|
import { ReactNode, useEffect, useState } from 'react';
|
||||||
import { UserContext } from './UserContext.tsx';
|
import { UserContext } from './UserContext.tsx';
|
||||||
import { refresh } from '@lib/account.ts';
|
import { profile } from '@lib/account.ts';
|
||||||
import { User } from '@lib/account.types.ts';
|
import { User } from '@lib/account.types.ts';
|
||||||
import { Center, Loader } from '@mantine/core';
|
import { Center, Loader } from '@mantine/core';
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ export function UserProvider({ children }: { children: ReactNode }) {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh().then((refreshUser) => {
|
profile().then((refreshUser) => {
|
||||||
if (refreshUser) {
|
if (refreshUser) {
|
||||||
setUser(refreshUser);
|
setUser(refreshUser);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ export async function logout() {
|
|||||||
return await postRequest('account/logout', {});
|
return await postRequest('account/logout', {});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refresh(): Promise<User | undefined> {
|
export async function profile(): Promise<User | undefined> {
|
||||||
const response = await getRequest('account/session');
|
const response = await getRequest('account/profile');
|
||||||
if (response?.status === 200) {
|
if (response?.status === 200) {
|
||||||
return response.json();
|
return response.json();
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export interface Runway {
|
|||||||
|
|
||||||
export interface Frequency {
|
export interface Frequency {
|
||||||
id: string;
|
id: string;
|
||||||
|
name: string;
|
||||||
frequency_mhz: number;
|
frequency_mhz: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { UserProvider } from '@components/context/UserProvider.tsx';
|
|||||||
import { BrowserRouter, Route, Routes } from 'react-router';
|
import { BrowserRouter, Route, Routes } from 'react-router';
|
||||||
import { Profile } from '@components/Profile.tsx';
|
import { Profile } from '@components/Profile.tsx';
|
||||||
import { Administration } from '@components/Administration.tsx';
|
import { Administration } from '@components/Administration.tsx';
|
||||||
|
import { NotFound } from '@components/NotFound';
|
||||||
|
|
||||||
const theme = createTheme({
|
const theme = createTheme({
|
||||||
fontFamily: 'Inter, sans-serif'
|
fontFamily: 'Inter, sans-serif'
|
||||||
@@ -23,6 +24,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<Route path='/' element={<App />} />
|
<Route path='/' element={<App />} />
|
||||||
<Route path='/profile' element={<Profile />} />
|
<Route path='/profile' element={<Profile />} />
|
||||||
<Route path='/administration' element={<Administration />} />
|
<Route path='/administration' element={<Administration />} />
|
||||||
|
<Route path='*' element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</UserProvider>
|
</UserProvider>
|
||||||
</MantineProvider>
|
</MantineProvider>
|
||||||
|
|||||||
Reference in New Issue
Block a user