Consolidate authentication code

This commit is contained in:
april 2024-01-03 10:01:15 -06:00
parent 73b11482ff
commit 912e4d1b0e
12 changed files with 206 additions and 114 deletions

View File

@ -14,6 +14,9 @@ import {
useRouteError, useRouteError,
} from "@remix-run/react"; } from "@remix-run/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { import {
Button, Button,
ColorSchemeScript, ColorSchemeScript,
@ -23,11 +26,9 @@ import {
Stack, Stack,
Title, Title,
} from "@mantine/core"; } from "@mantine/core";
import { TailfinAppShell } from "./ui/nav/app-shell";
import { IconRocket } from "@tabler/icons-react"; import { IconRocket } from "@tabler/icons-react";
import Providers from "./providers";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { AuthProvider } from "./util/auth";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
export const links: LinksFunction = () => [ export const links: LinksFunction = () => [
...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []), ...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []),
@ -60,7 +61,7 @@ export function ErrorBoundary() {
<Button <Button
leftSection={<IconRocket />} leftSection={<IconRocket />}
component={Link} component={Link}
to="/" to="/logbook"
variant="default" variant="default"
> >
Get me out of here! Get me out of here!
@ -89,10 +90,12 @@ export default function App() {
<body> <body>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<MantineProvider theme={{ primaryColor: "violet" }}> <MantineProvider theme={{ primaryColor: "violet" }}>
<AuthProvider>
<Outlet /> <Outlet />
<ScrollRestoration /> <ScrollRestoration />
<Scripts /> <Scripts />
<LiveReload /> <LiveReload />
</AuthProvider>
</MantineProvider> </MantineProvider>
<ReactQueryDevtools initialIsOpen={false} /> <ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider> </QueryClientProvider>

View File

@ -1,12 +1,11 @@
import { client } from "@/util/api"; import { client } from "@/util/api";
import { List, Stack, Text } from "@mantine/core"; import { List, Stack, Text } from "@mantine/core";
import { useParams } from "@remix-run/react"; import { useParams } from "@remix-run/react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
export default function Flight() { export default function Flight() {
const params = useParams(); const params = useParams();
const queryClient = useQueryClient();
const flight = useQuery({ const flight = useQuery({
queryKey: [params.id], queryKey: [params.id],
queryFn: async () => queryFn: async () =>

View File

@ -1,20 +1,11 @@
import { client } from "@/util/api"; import { client } from "@/util/api";
import { import { Flight } from "@/util/types";
Divider, import { NavLink, Text, Button, ScrollArea, Stack } from "@mantine/core";
NavLink,
Text,
Container,
Button,
ScrollArea,
Stack,
} from "@mantine/core";
import { Link, useLocation } from "@remix-run/react"; import { Link, useLocation } from "@remix-run/react";
import { IconPlus } from "@tabler/icons-react"; import { IconPlus } from "@tabler/icons-react";
import { useQuery, useQueryClient } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
export function FlightsList() { export function FlightsList() {
const queryClient = useQueryClient();
const flights = useQuery({ const flights = useQuery({
queryKey: ["flights-list"], queryKey: ["flights-list"],
queryFn: () => client.get(`/flights`).then((res) => res.data), queryFn: () => client.get(`/flights`).then((res) => res.data),
@ -30,7 +21,7 @@ export function FlightsList() {
</Button> </Button>
<ScrollArea> <ScrollArea>
{flights.data ? ( {flights.data ? (
flights.data.map((flight, index) => ( flights.data.map((flight: Flight, index: number) => (
<NavLink <NavLink
key={index} key={index}
component={Link} component={Link}
@ -48,8 +39,6 @@ export function FlightsList() {
} }
export function MobileFlightsList() { export function MobileFlightsList() {
const queryClient = useQueryClient();
const flights = useQuery({ const flights = useQuery({
queryKey: ["flights-list"], queryKey: ["flights-list"],
queryFn: () => client.get(`/flights`).then((res) => res.data), queryFn: () => client.get(`/flights`).then((res) => res.data),
@ -62,7 +51,7 @@ export function MobileFlightsList() {
<Stack p="0" m="0" justify="space-between" h="calc(100vh - 95px)"> <Stack p="0" m="0" justify="space-between" h="calc(100vh - 95px)">
<ScrollArea h="calc(100vh - 95px - 50px"> <ScrollArea h="calc(100vh - 95px - 50px">
{flights.data ? ( {flights.data ? (
flights.data.map((flight, index) => ( flights.data.map((flight: Flight, index: number) => (
<NavLink <NavLink
key={index} key={index}
component={Link} component={Link}

View File

@ -1,12 +1,12 @@
import { useMe } from "@/util/hooks"; import { useAuth } from "@/util/auth";
import { Container, Title } from "@mantine/core"; import { Container, Title } from "@mantine/core";
export default function Me() { export default function Me() {
const me = useMe(); const { user } = useAuth();
return ( return (
<Container> <Container>
<Title order={2}>{me.data?.username}</Title> <Title order={2}>{user}</Title>
</Container> </Container>
); );
} }

View File

@ -1,6 +1,8 @@
import { TailfinAppShell } from "@/ui/nav/app-shell"; import { TailfinAppShell } from "@/ui/nav/app-shell";
import { useAuth } from "@/util/auth";
import type { MetaFunction } from "@remix-run/node"; import type { MetaFunction } from "@remix-run/node";
import { Outlet } from "@remix-run/react"; import { Outlet, useNavigate } from "@remix-run/react";
import { useEffect } from "react";
export const meta: MetaFunction = () => { export const meta: MetaFunction = () => {
return [ return [
@ -10,11 +12,20 @@ export const meta: MetaFunction = () => {
}; };
export default function Index() { export default function Index() {
const { user, loading } = useAuth();
const navigate = useNavigate();
useEffect(() => {
console.log("loading: " + loading);
console.log("user: " + user);
if (!loading && !user) {
navigate("/login");
}
}, [user, loading, navigate]);
return ( return (
<div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
<TailfinAppShell> <TailfinAppShell>
<Outlet /> <Outlet />
</TailfinAppShell> </TailfinAppShell>
</div>
); );
} }

View File

@ -1,5 +1,4 @@
import { client } from "@/util/api"; import { useAuth } from "@/util/auth";
import { useLogin } from "@/util/hooks";
import { import {
Box, Box,
Button, Button,
@ -19,7 +18,7 @@ export default function Login() {
}, },
}); });
const signInMutation = useLogin(); const { signin } = useAuth();
return ( return (
<Stack gap="md" h="100%" justify="center" align="stretch"> <Stack gap="md" h="100%" justify="center" align="stretch">
@ -29,7 +28,7 @@ export default function Login() {
<Box maw={340} mx="auto"> <Box maw={340} mx="auto">
<form <form
onSubmit={form.onSubmit((values) => { onSubmit={form.onSubmit((values) => {
signInMutation(values); signin(values);
})} })}
> >
<TextInput <TextInput

View File

@ -4,7 +4,6 @@ import {
Group, Group,
Title, Title,
UnstyledButton, UnstyledButton,
Image,
Avatar, Avatar,
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";

View File

@ -1,10 +1,9 @@
import { useMe, useSignOut } from "@/util/hooks"; import { useAuth } from "@/util/auth";
import { Stack, NavLink, ActionIcon } from "@mantine/core"; import { Stack, NavLink } from "@mantine/core";
import { Link, useLocation } from "@remix-run/react"; import { Link, useLocation } from "@remix-run/react";
import { import {
IconBook2, IconBook2,
IconLogout, IconLogout,
IconMapRoute,
IconPlaneDeparture, IconPlaneDeparture,
IconUser, IconUser,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
@ -13,8 +12,7 @@ export default function Navbar() {
const location = useLocation(); const location = useLocation();
const page = location.pathname.split("/")[2]; const page = location.pathname.split("/")[2];
const me = useMe(); const { user, signout } = useAuth();
const signOut = useSignOut();
return ( return (
<Stack justify="space-between" h="100%"> <Stack justify="space-between" h="100%">
@ -39,13 +37,7 @@ export default function Navbar() {
<Stack gap="0"> <Stack gap="0">
<NavLink <NavLink
p="md" p="md"
label={ label={user ? user : "Not Logged In"}
me.isError
? me.error.message
: me.isFetched
? me.data?.username
: "Not Logged In"
}
leftSection={<IconUser />} leftSection={<IconUser />}
> >
<NavLink <NavLink
@ -57,7 +49,7 @@ export default function Navbar() {
/> />
<NavLink <NavLink
p="md" p="md"
onClick={() => signOut()} onClick={() => signout()}
label="Sign Out" label="Sign Out"
leftSection={<IconLogout />} leftSection={<IconLogout />}
/> />

View File

@ -1,3 +1,4 @@
import { useNavigate } from "@remix-run/react";
import axios from "axios"; import axios from "axios";
export const client = axios.create({ export const client = axios.create({
@ -13,15 +14,9 @@ client.interceptors.request.use(
} }
return config; return config;
}, },
(error) => {
return Promise.reject(error);
}
);
client.interceptors.request.use(
(response) => response,
async (error) => { async (error) => {
const originalRequest = error.config; const originalRequest = error.config;
const navigate = useNavigate();
if (error.response.status == 401 && !originalRequest._retry) { if (error.response.status == 401 && !originalRequest._retry) {
originalRequest._retry = true; originalRequest._retry = true;
const refreshToken = localStorage.getItem("refresh-token"); const refreshToken = localStorage.getItem("refresh-token");
@ -36,7 +31,10 @@ client.interceptors.request.use(
] = `Bearer ${data.refreshToken}`; ] = `Bearer ${data.refreshToken}`;
return client(originalRequest); return client(originalRequest);
} catch (_error) { } catch (_error) {
return Promise.reject(_error); localStorage.removeItem("token");
localStorage.removeItem("refresh-token");
console.log("Oh no!!!");
navigate("/login");
} }
} }
} }

102
web/app/util/auth.tsx Normal file
View File

@ -0,0 +1,102 @@
import { client } from "./api";
import { useNavigate } from "@remix-run/react";
import { createContext, useContext, useEffect, useState } from "react";
interface AuthContextValues {
user: string | null;
loading: boolean;
signin: ({
username,
password,
}: {
username: string;
password: string;
}) => void;
signout: () => void;
}
const AuthContext = createContext<AuthContextValues | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const auth = useProvideAuth();
return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
}
export function useAuth(): AuthContextValues {
const data = useContext(AuthContext);
if (!data) {
throw new Error("Could not find AuthContext provider");
}
return data;
}
function useProvideAuth() {
const [user, setUser] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const navigate = useNavigate();
const handleUser = (rawUser: string | null) => {
if (rawUser) {
setUser(rawUser);
setLoading(false);
return rawUser;
} else {
setUser(null);
clearTokens();
setLoading(false);
return false;
}
};
const handleTokens = (tokens: {
access_token: string;
refresh_token: string;
}) => {
if (tokens) {
localStorage.setItem("token", tokens.access_token);
localStorage.setItem("refresh-token", tokens.refresh_token);
}
};
const clearTokens = () => {
localStorage.removeItem("token");
localStorage.removeItem("refresh-token");
};
const signin = async (values: { username: string; password: string }) => {
setLoading(true);
await client
.postForm("/auth/login", values)
.then((response) => handleTokens(response.data))
.catch(() => {
setLoading(false);
throw new Error("Invalid username or password");
});
setLoading(false);
await client
.get("/users/me")
.then((response) => handleUser(response.data.username))
.catch(() => handleUser(null));
navigate("/logbook");
};
const signout = async () => {
return await client.post("/auth/logout").then(() => handleUser(null));
};
useEffect(() => {
client
.get("/users/me")
.then((response) => handleUser(response.data.username))
.catch(() => handleUser(null));
}, []);
return {
user,
loading,
signin,
signout,
};
}

View File

@ -1,52 +0,0 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { client } from "./api";
import { useNavigate } from "@remix-run/react";
type User = {
username: string;
level: number;
};
export function useMe() {
return useQuery<User, Error>({
queryKey: ["me"],
queryFn: () => client.get(`/users/me`).then((res) => res.data),
});
}
export function useSignOut() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const onSignOut = async () => {
queryClient.setQueryData(["user"], null);
const res = await client.post("/auth/logout");
if (res.status == 200) {
navigate("/login");
} else {
console.error("Failed to log out");
}
};
return onSignOut;
}
export function useLogin() {
const navigate = useNavigate();
const { mutate: signInMutation } = useMutation({
mutationFn: async (values) => {
return await client.postForm("/auth/login", values);
},
onSuccess: (data) => {
localStorage.setItem("token", data.data.access_token);
localStorage.setItem("refresh-token", data.data.refresh_token);
navigate("/logbook");
},
onError: (error) => {
console.error(error);
},
});
return signInMutation;
}

52
web/app/util/types.ts Normal file
View File

@ -0,0 +1,52 @@
type Flight = {
id: string;
user: string;
date: string;
aircraft: string | null;
waypoint_from: string | null;
waypoint_to: string | null;
route: string | null;
hobbs_start: number | null;
hobbs_end: number | null;
tach_start: number | null;
tach_end: number | null;
time_start: number | null;
time_off: number | null;
time_down: number | null;
time_stop: number | null;
time_total: number;
time_pic: number;
time_sic: number;
time_night: number;
time_solo: number;
time_xc: number;
dist_xc: number;
takeoffs_day: number;
landings_day: number;
takeoffs_night: number;
landings_night: number;
time_instrument: number;
time_sim_instrument: number;
holds_instrument: number;
dual_given: number;
dual_recvd: number;
time_sim: number;
time_ground: number;
tags: string[];
pax: string[];
crew: string[];
comments: string;
};
export { type Flight };