diff --git a/apps/antalmanac/package.json b/apps/antalmanac/package.json index ee1079e93..04e7f9751 100644 --- a/apps/antalmanac/package.json +++ b/apps/antalmanac/package.json @@ -34,10 +34,13 @@ "@mui/material": "^5.11.7", "@packages/antalmanac-types": "workspace:^", "@react-leaflet/core": "^2.1.0", + "@react-oauth/google": "^0.12.1", "@reactour/tour": "^3.6.1", "@tanstack/react-query": "^4.24.4", "@trpc/client": "^10.30.0", + "@trpc/react-query": "^10.45.2", "@trpc/server": "^10.30.0", + "antalmanac-backend": "workspace:^", "chart.js": "^4.2.1", "classnames": "^2.3.2", "date-fns": "^2.29.3", diff --git a/apps/antalmanac/src/App.tsx b/apps/antalmanac/src/App.tsx index 5adf53d2e..48ea199ef 100644 --- a/apps/antalmanac/src/App.tsx +++ b/apps/antalmanac/src/App.tsx @@ -1,18 +1,18 @@ import './App.css'; -import { createBrowserRouter, RouterProvider } from 'react-router-dom'; +import { GoogleOAuthProvider } from '@react-oauth/google'; +import { TourProvider } from '@reactour/tour'; import { SnackbarProvider } from 'notistack'; import { useEffect } from 'react'; import ReactGA4 from 'react-ga4'; +import { createBrowserRouter, RouterProvider } from 'react-router-dom'; -import { TourProvider } from '@reactour/tour'; import { undoDelete } from './actions/AppStoreActions'; import AppQueryProvider from './providers/Query'; import AppThemeProvider from './providers/Theme'; import AppThemev5Provider from './providers/Themev5'; - -import Home from './routes/Home'; import Feedback from './routes/Feedback'; +import Home from './routes/Home'; const BrowserRouter = createBrowserRouter([ { @@ -46,35 +46,37 @@ export default function App() { - ({ - // The highlighted area - ...base, - rx: 5, - }), - maskWrapper: (base, _) => ({ - // The background/overlay - ...base, - color: 'rgba(0, 0, 0, 0.3)', - }), - popover: (base, _) => ({ - // The text box - ...base, - background: '#fff', - color: 'black', - borderRadius: 5, - boxShadow: '0 0 10px #000', - padding: 20, - }), - }} - > - - - - + + ({ + // The highlighted area + ...base, + rx: 5, + }), + maskWrapper: (base, _) => ({ + // The background/overlay + ...base, + color: 'rgba(0, 0, 0, 0.3)', + }), + popover: (base, _) => ({ + // The text box + ...base, + background: '#fff', + color: 'black', + borderRadius: 5, + boxShadow: '0 0 10px #000', + padding: 20, + }), + }} + > + + + + + diff --git a/apps/antalmanac/src/components/Header/AccountButton.tsx b/apps/antalmanac/src/components/Header/AccountButton.tsx new file mode 100644 index 000000000..f3ce59203 --- /dev/null +++ b/apps/antalmanac/src/components/Header/AccountButton.tsx @@ -0,0 +1,143 @@ +import { Logout, Settings } from '@mui/icons-material'; +import { + Avatar, + Box, + Button, + Dialog, + DialogContent, + DialogTitle, + Divider, + IconButton, + ListItemIcon, + Menu, + MenuItem, + Stack, + TextField, + Tooltip, + Typography, +} from '@mui/material'; +import { useSnackbar } from 'notistack'; +import { useState } from 'react'; + +import { trpc } from '$lib/trpc'; + +export function AccountButton() { + const [anchorEl, setAnchorEl] = useState(); + + const [username, setUsername] = useState(''); + + const [settingsOpen, setSettingsOpen] = useState(false); + + const { enqueueSnackbar } = useSnackbar(); + + const handleClick = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget); + }; + + const handleClose = () => { + setAnchorEl(undefined); + }; + + const logoutMutation = trpc.auth.logout.useMutation(); + + const utils = trpc.useUtils(); + + const open = Boolean(anchorEl); + + const logout = async () => { + await logoutMutation.mutateAsync(); + await utils.auth.status.invalidate(); + handleClose(); + }; + + const handleOpenSettings = () => { + setSettingsOpen(true); + handleClose(); + }; + + const handleCloseSettings = () => { + setSettingsOpen(false); + }; + + const handleUsernameChange = (e: React.ChangeEvent) => { + setUsername(e.target.value); + }; + + const handleSubmit = async () => { + if (!username) { + enqueueSnackbar('Username must not be empty', { variant: 'error' }); + return; + } + console.log('new username: ', username); + enqueueSnackbar('New username set', { variant: 'success' }); + }; + + return ( + <> + + + + + + + + + + + Settings + + + + + + Logout + + + + + Account Settings + + + + + + Change Username + + + Enter a unique username. + If the username exists, you will need to claim it. + + + + + Username + + + + + + + + + + + ); +} + +export default AccountButton; diff --git a/apps/antalmanac/src/components/Header/Header.tsx b/apps/antalmanac/src/components/Header/Header.tsx index 7da5e8a31..a27800f05 100644 --- a/apps/antalmanac/src/components/Header/Header.tsx +++ b/apps/antalmanac/src/components/Header/Header.tsx @@ -2,12 +2,16 @@ import { AppBar, Toolbar, useMediaQuery } from '@material-ui/core'; import { withStyles } from '@material-ui/core/styles'; import { ClassNameMap } from '@material-ui/core/styles/withStyles'; +import AccountButton from './AccountButton'; import Import from './Import'; -import LoadSaveScheduleFunctionality from './LoadSaveFunctionality'; +import LoadButton from './LoadButton'; +import LoginButton from './LoginButton'; +import SaveButton from './SaveButton'; import AppDrawer from './SettingsMenu'; import Logo from '$assets/logo.svg'; import MobileLogo from '$assets/mobile-logo.svg'; +import { trpc } from '$lib/trpc'; const styles = { appBar: { @@ -39,6 +43,8 @@ interface CustomAppBarProps { const Header = ({ classes }: CustomAppBarProps) => { const isMobileScreen = useMediaQuery('(max-width:750px)'); + const authStatus = trpc.auth.status.useQuery(); + return ( @@ -50,8 +56,14 @@ const Header = ({ classes }: CustomAppBarProps) => { />
- + + + + + + {authStatus.data ? : } +
diff --git a/apps/antalmanac/src/components/Header/LoadButton.tsx b/apps/antalmanac/src/components/Header/LoadButton.tsx new file mode 100644 index 000000000..20a45e6b7 --- /dev/null +++ b/apps/antalmanac/src/components/Header/LoadButton.tsx @@ -0,0 +1,197 @@ +import { CloudDownload } from '@material-ui/icons'; +import { LoadingButton } from '@mui/lab'; +import { + Autocomplete, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Stack, + TextField, +} from '@mui/material'; +import { useSnackbar } from 'notistack'; +import { useEffect, useState } from 'react'; + +import { loadSchedule } from '$actions/AppStoreActions'; +import analyticsEnum, { logAnalytics } from '$lib/analytics'; +import { trpc } from '$lib/trpc'; +import AppStore from '$stores/AppStore'; +import { useThemeStore } from '$stores/SettingsStore'; + +export function LoadButton() { + const [open, setOpen] = useState(false); + + const [loading, setLoading] = useState(false); + + const [skeletonMode, setSkeletonMode] = useState(AppStore.getSkeletonMode()); + + const authStatus = trpc.auth.status.useQuery(); + + const utils = trpc.useUtils(); + + const { enqueueSnackbar } = useSnackbar(); + + const [userId, setUserId] = useState(''); + + const isDark = useThemeStore((store) => store.isDark); + + const loadScheduleAndSetLoading = async (userID: string, rememberMe: boolean) => { + setLoading(true); + await loadSchedule(userID, rememberMe); + setLoading(false); + }; + + const loadSavedSchedule = async () => { + if (typeof Storage === 'undefined') return; + + const savedUserID = window.localStorage.getItem('userID'); + + if (savedUserID == null) return; + + await loadScheduleAndSetLoading(savedUserID, true); + }; + + const handleOpen = () => { + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; + + const handleSubmit = async () => { + logAnalytics({ + category: analyticsEnum.nav.title, + action: analyticsEnum.nav.actions.LOAD_SCHEDULE, + label: userId, + value: 1, // rememberMe ? 1 : 0, + }); + + const normalizedUserId = userId.replace(/\s+/g, ''); + + if (!normalizedUserId) { + enqueueSnackbar('Invalid user ID.', { variant: 'error' }); + setLoading(false); + return; + } + + if ( + AppStore.hasUnsavedChanges() && + !window.confirm(`Are you sure you want to load a different schedule? You have unsaved changes!`) + ) { + return; + } + + try { + const res = await utils.users.viewUserData.fetch({ + requesterId: authStatus.data?.id, + requesteeId: userId, + }); + + const scheduleSaveState = res && 'userData' in res ? res.userData : res; + + if (scheduleSaveState == null) { + enqueueSnackbar(`Couldn't find schedules for username "${userId}".`, { variant: 'error' }); + return; + } + + if (await AppStore.loadSchedule(scheduleSaveState)) { + enqueueSnackbar(`Schedule for username "${userId}" loaded.`, { variant: 'success' }); + return; + } + + AppStore.loadSkeletonSchedule(scheduleSaveState); + enqueueSnackbar( + `Network error loading course information for "${userId}". + If this continues to happen, please submit a feedback form.`, + { variant: 'error' } + ); + } catch (e) { + console.error(e); + enqueueSnackbar(`Failed to load schedules. If this continues to happen, please submit a feedback form.`, { + variant: 'error', + }); + } finally { + setLoading(false); + } + }; + + const options: string[] = []; + + if (authStatus.data?.id != null) { + options.push(authStatus.data?.id); + } + + if (userId && userId !== authStatus.data?.id) { + options.push(userId); + } + + useEffect(() => { + const handleSkeletonModeChange = () => { + setSkeletonMode(AppStore.getSkeletonMode()); + }; + + AppStore.on('skeletonModeChange', handleSkeletonModeChange); + + return () => { + AppStore.off('skeletonModeChange', handleSkeletonModeChange); + }; + }, []); + + useEffect(() => { + loadSavedSchedule(); + }, []); + + return ( + <> + : } + disabled={skeletonMode} + loading={false} + > + Load + + + + Load + + + + Enter a unique user ID here to view a schedule. + + { + if (newValue != null) { + setUserId(newValue); + } + }} + sx={{ width: 300 }} + options={options} + renderInput={(params) => { + return ; + }} + /> + + + + + + + + + + ); +} + +export default LoadButton; diff --git a/apps/antalmanac/src/components/Header/LoadSaveFunctionality.tsx b/apps/antalmanac/src/components/Header/LoadSaveFunctionality.tsx index d0c418f6a..f35c46bf4 100644 --- a/apps/antalmanac/src/components/Header/LoadSaveFunctionality.tsx +++ b/apps/antalmanac/src/components/Header/LoadSaveFunctionality.tsx @@ -225,14 +225,6 @@ const LoadSaveScheduleFunctionality = () => { loading={saving} colorType={isDark ? 'secondary' : 'primary'} /> - ); }; diff --git a/apps/antalmanac/src/components/Header/LoginButton.tsx b/apps/antalmanac/src/components/Header/LoginButton.tsx new file mode 100644 index 000000000..ba5b9dfb6 --- /dev/null +++ b/apps/antalmanac/src/components/Header/LoginButton.tsx @@ -0,0 +1,221 @@ +import LoginIcon from '@mui/icons-material/Login'; +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Divider, + Stack, + TextField, + Typography, +} from '@mui/material'; +import { GoogleLogin } from '@react-oauth/google'; +import type { CredentialResponse } from '@react-oauth/google'; +import { useSnackbar } from 'notistack'; +import { useState } from 'react'; + +import analyticsEnum, { logAnalytics } from '$lib/analytics'; +import { trpc } from '$lib/trpc'; +import AppStore from '$stores/AppStore'; + +/** + * Opens dialog for logging in. + */ +export function LoginButton() { + const [userId, setUserId] = useState(''); + + const [open, setOpen] = useState(false); + + const snackbar = useSnackbar(); + + const usernameLoginMutation = trpc.auth.loginUsername.useMutation(); + + const googleLoginMutation = trpc.auth.loginGoogle.useMutation(); + + const utils = trpc.useUtils(); + + const handleOpen = () => { + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; + + const handleUserIdChange = (event: React.ChangeEvent) => { + setUserId(event.target.value); + }; + + const handleUsernameLogin = async (e: React.FormEvent) => { + e.preventDefault(); + + logAnalytics({ + category: analyticsEnum.nav.title, + action: analyticsEnum.nav.actions.LOAD_SCHEDULE, + label: userId, + value: 1, + }); + + const shouldStop = + AppStore.hasUnsavedChanges() && + !window.confirm('Are you sure you want to load a different schedule? You have unsaved changes!'); + + if (shouldStop) return; + + if (userId.length === 0) { + snackbar.enqueueSnackbar('Please enter a user ID.'); + return; + } + + try { + const response = await usernameLoginMutation.mutateAsync(userId); + + await utils.auth.status.invalidate(); + + if (response?.userData == null) { + snackbar.enqueueSnackbar(`Logged in as "${userId}", no schedules found.`); + setOpen(false); + return; + } + + const loadedSchedule = await AppStore.loadSchedule(response.userData); + + if (loadedSchedule == null) { + AppStore.loadSkeletonSchedule(response.userData); + snackbar.enqueueSnackbar( + `Network error loading course information for "${userId}". + If this continues to happen, please submit a feedback form.`, + { variant: 'error' } + ); + setOpen(false); + return; + } + + snackbar.enqueueSnackbar(`Schedule for username "${userId}" loaded.`, { variant: 'success' }); + + setOpen(false); + } catch (e) { + console.error('Error occurred while loading schedules: ', e); + snackbar.enqueueSnackbar( + `Failed to load schedules. If this continues to happen, please submit a feedback form.`, + { variant: 'error' } + ); + setOpen(false); + } + }; + + const handleGoogleLogin = async (credential: CredentialResponse) => { + if (credential.credential == null) { + console.error('Did not receive google credential'); + return; + } + + try { + const response = await googleLoginMutation.mutateAsync(credential.credential); + + await utils.auth.status.invalidate(); + + if (response?.userData == null) { + snackbar.enqueueSnackbar(`Logged in as "${response?.id}", no schedules found.`); + setOpen(false); + return; + } + + const loadedSchedule = await AppStore.loadSchedule(response.userData); + + if (loadedSchedule == null) { + AppStore.loadSkeletonSchedule(response.userData); + snackbar.enqueueSnackbar( + `Network error loading course information for "${userId}". + If this continues to happen, please submit a feedback form.`, + { variant: 'error' } + ); + setOpen(false); + return; + } + + snackbar.enqueueSnackbar(`Schedule for username "${userId}" loaded.`, { variant: 'success' }); + + setOpen(false); + } catch (e) { + console.error('Error occurred while loading schedules: ', e); + snackbar.enqueueSnackbar( + `Failed to load schedules. If this continues to happen, please submit a feedback form.`, + { variant: 'error' } + ); + setOpen(false); + } + }; + + const handleGoogleError = async () => { + console.log('Login Failed'); + }; + + return ( + <> + + + Login + + + + + Username + + + + Enter your unique user ID here to login. + + Make sure the user ID is unique and secret, or someone else can overwrite your + schedule. + + + +
+ + + + + + +
+
+ + + + + Providers + + + + +
+
+ + + + +
+ + ); +} + +export default LoginButton; diff --git a/apps/antalmanac/src/components/Header/SaveButton.tsx b/apps/antalmanac/src/components/Header/SaveButton.tsx new file mode 100644 index 000000000..5fd0d6e4a --- /dev/null +++ b/apps/antalmanac/src/components/Header/SaveButton.tsx @@ -0,0 +1,213 @@ +import { Save } from '@material-ui/icons'; +import { LoadingButton } from '@mui/lab'; +import { + Autocomplete, + Button, + CircularProgress, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + FormControl, + FormLabel, + FormControlLabel, + Radio, + RadioGroup, + Stack, + TextField, + Tooltip, +} from '@mui/material'; +import { TRPCError } from '@trpc/server'; +import { useSnackbar } from 'notistack'; +import { useEffect, useState } from 'react'; + +import analyticsEnum, { logAnalytics } from '$lib/analytics'; +import { trpc } from '$lib/trpc'; +import AppStore from '$stores/AppStore'; +import { useThemeStore } from '$stores/SettingsStore'; + +export function SaveButton() { + const [open, setOpen] = useState(false); + + const [loading, setLoading] = useState(false); + + const [visibility, setVisibility] = useState('public'); + + const [skeletonMode, setSkeletonMode] = useState(AppStore.getSkeletonMode()); + + const [userId, setUserId] = useState(''); + + const { enqueueSnackbar } = useSnackbar(); + + const isDark = useThemeStore((store) => store.isDark); + + const authStatus = trpc.auth.status.useQuery(); + + const saveMutation = trpc.users.saveUserData.useMutation(); + + const handleOpen = () => { + setOpen(true); + }; + + const handleClose = () => { + setOpen(false); + }; + + const handlevisibilityChange = (e: React.ChangeEvent) => { + setVisibility(e.target.value); + }; + + const handleSubmit = async () => { + setLoading(true); + + logAnalytics({ + category: analyticsEnum.nav.title, + action: analyticsEnum.nav.actions.SAVE_SCHEDULE, + label: userId, + value: 1, + }); + + const normalizedUserId = userId.replace(/\s+/g, ''); + + if (!normalizedUserId) { + enqueueSnackbar('Invalid user ID.', { variant: 'error' }); + setLoading(false); + return; + } + + const scheduleSaveState = AppStore.schedule.getScheduleAsSaveState(); + + try { + await saveMutation.mutateAsync({ + /** + * Requester ID. + * + * The user may be logged in, and save the schedule under a different account. + * Based on the schedule's visibility settings, this may or may not be permitted. + * + * If the user is not logged in, assume that the requester and requestee are the same. + */ + id: authStatus.data?.id ?? normalizedUserId, + + /** + * Assume that the schedule belongs to whatever the specified userId is. + */ + data: { + id: normalizedUserId, + visibility, + userData: scheduleSaveState, + }, + }); + + enqueueSnackbar( + `Schedule saved under username "${normalizedUserId}". Don't forget to sign up for classes on WebReg!`, + { variant: 'success' } + ); + AppStore.saveSchedule(); + } catch (e) { + if (e instanceof TRPCError) { + enqueueSnackbar(`Schedule could not be saved under username "${normalizedUserId}`, { + variant: 'error', + }); + } else { + enqueueSnackbar('Network error or server is down.', { variant: 'error' }); + } + } + + setLoading(false); + }; + + const options: string[] = []; + + if (authStatus.data?.id != null) { + options.push(authStatus.data?.id); + } + + if (userId && userId !== authStatus.data?.id) { + options.push(userId); + } + + useEffect(() => { + const handleSkeletonModeChange = () => { + setSkeletonMode(AppStore.getSkeletonMode()); + }; + + AppStore.on('skeletonModeChange', handleSkeletonModeChange); + + return () => { + AppStore.off('skeletonModeChange', handleSkeletonModeChange); + }; + }, []); + + return ( + <> + : } + disabled={skeletonMode} + loading={false} + > + Save + + + + Save + + + + Enter a unique user ID here to save a schedule. + + { + if (newValue != null) { + setUserId(newValue); + } + }} + sx={{ width: 300 }} + options={options} + renderInput={(params) => { + return ; + }} + /> + + + Visibility + + + } label="Public" /> + + + } label="Open" /> + + + } label="Private" /> + + + + + + + + + + + + + ); +} + +export default SaveButton; diff --git a/apps/antalmanac/src/lib/api/trpc.ts b/apps/antalmanac/src/lib/api/trpc.ts index 3e1ef8e89..0926cf705 100644 --- a/apps/antalmanac/src/lib/api/trpc.ts +++ b/apps/antalmanac/src/lib/api/trpc.ts @@ -1,8 +1,8 @@ import { createTRPCProxyClient, httpBatchLink } from '@trpc/client'; +import type { AppRouter } from 'antalmanac-backend/src/routers'; import superjson from 'superjson'; -import type { AppRouter } from '../../../../backend/src/routers'; -function getEndpoint() { +export function getEndpoint() { if (import.meta.env.VITE_ENDPOINT) { return `https://${import.meta.env.VITE_ENDPOINT}.api.antalmanac.com`; } diff --git a/apps/antalmanac/src/lib/trpc.ts b/apps/antalmanac/src/lib/trpc.ts new file mode 100644 index 000000000..18b1293c1 --- /dev/null +++ b/apps/antalmanac/src/lib/trpc.ts @@ -0,0 +1,11 @@ +import { createTRPCReact } from '@trpc/react-query'; +import type { inferReactQueryProcedureOptions } from '@trpc/react-query'; +import type { inferRouterInputs, inferRouterOutputs } from '@trpc/server'; +import type { AppRouter } from 'antalmanac-backend/src/routers'; + +export const trpc = createTRPCReact(); + +// infer the types for your router +export type ReactQueryOptions = inferReactQueryProcedureOptions; +export type RouterInputs = inferRouterInputs; +export type RouterOutputs = inferRouterOutputs; diff --git a/apps/antalmanac/src/providers/Query.tsx b/apps/antalmanac/src/providers/Query.tsx index 64c6db91d..1055339ff 100644 --- a/apps/antalmanac/src/providers/Query.tsx +++ b/apps/antalmanac/src/providers/Query.tsx @@ -1,11 +1,37 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { httpLink } from '@trpc/client'; +import { useState } from 'react'; +import transformer from 'superjson'; + +import { getEndpoint } from '$lib/api/trpc'; +import { trpc } from '$lib/trpc'; interface Props { children?: React.ReactNode; } export default function AppQueryProvider(props: Props) { - const queryClient = new QueryClient(); + const [queryClient] = useState(() => new QueryClient()); + const [trpcClient] = useState(() => + trpc.createClient({ + transformer, + links: [ + httpLink({ + fetch(url, options) { + return fetch(url, { + ...options, + credentials: 'include', + }); + }, + url: getEndpoint() + '/trpc', + }), + ], + }) + ); - return {props.children}; + return ( + + {props.children}; + + ); } diff --git a/apps/backend/package.json b/apps/backend/package.json index 6f1d36e3e..67d142b0b 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -15,10 +15,13 @@ "@vendia/serverless-express": "^4.10.1", "arktype": "1.0.14-alpha", "aws-lambda": "^1.0.7", + "cookie-parser": "^1.4.6", "cors": "^2.8.5", "dotenv": "^16.0.3", "envalid": "^7.3.1", "express": "^4.18.2", + "jose": "^5.2.4", + "jsonwebtoken": "^9.0.2", "mongodb": "^5.0.1", "mongoose": "^7.1.0j", "superjson": "^1.12.3", @@ -28,8 +31,10 @@ "@aws-sdk/client-dynamodb": "^3.332.0", "@aws-sdk/lib-dynamodb": "^3.332.0", "@types/aws-lambda": "^8.10.110", + "@types/cookie-parser": "^1.4.7", "@types/cors": "^2.8.13", "@types/express": "^4.17.17", + "@types/jsonwebtoken": "^9.0.6", "@typescript-eslint/eslint-plugin": "^5.52.0", "@typescript-eslint/parser": "^5.52.0", "concurrently": "^8.0.1", @@ -43,11 +48,5 @@ "prettier": "^2.8.4", "tsx": "^3.12.7", "typescript": "^4.9.5" - }, - "lint-staged": { - "*.{js,json,css,html}": [ - "prettier --write", - "git add" - ] } } diff --git a/apps/backend/src/db/ddb.ts b/apps/backend/src/db/ddb.ts index b26f6e72a..4a0162751 100644 --- a/apps/backend/src/db/ddb.ts +++ b/apps/backend/src/db/ddb.ts @@ -25,9 +25,9 @@ export const VISIBILITY = { }; class DDBClient>> { - private tableName: string; + tableName: string; - private schema: T; + schema: T; client: DynamoDB; @@ -144,20 +144,20 @@ class DDBClient>> { return (await ddbClient.get('googleId', googleId))?.userData; } - async viewUserData(requesterId: string, requesteeId: string) { + async viewUserData(requesteeId: string, requesterId?: string) { const existingUserData = await ddbClient.get('id', requesteeId); if (existingUserData == null) { - return undefined; + return null; } const parsedUserData = UserSchema(existingUserData); if (parsedUserData.problems != null) { - return undefined; + return null; } - parsedUserData.data.visibility ??= VISIBILITY.PRIVATE; + const visibility = parsedUserData.data.visibility ?? VISIBILITY.PUBLIC; // Requester and requestee IDs must match if schedule is private. // Otherwise, return the schedule without any additional processing. @@ -166,12 +166,11 @@ class DDBClient>> { // check the schedule's user ID with the user ID making the request // to fully define the visibility system. - if (parsedUserData.data.visibility === VISIBILITY.PRIVATE) { - return requesterId === requesteeId ? parsedUserData.data : undefined; + if (visibility === VISIBILITY.PRIVATE) { + return requesterId === requesteeId ? parsedUserData.data : null; } else { return parsedUserData.data; } - } } diff --git a/apps/backend/src/index.ts b/apps/backend/src/index.ts index bb1e2a356..9ed243bef 100644 --- a/apps/backend/src/index.ts +++ b/apps/backend/src/index.ts @@ -2,26 +2,31 @@ import express from 'express'; import cors from 'cors'; import type { CorsOptions } from 'cors'; import { createExpressMiddleware } from '@trpc/server/adapters/express'; +import cookieParser from 'cookie-parser'; import AppRouter from './routers'; import createContext from './context'; import env from './env'; -import connectToMongoDB from '$db/mongodb'; - -const corsOptions: CorsOptions = { - origin: ['https://antalmanac.com', 'https://www.antalmanac.com', 'https://icssc-projects.github.io/AntAlmanac'], -}; const MAPBOX_API_URL = 'https://api.mapbox.com'; const PORT = 3000; +const origins = ['https://antalmanac.com', 'https://www.antalmanac.com', 'https://icssc-projects.github.io/AntAlmanac']; + export async function start(corsEnabled = false) { - await connectToMongoDB(); + const corsOptions: CorsOptions = { + credentials: true, + origin: corsEnabled ? origins : true, + }; const app = express(); - app.use(cors(corsEnabled ? corsOptions : undefined)); + + app.use(cors(corsOptions)); + app.use(express.json()); + app.use(cookieParser()); + app.use('/mapbox/directions/*', async (req, res) => { const searchParams = new URLSearchParams(req.query as any); searchParams.set('access_token', env.MAPBOX_ACCESS_TOKEN); @@ -33,16 +38,14 @@ export async function start(corsEnabled = false) { app.use('/mapbox/tiles/*', async (req, res) => { const searchParams = new URLSearchParams(req.query as any); searchParams.set('access_token', env.MAPBOX_ACCESS_TOKEN); - const url = `${MAPBOX_API_URL}/styles/v1/mapbox/streets-v11/tiles/${(req.params as any)[0]}?${searchParams.toString()}`; + const url = `${MAPBOX_API_URL}/styles/v1/mapbox/streets-v11/tiles/${ + (req.params as any)[0] + }?${searchParams.toString()}`; const buffer = await fetch(url).then((res) => res.arrayBuffer()); - res.type('image/png') - res.send(Buffer.from(buffer)) - // // res.header('Content-Security-Policy', "img-src 'self'"); // https://stackoverflow.com/questions/56386307/loading-of-a-resource-blocked-by-content-security-policy - // // res.header('Access-Control-Allow-Methods', 'GET, OPTIONS') - // res.type('image/png') - // res.send(result) + res.type('image/png'); + res.send(Buffer.from(buffer)); }); - + app.use( '/trpc', createExpressMiddleware({ diff --git a/apps/backend/src/routers/index.ts b/apps/backend/src/routers/index.ts index 2611cca4a..ac3c7f91a 100644 --- a/apps/backend/src/routers/index.ts +++ b/apps/backend/src/routers/index.ts @@ -1,12 +1,152 @@ -import { router } from '../trpc'; +import { type } from 'arktype'; +import jwt from 'jsonwebtoken'; +import { ddbClient } from '../db/ddb'; +import { procedure, router } from '../trpc'; import newsRouter from './news'; import usersRouter from './users'; -import zotcourseRouter from "./zotcours"; +import zotcourseRouter from './zotcours'; + +export interface GoogleProfile extends Record { + aud: string; + azp: string; + email: string; + email_verified: boolean; + exp: number; + family_name?: string; + given_name: string; + hd?: string; + iat: number; + iss: string; + jti?: string; + locale?: string; + name: string; + nbf?: number; + picture: string; + sub: string; +} + +export const AccessTokenSchema = type({ + id: 'string', + 'googleId?': 'string', +}); + +const privateKey = 'secret'; + +const authRouter = router({ + status: procedure.query(async (context) => { + const authCookie = context.ctx.req.cookies['auth']; + + if (authCookie == null) return null; + + try { + const decodedToken = jwt.decode(authCookie); + + const parsedToken = AccessTokenSchema(decodedToken); + + if (parsedToken.problems) { + return null; + } + + return parsedToken.data; + } catch (e) { + console.error('Failed to decode auth cookie: ', e); + return null; + } + }), + /** + * Logs in with Google and returns schedule data. + */ + loginGoogle: procedure.input(type('string').assert).mutation(async (context) => { + try { + const idToken = jwt.decode(context.input) as GoogleProfile; + + const googleId = idToken.sub; + + const existingUser = await ddbClient.get('googleId', googleId).catch((e) => { + console.error('Error while looking up google ID: ', e); + }); + + if (existingUser != null) { + const authCookie = jwt.sign( + { + id: existingUser.id, + googleId, + }, + privateKey + ); + + // Set the auth cookie so the client can be recognized on future requests. + context.ctx.res.cookie('auth', authCookie); + + return existingUser; + } + + await ddbClient.documentClient.put({ + TableName: ddbClient.tableName, + Item: { + id: googleId, + googleId, + }, + }); + + /** + * By default, a new user logged in with Google will just have their ID set to + * their google ID. + */ + const authCookie = jwt.sign( + { + id: googleId, + googleId, + }, + privateKey + ); + + context.ctx.res.cookie('auth', authCookie); + + return { id: googleId, userData: null }; + } catch (e) { + console.error('Error ocurred while logging in with google: ', e); + return null; + } + }), + loginUsername: procedure.input(type('string').assert).mutation(async (context) => { + const id = context.input; + + const userData = await ddbClient.getUserData(id); + + if (userData != null) { + const authCookie = jwt.sign({ id }, privateKey); + + // Set the auth cookie so the client can be recognized on future requests. + context.ctx.res.cookie('auth', authCookie, { maxAge: 1000 * 60 * 60, path: '/' }); + + return { id, userData }; + } + + await ddbClient.documentClient.put({ + TableName: ddbClient.tableName, + Item: { + id, + }, + }); + + const authCookie = jwt.sign({ id }, privateKey); + + // Set the auth cookie so the client can be recognized on future requests. + context.ctx.res.cookie('auth', authCookie, { maxAge: 1000 * 60 * 60, path: '/' }); + + return { id, userData: null }; + }), + logout: procedure.mutation(async (context) => { + context.ctx.res.cookie('auth', '', { maxAge: 0 }); + }), +}); const appRouter = router({ + auth: authRouter, news: newsRouter, users: usersRouter, - zotcourse: zotcourseRouter + zotcourse: zotcourseRouter, }); export type AppRouter = typeof appRouter; diff --git a/apps/backend/src/routers/users.ts b/apps/backend/src/routers/users.ts index 82d09c5c3..dc8453c70 100644 --- a/apps/backend/src/routers/users.ts +++ b/apps/backend/src/routers/users.ts @@ -1,16 +1,18 @@ import { type } from 'arktype'; import { UserSchema } from '@packages/antalmanac-types'; +import { TRPCError } from '@trpc/server'; import { router, procedure } from '../trpc'; import { ddbClient, VISIBILITY } from '../db/ddb'; -import { TRPCError } from '@trpc/server'; const userInputSchema = type([{ userId: 'string' }, '|', { googleId: 'string' }]); const viewInputSchema = type({ /** * ID of the user who's requesting to view another user's schedule. + * + * Maybe undefined if user is viewing anonymously. */ - requesterId: 'string', + 'requesterId?': 'string | undefined', /** * ID of the user whose schedule is being requested. @@ -39,9 +41,18 @@ const usersRouter = router({ */ getUserData: procedure.input(userInputSchema.assert).query(async ({ input }) => { if ('googleId' in input) { - return await ddbClient.getGoogleUserData(input.googleId); + const user = await ddbClient.get('googleId', input.googleId); + if (user == null) return null; + return { id: user.id, visibility: user.visibility, userData: user.userData }; } - return (await ddbClient.getUserData(input.userId)) ?? (await ddbClient.getLegacyUserData(input.userId)); + + const user = (await ddbClient.get('id', input.userId)) ?? (await ddbClient.getLegacyUserData(input.userId)); + + if (user == null) return null; + + const visibility = 'visibility' in user ? user.visibility : undefined; + + return { id: user.id, visibility, userData: user.userData }; }), /** @@ -51,7 +62,7 @@ const usersRouter = router({ /** * Assign default visility value. */ - input.data.visibility ??= VISIBILITY.PRIVATE; + input.data.visibility ??= VISIBILITY.PUBLIC; // Requester and requestee IDs must match if schedule is private. @@ -86,7 +97,7 @@ const usersRouter = router({ * - open: Anybody can view and edit. */ viewUserData: procedure.input(viewInputSchema.assert).query(async ({ input }) => { - return await ddbClient.viewUserData(input.requesterId, input.requesteeId); + return await ddbClient.viewUserData(input.requesteeId, input.requesterId); }), }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fe1cbba64..a11c1d6cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '6.0' +lockfileVersion: '6.1' settings: autoInstallPeers: true @@ -34,7 +34,7 @@ importers: version: 3.1.0 turbo: specifier: latest - version: 1.10.13 + version: 1.13.3 vitest: specifier: ^0.34.4 version: 0.34.4(jsdom@22.1.0) @@ -98,6 +98,9 @@ importers: '@react-leaflet/core': specifier: ^2.1.0 version: 2.1.0(leaflet@1.9.3)(react-dom@18.2.0)(react@18.2.0) + '@react-oauth/google': + specifier: ^0.12.1 + version: 0.12.1(react-dom@18.2.0)(react@18.2.0) '@reactour/tour': specifier: ^3.6.1 version: 3.6.1(react@18.2.0) @@ -107,9 +110,15 @@ importers: '@trpc/client': specifier: ^10.30.0 version: 10.30.0(@trpc/server@10.30.0) + '@trpc/react-query': + specifier: ^10.45.2 + version: 10.45.2(@tanstack/react-query@4.24.10)(@trpc/client@10.30.0)(@trpc/server@10.30.0)(react-dom@18.2.0)(react@18.2.0) '@trpc/server': specifier: ^10.30.0 version: 10.30.0 + antalmanac-backend: + specifier: workspace:^ + version: link:../backend chart.js: specifier: ^4.2.1 version: 4.2.1 @@ -266,7 +275,7 @@ importers: version: 8.8.0(eslint@8.37.0) eslint-plugin-import: specifier: ^2.27.5 - version: 2.27.5(eslint-import-resolver-typescript@3.6.1)(eslint@8.37.0) + version: 2.27.5(@typescript-eslint/parser@5.57.1)(eslint-import-resolver-typescript@3.6.1)(eslint@8.38.0) eslint-plugin-jsx-a11y: specifier: ^6.7.1 version: 6.7.1(eslint@8.37.0) @@ -309,6 +318,9 @@ importers: aws-lambda: specifier: ^1.0.7 version: 1.0.7 + cookie-parser: + specifier: ^1.4.6 + version: 1.4.6 cors: specifier: ^2.8.5 version: 2.8.5 @@ -321,6 +333,12 @@ importers: express: specifier: ^4.18.2 version: 4.18.2 + jose: + specifier: ^5.2.4 + version: 5.2.4 + jsonwebtoken: + specifier: ^9.0.2 + version: 9.0.2 mongodb: specifier: ^5.0.1 version: 5.0.1 @@ -343,12 +361,18 @@ importers: '@types/aws-lambda': specifier: ^8.10.110 version: 8.10.110 + '@types/cookie-parser': + specifier: ^1.4.7 + version: 1.4.7 '@types/cors': specifier: ^2.8.13 version: 2.8.13 '@types/express': specifier: ^4.17.17 version: 4.17.17 + '@types/jsonwebtoken': + specifier: ^9.0.6 + version: 9.0.6 '@typescript-eslint/eslint-plugin': specifier: ^5.52.0 version: 5.57.1(@typescript-eslint/parser@5.57.1)(eslint@8.38.0)(typescript@4.9.5) @@ -3390,6 +3414,16 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@react-oauth/google@0.12.1(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-qagsy22t+7UdkYAiT5ZhfM4StXi9PPNvw0zuwNmabrWyMKddczMtBIOARflbaIj+wHiQjnMAsZmzsUYuXeyoSg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@reactour/mask@1.1.0(react@18.2.0): resolution: {integrity: sha512-GkJMLuTs3vTsm4Ryq2uXcE4sMzRP1p4xSd6juSOMqbHa7IVD/UiLCLqJWHR9xGSQPbYhpZAZAORUG5cS0U5tBA==} peerDependencies: @@ -4061,6 +4095,22 @@ packages: '@trpc/server': 10.30.0 dev: false + /@trpc/react-query@10.45.2(@tanstack/react-query@4.24.10)(@trpc/client@10.30.0)(@trpc/server@10.30.0)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-BAqb9bGZIscroradlNx+Cc9522R+idY3BOSf5z0jHUtkxdMbjeGKxSSMxxu7JzoLqSIEC+LVzL3VvF8sdDWaZQ==} + peerDependencies: + '@tanstack/react-query': ^4.18.0 + '@trpc/client': 10.45.2 + '@trpc/server': 10.45.2 + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + '@tanstack/react-query': 4.24.10(react-dom@18.2.0)(react@18.2.0) + '@trpc/client': 10.30.0(@trpc/server@10.30.0) + '@trpc/server': 10.30.0 + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@trpc/server@10.30.0: resolution: {integrity: sha512-pRsrHCuar3fbyOdJvO4b80OMP1Tx/wOSy5Ozy6cFDFWVUmfAyIX3En5Hoysy4cmMUuCsQsfTEYQwo+OcpjzBkg==} dev: false @@ -4096,6 +4146,12 @@ packages: '@types/node': 20.11.5 dev: true + /@types/cookie-parser@1.4.7: + resolution: {integrity: sha512-Fvuyi354Z+uayxzIGCwYTayFKocfV7TuDYZClCdIP9ckhvAu/ixDtCB6qx2TT0FKjPLf1f3P/J1rgf6lPs64mw==} + dependencies: + '@types/express': 4.17.17 + dev: true + /@types/cors@2.8.13: resolution: {integrity: sha512-RG8AStHlUiV5ysZQKq97copd2UmVYw3/pRMLefISZ3S1hK104Cwm7iLQ3fTKx+lsUH2CE8FlLaYeEA2LSeqYUA==} dependencies: @@ -4193,6 +4249,12 @@ packages: resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} dev: true + /@types/jsonwebtoken@9.0.6: + resolution: {integrity: sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw==} + dependencies: + '@types/node': 20.11.6 + dev: true + /@types/leaflet-routing-machine@3.2.4: resolution: {integrity: sha512-CFcWghU+eDel/zY2Q1u1tI6rxL2V6SfJ85/prt7s2QEw8tK+OkH+o5PLm/7eYsZH86i30ff5rmEk8GN1nZ9ODg==} dependencies: @@ -4941,6 +5003,10 @@ packages: engines: {node: '>=14.20.1'} dev: false + /buffer-equal-constant-time@1.0.1: + resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==} + dev: false + /buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} dev: true @@ -5170,10 +5236,23 @@ packages: /convert-source-map@1.9.0: resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + /cookie-parser@1.4.6: + resolution: {integrity: sha512-z3IzaNjdwUC2olLIB5/ITd0/setiaFMLYiZJle7xg5Fe9KWAceil7xszYfHHBtDFYLSgJduS2Ty0P1uJdPDJeA==} + engines: {node: '>= 0.8.0'} + dependencies: + cookie: 0.4.1 + cookie-signature: 1.0.6 + dev: false + /cookie-signature@1.0.6: resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} dev: false + /cookie@0.4.1: + resolution: {integrity: sha512-ZwrFkGJxUR3EIoXtO+yVE69Eb7KlixbaeAWfBQB9vVsNn/o+Yw69gBWSSDK825hQNdN+wF8zELf3dFNl/kxkUA==} + engines: {node: '>= 0.6'} + dev: false + /cookie@0.5.0: resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==} engines: {node: '>= 0.6'} @@ -5520,6 +5599,12 @@ packages: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} dev: true + /ecdsa-sig-formatter@1.0.11: + resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} + dependencies: + safe-buffer: 5.2.1 + dev: false + /ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} dev: false @@ -5763,7 +5848,7 @@ packages: enhanced-resolve: 5.15.1 eslint: 8.38.0 eslint-module-utils: 2.7.4(@typescript-eslint/parser@5.57.1)(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.6.1)(eslint@8.38.0) - eslint-plugin-import: 2.27.5(eslint-import-resolver-typescript@3.6.1)(eslint@8.38.0) + eslint-plugin-import: 2.27.5(@typescript-eslint/parser@5.57.1)(eslint-import-resolver-typescript@3.6.1)(eslint@8.38.0) fast-glob: 3.3.2 get-tsconfig: 4.6.2 is-core-module: 2.11.0 @@ -5805,35 +5890,6 @@ packages: - supports-color dev: true - /eslint-module-utils@2.7.4(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.6.1)(eslint@8.37.0): - resolution: {integrity: sha512-j4GT+rqzCoRKHwURX7pddtIPGySnX9Si/cgMI5ztrcqOPtk5dDEeZ34CQVPphnqkJytlc97Vuk05Um2mJ3gEQA==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: '*' - eslint-import-resolver-node: '*' - eslint-import-resolver-typescript: '*' - eslint-import-resolver-webpack: '*' - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - eslint: - optional: true - eslint-import-resolver-node: - optional: true - eslint-import-resolver-typescript: - optional: true - eslint-import-resolver-webpack: - optional: true - dependencies: - debug: 3.2.7(supports-color@5.5.0) - eslint: 8.37.0 - eslint-import-resolver-node: 0.3.7 - eslint-import-resolver-typescript: 3.6.1(eslint-plugin-import@2.27.5)(eslint@8.38.0) - transitivePeerDependencies: - - supports-color - dev: true - /eslint-plugin-import@2.27.5(@typescript-eslint/parser@5.57.1)(eslint-import-resolver-typescript@3.6.1)(eslint@8.38.0): resolution: {integrity: sha512-LmEt3GVofgiGuiE+ORpnvP+kAm3h6MLZJ4Q5HCyHADofsb4VzXFsRiWj3c0OFiV+3DWFh0qg3v9gcPlfc3zRow==} engines: {node: '>=4'} @@ -5867,70 +5923,6 @@ packages: - supports-color dev: true - /eslint-plugin-import@2.27.5(eslint-import-resolver-typescript@3.6.1)(eslint@8.37.0): - resolution: {integrity: sha512-LmEt3GVofgiGuiE+ORpnvP+kAm3h6MLZJ4Q5HCyHADofsb4VzXFsRiWj3c0OFiV+3DWFh0qg3v9gcPlfc3zRow==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - dependencies: - array-includes: 3.1.6 - array.prototype.flat: 1.3.1 - array.prototype.flatmap: 1.3.1 - debug: 3.2.7(supports-color@5.5.0) - doctrine: 2.1.0 - eslint: 8.37.0 - eslint-import-resolver-node: 0.3.7 - eslint-module-utils: 2.7.4(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.6.1)(eslint@8.37.0) - has: 1.0.3 - is-core-module: 2.11.0 - is-glob: 4.0.3 - minimatch: 3.1.2 - object.values: 1.1.6 - resolve: 1.22.1 - semver: 6.3.0 - tsconfig-paths: 3.14.1 - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - dev: true - - /eslint-plugin-import@2.27.5(eslint-import-resolver-typescript@3.6.1)(eslint@8.38.0): - resolution: {integrity: sha512-LmEt3GVofgiGuiE+ORpnvP+kAm3h6MLZJ4Q5HCyHADofsb4VzXFsRiWj3c0OFiV+3DWFh0qg3v9gcPlfc3zRow==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - dependencies: - array-includes: 3.1.6 - array.prototype.flat: 1.3.1 - array.prototype.flatmap: 1.3.1 - debug: 3.2.7(supports-color@5.5.0) - doctrine: 2.1.0 - eslint: 8.38.0 - eslint-import-resolver-node: 0.3.7 - eslint-module-utils: 2.7.4(@typescript-eslint/parser@5.57.1)(eslint-import-resolver-node@0.3.7)(eslint-import-resolver-typescript@3.6.1)(eslint@8.38.0) - has: 1.0.3 - is-core-module: 2.11.0 - is-glob: 4.0.3 - minimatch: 3.1.2 - object.values: 1.1.6 - resolve: 1.22.1 - semver: 6.3.0 - tsconfig-paths: 3.14.1 - transitivePeerDependencies: - - eslint-import-resolver-typescript - - eslint-import-resolver-webpack - - supports-color - dev: true - /eslint-plugin-jsx-a11y@6.7.1(eslint@8.37.0): resolution: {integrity: sha512-63Bog4iIethyo8smBklORknVjB0T2dwB8Mr/hIC+fBS0uyHdYYpzM/Ed+YC8VxTjlXHEWFOdmgwcDn1U2L9VCA==} engines: {node: '>=4.0'} @@ -6935,6 +6927,10 @@ packages: resolution: {integrity: sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==} engines: {node: '>= 0.6.0'} + /jose@5.2.4: + resolution: {integrity: sha512-6ScbIk2WWCeXkmzF6bRPmEuaqy1m8SbsRFMa/FLrSCkGIhj8OLVG/IH+XHVmNMx/KUo8cVWEE6oKR4dJ+S0Rkg==} + dev: false + /js-sdsl@4.3.0: resolution: {integrity: sha512-mifzlm2+5nZ+lEcLJMoBK0/IH/bDg8XnJfd/Wq6IP+xoCjLZsTOnV2QpxlVbX9bMnkl5PdEjNtBJ9Cj1NjifhQ==} dev: true @@ -7029,6 +7025,22 @@ packages: resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==} dev: true + /jsonwebtoken@9.0.2: + resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==} + engines: {node: '>=12', npm: '>=6'} + dependencies: + jws: 3.2.2 + lodash.includes: 4.3.0 + lodash.isboolean: 3.0.3 + lodash.isinteger: 4.0.4 + lodash.isnumber: 3.0.3 + lodash.isplainobject: 4.0.6 + lodash.isstring: 4.0.1 + lodash.once: 4.1.1 + ms: 2.1.3 + semver: 7.6.0 + dev: false + /jss-plugin-camel-case@10.10.0: resolution: {integrity: sha512-z+HETfj5IYgFxh1wJnUAU8jByI48ED+v0fuTuhKrPR+pRBYS2EDwbusU8aFOpCdYhtRc9zhN+PJ7iNE8pAWyPw==} dependencies: @@ -7099,6 +7111,21 @@ packages: object.assign: 4.1.4 dev: true + /jwa@1.4.1: + resolution: {integrity: sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==} + dependencies: + buffer-equal-constant-time: 1.0.1 + ecdsa-sig-formatter: 1.0.11 + safe-buffer: 5.2.1 + dev: false + + /jws@3.2.2: + resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==} + dependencies: + jwa: 1.4.1 + safe-buffer: 5.2.1 + dev: false + /kareem@2.5.1: resolution: {integrity: sha512-7jFxRVm+jD+rkq3kY0iZDJfsO2/t4BBPeEb2qKn2lR/9KhuksYk5hxzfRYWMPV8P/x2d0kHD306YyWLzjjH+uA==} engines: {node: '>=12.0.0'} @@ -7204,10 +7231,38 @@ packages: resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} dev: false + /lodash.includes@4.3.0: + resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} + dev: false + + /lodash.isboolean@3.0.3: + resolution: {integrity: sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==} + dev: false + + /lodash.isinteger@4.0.4: + resolution: {integrity: sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==} + dev: false + + /lodash.isnumber@3.0.3: + resolution: {integrity: sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==} + dev: false + + /lodash.isplainobject@4.0.6: + resolution: {integrity: sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==} + dev: false + + /lodash.isstring@4.0.1: + resolution: {integrity: sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==} + dev: false + /lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} dev: true + /lodash.once@4.1.1: + resolution: {integrity: sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==} + dev: false + /lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} @@ -7244,7 +7299,6 @@ packages: engines: {node: '>=10'} dependencies: yallist: 4.0.0 - dev: true /luxon@3.2.1: resolution: {integrity: sha512-QrwPArQCNLAKGO/C+ZIilgIuDnEnKx5QYODdDtbFaxzsbZcc/a7WFq7MhsVYgRlwawLtvOUESTlfJ+hc/USqPg==} @@ -8407,6 +8461,14 @@ packages: lru-cache: 6.0.0 dev: true + /semver@7.6.0: + resolution: {integrity: sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==} + engines: {node: '>=10'} + hasBin: true + dependencies: + lru-cache: 6.0.0 + dev: false + /send@0.18.0: resolution: {integrity: sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==} engines: {node: '>= 0.8.0'} @@ -8867,64 +8929,64 @@ packages: engines: {node: '>=0.6.11 <=0.7.0 || >=0.7.3'} dev: false - /turbo-darwin-64@1.10.13: - resolution: {integrity: sha512-vmngGfa2dlYvX7UFVncsNDMuT4X2KPyPJ2Jj+xvf5nvQnZR/3IeDEGleGVuMi/hRzdinoxwXqgk9flEmAYp0Xw==} + /turbo-darwin-64@1.13.3: + resolution: {integrity: sha512-glup8Qx1qEFB5jerAnXbS8WrL92OKyMmg5Hnd4PleLljAeYmx+cmmnsmLT7tpaVZIN58EAAwu8wHC6kIIqhbWA==} cpu: [x64] os: [darwin] requiresBuild: true dev: true optional: true - /turbo-darwin-arm64@1.10.13: - resolution: {integrity: sha512-eMoJC+k7gIS4i2qL6rKmrIQGP6Wr9nN4odzzgHFngLTMimok2cGLK3qbJs5O5F/XAtEeRAmuxeRnzQwTl/iuAw==} + /turbo-darwin-arm64@1.13.3: + resolution: {integrity: sha512-/np2xD+f/+9qY8BVtuOQXRq5f9LehCFxamiQnwdqWm5iZmdjygC5T3uVSYuagVFsZKMvX3ycySwh8dylGTl6lg==} cpu: [arm64] os: [darwin] requiresBuild: true dev: true optional: true - /turbo-linux-64@1.10.13: - resolution: {integrity: sha512-0CyYmnKTs6kcx7+JRH3nPEqCnzWduM0hj8GP/aodhaIkLNSAGAa+RiYZz6C7IXN+xUVh5rrWTnU2f1SkIy7Gdg==} + /turbo-linux-64@1.13.3: + resolution: {integrity: sha512-G+HGrau54iAnbXLfl+N/PynqpDwi/uDzb6iM9hXEDG+yJnSJxaHMShhOkXYJPk9offm9prH33Khx2scXrYVW1g==} cpu: [x64] os: [linux] requiresBuild: true dev: true optional: true - /turbo-linux-arm64@1.10.13: - resolution: {integrity: sha512-0iBKviSGQQlh2OjZgBsGjkPXoxvRIxrrLLbLObwJo3sOjIH0loGmVIimGS5E323soMfi/o+sidjk2wU1kFfD7Q==} + /turbo-linux-arm64@1.13.3: + resolution: {integrity: sha512-qWwEl5VR02NqRyl68/3pwp3c/olZuSp+vwlwrunuoNTm6JXGLG5pTeme4zoHNnk0qn4cCX7DFrOboArlYxv0wQ==} cpu: [arm64] os: [linux] requiresBuild: true dev: true optional: true - /turbo-windows-64@1.10.13: - resolution: {integrity: sha512-S5XySRfW2AmnTeY1IT+Jdr6Goq7mxWganVFfrmqU+qqq3Om/nr0GkcUX+KTIo9mPrN0D3p5QViBRzulwB5iuUQ==} + /turbo-windows-64@1.13.3: + resolution: {integrity: sha512-Nudr4bRChfJzBPzEmpVV85VwUYRCGKecwkBFpbp2a4NtrJ3+UP1VZES653ckqCu2FRyRuS0n03v9euMbAvzH+Q==} cpu: [x64] os: [win32] requiresBuild: true dev: true optional: true - /turbo-windows-arm64@1.10.13: - resolution: {integrity: sha512-nKol6+CyiExJIuoIc3exUQPIBjP9nIq5SkMJgJuxsot2hkgGrafAg/izVDRDrRduQcXj2s8LdtxJHvvnbI8hEQ==} + /turbo-windows-arm64@1.13.3: + resolution: {integrity: sha512-ouJCgsVLd3icjRLmRvHQDDZnmGzT64GBupM1Y+TjtYn2LVaEBoV6hicFy8x5DUpnqdLy+YpCzRMkWlwhmkX7sQ==} cpu: [arm64] os: [win32] requiresBuild: true dev: true optional: true - /turbo@1.10.13: - resolution: {integrity: sha512-vOF5IPytgQPIsgGtT0n2uGZizR2N3kKuPIn4b5p5DdeLoI0BV7uNiydT7eSzdkPRpdXNnO8UwS658VaI4+YSzQ==} + /turbo@1.13.3: + resolution: {integrity: sha512-n17HJv4F4CpsYTvKzUJhLbyewbXjq1oLCi90i5tW1TiWDz16ML1eDG7wi5dHaKxzh5efIM56SITnuVbMq5dk4g==} hasBin: true optionalDependencies: - turbo-darwin-64: 1.10.13 - turbo-darwin-arm64: 1.10.13 - turbo-linux-64: 1.10.13 - turbo-linux-arm64: 1.10.13 - turbo-windows-64: 1.10.13 - turbo-windows-arm64: 1.10.13 + turbo-darwin-64: 1.13.3 + turbo-darwin-arm64: 1.13.3 + turbo-linux-64: 1.13.3 + turbo-linux-arm64: 1.13.3 + turbo-windows-64: 1.13.3 + turbo-windows-arm64: 1.13.3 dev: true /type-check@0.4.0: @@ -9511,7 +9573,6 @@ packages: /yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - dev: true /yaml@1.10.2: resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==}