Working on session validation

This commit is contained in:
2025-04-11 09:53:05 -04:00
parent 56ac66e9b1
commit 98887d7fef
24 changed files with 724 additions and 132 deletions

18
ui/package-lock.json generated
View File

@@ -15,6 +15,7 @@
"@mantine/notifications": "^7.17.2",
"@tabler/icons-react": "^3.31.0",
"d3": "^7.9.0",
"js-cookie": "^3.0.5",
"leaflet": "^1.9.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -23,6 +24,7 @@
"devDependencies": {
"@eslint/js": "^9.21.0",
"@types/d3": "^7.4.3",
"@types/js-cookie": "^3.0.6",
"@types/leaflet": "^1.9.16",
"@types/node": "^22.13.10",
"@types/react": "^19.0.10",
@@ -1847,6 +1849,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/js-cookie": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz",
"integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -3494,6 +3503,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/js-cookie": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz",
"integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==",
"license": "MIT",
"engines": {
"node": ">=14"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",

View File

@@ -18,6 +18,7 @@
"@mantine/notifications": "^7.17.2",
"@tabler/icons-react": "^3.31.0",
"d3": "^7.9.0",
"js-cookie": "^3.0.5",
"leaflet": "^1.9.4",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@@ -26,6 +27,7 @@
"devDependencies": {
"@eslint/js": "^9.21.0",
"@types/d3": "^7.4.3",
"@types/js-cookie": "^3.0.6",
"@types/leaflet": "^1.9.16",
"@types/node": "^22.13.10",
"@types/react": "^19.0.10",

View File

@@ -35,7 +35,6 @@ export default function AirportLayer({ setAirport }: { setAirport: (airport: Air
limit: 200
})
.then((response) => {
console.log(response);
setAirports(response.data);
})
.catch((error) => {

View File

@@ -0,0 +1,224 @@
import {
Modal,
Container,
Title,
Anchor,
Paper,
TextInput,
Button,
PasswordInput,
Group,
Checkbox,
Text
} from '@mantine/core';
import { useForm } from '@mantine/form';
import Cookies from 'js-cookie';
interface HeaderModalProps {
type?: string;
toggle: (input: string | undefined) => void;
login: ({ email, password }: { email: string; password: string }) => Promise<boolean>;
register: ({
firstName,
lastName,
email,
password
}: {
firstName: string;
lastName: string;
email: string;
password: string;
}) => Promise<boolean>;
}
export function HeaderModal({ type, toggle, login, register }: HeaderModalProps) {
function passwordValidator(value: string) {
if (value.trim().length < 8) {
return 'Password must be at least 8 characters';
}
if (value.trim().length >= 128) {
return 'Password must be at most 128 characters';
}
if (!/(\d)/.test(value)) {
return 'Password must contain at least one number';
}
if (!/[a-z]/.test(value)) {
return 'Password must contain at least one lowercase letter';
}
if (!/[A-Z]/.test(value)) {
return 'Password must contain at least one uppercase letter';
}
if (!/[!@#$%^&*]/.test(value)) {
return 'Password must contain at least one special character';
}
return null;
}
function emailValidator(value: string) {
if (value.trim().length == 0) {
return 'Email is required';
}
if (!/^\S+@\S+$/.test(value)) {
return 'Invalid email';
}
return null;
}
const registerForm = useForm({
initialValues: {
firstName: '',
lastName: '',
email: '',
password: ''
},
validate: {
firstName: (value) => (value.trim().length > 0 ? null : 'First name is required'),
lastName: (value) => (value.trim().length > 0 ? null : 'Last name is required'),
email: emailValidator,
password: passwordValidator
}
});
const loginForm = useForm({
initialValues: {
email: Cookies.get('email') || '',
password: '',
remember: Cookies.get('remember') === 'true'
}
});
const resetForm = useForm({
initialValues: {
email: ''
}
});
function onClose() {
toggle(undefined);
registerForm.reset();
resetForm.reset();
if (!loginForm.values.remember) {
loginForm.reset();
}
}
return (
<Modal opened={type !== undefined} onClose={onClose} withCloseButton={false} zIndex={1000}>
{type == 'reset' ? (
<Container>
<Title ta='center'>Reset password</Title>
<Text c='dimmed' size='sm' ta='center' mt={5}>
Enter your email and we will send you a link to reset your password.{' '}
<Anchor size='sm' component='a' onClick={() => toggle('login')}>
Go Back
</Anchor>
</Text>
<Paper withBorder shadow='md' p={30} mt={30} radius='md'>
<form onSubmit={resetForm.onSubmit(async (values) => console.log(values))}>
<TextInput label='Email' placeholder='you@example.com' required {...resetForm.getInputProps('email')} />
<Button type='submit' fullWidth mt='xl'>
Reset password
</Button>
</form>
</Paper>
</Container>
) : type == 'register' ? (
<Container>
<Title ta='center'>Create account</Title>
<Text c='dimmed' size='sm' ta='center' mt={5}>
Already have an account?{' '}
<Anchor size='sm' component='a' onClick={() => toggle('login')}>
Sign in
</Anchor>
</Text>
<Paper withBorder shadow='md' p={30} mt={30} radius='md'>
<form
onSubmit={registerForm.onSubmit(async (values) => {
const success = await register(values);
if (success) {
onClose();
}
})}
>
<TextInput label='First name' placeholder='John' required {...registerForm.getInputProps('firstName')} />
<TextInput
label='Last name'
placeholder='Smith'
required
mt='md'
{...registerForm.getInputProps('lastName')}
/>
<TextInput
label='Email'
placeholder='you@example.com'
required
{...registerForm.getInputProps('email')}
/>
<PasswordInput
label='Password'
description='Passwords must be at least 10 characters long, contain at least one number, one uppercase letter, one lowercase letter, and one special character.'
placeholder='Your password'
required
mt='md'
{...registerForm.getInputProps('password')}
/>
<Button type='submit' fullWidth mt='xl'>
Sign up
</Button>
</form>
</Paper>
</Container>
) : (
<Container>
<Title ta='center'>Welcome back!</Title>
<Text c='dimmed' size='sm' ta='center' mt={5}>
Do not have an account yet?{' '}
<Anchor size='sm' component='a' onClick={() => toggle('register')}>
Create account
</Anchor>
</Text>
<Paper withBorder shadow='md' p={30} mt={30} radius='md'>
<form
onSubmit={loginForm.onSubmit(async (values) => {
Cookies.set('remember', 'true', { expires: 365 });
if (values.remember) {
Cookies.set('email', values.email, { expires: 365 });
} else {
Cookies.remove('email');
}
const success = await login(values);
if (success) {
onClose();
}
})}
>
<TextInput label='Email' placeholder='you@example.com' required {...loginForm.getInputProps('email')} />
<PasswordInput
label='Password'
placeholder='Your password'
required
mt='md'
{...loginForm.getInputProps('password')}
/>
<Group justify='space-between' mt='lg'>
<Checkbox
label='Remember me'
defaultChecked={loginForm.values.remember}
{...loginForm.getInputProps('remember')}
/>
<Anchor component='a' size='sm' onClick={() => toggle('reset')}>
Forgot password?
</Anchor>
</Group>
<Button type='submit' fullWidth mt='xl'>
Sign in
</Button>
</form>
</Paper>
</Container>
)}
</Modal>
);
}

View File

@@ -0,0 +1,90 @@
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 './styles.css';
interface HeaderUserProps {
user: User;
profilePicture: File | undefined;
logout: () => Promise<void>;
}
export default function HeaderUser({ user, profilePicture, logout }: HeaderUserProps) {
return (
<Menu shadow='md' width={200} openDelay={100} closeDelay={400} zIndex={1000}>
<Menu.Target>
<UnstyledButton>
<Group>
<Avatar src={profilePicture ? URL.createObjectURL(profilePicture) : undefined} />
<div style={{ flex: 1 }}>
<Text size='sm' fw={500}>
{user.first_name} {user.last_name}
</Text>
<Text c='dimmed' size='xs' style={{ textTransform: 'uppercase' }}>
{user.role}
</Text>
</div>
</Group>
</UnstyledButton>
</Menu.Target>
<Menu.Dropdown p={0}>
<Card>
<Card.Section h={140} style={{ backgroundColor: '#4481e3' }} />
<FileButton
onChange={(payload) => {
if (payload) {
// TODO profile picture
// setPicture(payload).then((response: any) => {
// if (response) {
//
// }
// });
}
}}
accept='image/png,image/jpeg,image/svg+xml,image/webp,image/gif,image/apng,image/avif'
multiple={false}
>
{(props) => (
<Avatar
{...props}
component='button'
size={80}
radius={80}
mx={'auto'}
mt={-30}
style={{ cursor: 'pointer' }}
bg={profilePicture ? 'transparent' : 'white'}
src={profilePicture ? URL.createObjectURL(profilePicture) : undefined}
/>
)}
</FileButton>
<Text ta='center' fz='lg' fw={500} mt='sm'>
{user.first_name} {user.last_name}
</Text>
<Text ta='center' fz='sm' c='dimmed' style={{ textTransform: 'uppercase' }}>
{user.role}
</Text>
<Grid mt='xl'>
<Grid.Col span={6}>
<Button fullWidth radius='md' size='xs' variant='default'>
Profile
</Button>
</Grid.Col>
<Grid.Col span={6}>
<Button fullWidth radius='md' size='xs' variant='default' onClick={logout}>
Logout
</Button>
</Grid.Col>
{user.role == 'admin' && (
<Grid.Col span={12}>
<Button fullWidth radius='md' size='xs' variant='default'>
Administration
</Button>
</Grid.Col>
)}
</Grid>
</Card>
</Menu.Dropdown>
</Menu>
);
}

View File

@@ -1,64 +1,163 @@
import { useState } from 'react';
import { Avatar, Box, Burger, Button, Group, Text } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
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';
const links = [
{ link: '/', label: 'Map' },
{ link: '/airports', label: 'Airports' },
{ link: '/metars', label: 'Metars' }
];
// const links = [
// { link: '/', label: 'Map' },
// { link: '/airports', label: 'Airports' },
// { link: '/metars', label: 'Metars' }
// ];
export function Header() {
const [opened, { toggle }] = useDisclosure(false);
const [active, setActive] = useState(links[0].link);
const isSignedIn = 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) => (
<a
key={link.label}
href={link.link}
className={classes.link}
data-active={active === link.link || undefined}
onClick={(event) => {
event.preventDefault();
setActive(link.link);
}}
>
{link.label}
</a>
));
// const navItems = links.map((link) => (
// <a
// key={link.label}
// href={link.link}
// className={classes.link}
// data-active={active === link.link || undefined}
// onClick={(event) => {
// event.preventDefault();
// setActive(link.link);
// }}
// >
// {link.label}
// </a>
// ));
async function loginUser({ email, password }: { email: string; password: string }): Promise<boolean> {
const loginResponse = await login(email, password);
if (loginResponse) {
setUser(loginResponse);
notifications.show({
title: `Welcome back ${loginResponse.first_name}!`,
message: `You have been logged in.`,
color: 'green',
autoClose: 2000,
loading: false
});
return true;
} else {
notifications.show({
title: `Unable to Login`,
message: `Please try again.`,
color: 'red',
autoClose: 2000,
loading: false
});
}
return false;
}
async function logoutUser(): Promise<void> {
await logout();
Cookies.remove('logged_in');
setUser(undefined);
}
async function registerUser({
firstName,
lastName,
email,
password
}: {
firstName: string;
lastName: string;
email: string;
password: string;
}): Promise<boolean> {
const id = notifications.show({
loading: true,
title: `Creating account`,
message: `Please wait...`,
autoClose: false,
withCloseButton: false
});
const registerResponse = await register({
first_name: firstName,
last_name: lastName,
email: email,
password: password
});
if (registerResponse) {
const loginResponse = await login(email, password);
if (loginResponse) {
setUser(loginResponse);
notifications.update({
id,
title: `Account created`,
message: `Welcome ${loginResponse.first_name}!`,
color: 'green',
autoClose: 2000,
loading: false
});
return true;
} else {
notifications.update({
id,
title: `Unable to Login`,
message: `Please try again.`,
color: 'red',
autoClose: 2000,
loading: false
});
}
} else {
notifications.update({
id,
title: `Unable to Register`,
message: `Please try again.`,
color: 'error',
autoClose: 2000,
loading: false
});
}
return false;
}
console.log(Cookies.get('logged_in'));
console.log(Cookies.get('session'));
return (
<Box>
<header className={classes.header}>
<Group justify='space-between' h='100%'>
<Group align='center' gap='xs'>
<Burger opened={opened} onClick={toggle} hiddenFrom='xs' size='sm' />
<Avatar src='/logo.svg' alt='logo' />
<Text>Aviation</Text>
<>
<Box>
<header className={classes.header}>
<Group justify='space-between' h='100%'>
<Group align='center' gap='xs'>
<Burger opened={opened} onClick={toggle} hiddenFrom='xs' size='sm' />
<Avatar src='/logo.svg' alt='logo' />
<Text>FlightLink</Text>
</Group>
{/*<Group gap={5} visibleFrom='xs' className={classes.navGroup}>*/}
{/* {navItems}*/}
{/*</Group>*/}
<Group align='center' gap='xs'>
{user ? (
<HeaderUser user={user} profilePicture={undefined} logout={logoutUser} />
) : (
<Group className={'user'}>
<Button variant='default' onClick={() => modalToggle('login')}>
Login
</Button>
<Button onClick={() => modalToggle('register')}>Signup</Button>
</Group>
)}
</Group>
</Group>
<Group gap={5} visibleFrom='xs' className={classes.navGroup}>
{navItems}
</Group>
<Group align='center' gap='xs'>
{isSignedIn ? (
// Clickable avatar if signed in
<Avatar
src='/user-avatar.jpg' // replace with dynamic source when available
alt='User avatar'
style={{ cursor: 'pointer' }}
// Add click handler for user dropdown if needed
/>
) : (
<>
<Button variant='default'>Login</Button>
<Button>Signup</Button>
</>
)}
</Group>
</Group>
</header>
</Box>
</header>
</Box>
<HeaderModal type={modalType} toggle={modalToggle} login={loginUser} register={registerUser} />
</>
);
}

63
ui/src/lib/account.ts Normal file
View File

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

@@ -0,0 +1,19 @@
export interface ResponseAuth {
token: string;
user: User;
}
export interface RegisterUser {
email: string;
password: string;
first_name: string;
last_name: string;
}
export interface User {
email: string;
role: string;
first_name: string;
last_name: string;
profile_picture?: string;
}

View File

@@ -1,7 +1,8 @@
// const serviceHost = process.env.SERVICE_HOST || 'http://localhost';
// const servicePort = process.env.SERVICE_PORT || 5000;'
// const baseURL = `${serviceHost}:${servicePort}`;
const baseUrl = 'http://localhost:5000';
// const protocol = process.env.HTTPD_PROTOCOL || 'http';
// const host = process.env.HTTPD_HOST || 'localhost';
// const port = process.env.HTTPD_PORT || 8080;
// const baseUrl = `${protocol}://${host}:${port}/api`;
const baseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8080/api';
export async function getRequest(endpoint: string, params: Record<string, any> = {}): Promise<Response> {
Object.keys(params).forEach((key) => params[key] === undefined && delete params[key]);

View File

@@ -4,21 +4,15 @@ import './index.css';
import App from './App.tsx';
import { createTheme, MantineProvider } from '@mantine/core';
import { Notifications } from '@mantine/notifications';
import {} from '@mantine/core';
const theme = createTheme({
fontFamily: 'Inter, sans-serif'
});
export const metadata = {
title: 'Aviation',
description: ''
};
createRoot(document.getElementById('root')!).render(
<StrictMode>
<MantineProvider theme={theme} defaultColorScheme={'dark'}>
<Notifications />
<Notifications zIndex={2000} />
<App />
</MantineProvider>
</StrictMode>