diff --git a/src/backend/main.ts b/src/backend/main.ts index bcc3fa7..afe6350 100644 --- a/src/backend/main.ts +++ b/src/backend/main.ts @@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import cookieParser from 'cookie-parser'; import { Request } from 'express'; +import mongoose from 'mongoose'; import morgan from 'morgan'; import { WinstonModule } from 'nest-winston'; @@ -37,6 +38,14 @@ async function bootstrap() { const document = SwaggerModule.createDocument(app, config); SwaggerModule.setup('api', app, document); + (async () => { + try { + await mongoose.syncIndexes(); + logger.info('Synced indexes to MongoDB!'); + } catch (error) { + logger.warn('Failed to sync indexes to MongoDB: %o', error); + } + })(); await app.listen(3000); } diff --git a/src/backend/plugin-token/plugin-token.controller.ts b/src/backend/plugin-token/plugin-token.controller.ts index 009d75c..0bf367e 100644 --- a/src/backend/plugin-token/plugin-token.controller.ts +++ b/src/backend/plugin-token/plugin-token.controller.ts @@ -1,8 +1,11 @@ -import { BadRequestException, Body, Controller, Get, HttpCode, NotFoundException, Param, Post, Res, UnauthorizedException } from '@nestjs/common'; +import { BadRequestException, Body, Controller, Delete, Get, HttpCode, NotFoundException, Param, Post, Query, Res, UnauthorizedException, UseGuards } from '@nestjs/common'; import { ApiExcludeController } from '@nestjs/swagger'; import { Response } from 'express'; +import { FilterQuery } from 'mongoose'; +import PluginToken from '../../shared/interfaces/plugin-token.interface'; import { User } from '../auth/auth.decorator'; +import { AuthGuard } from '../auth/auth.guard'; import getAppConfig from '../config'; import logger from '../logger'; import { UserDocument } from '../user/user.model'; @@ -96,4 +99,32 @@ export class PluginTokenController { token, }; } + + @Get('') + @UseGuards(AuthGuard) + async getAllTokens(@User() user: UserDocument, @Query('scope') scope = 'own') { + let filter: FilterQuery = { user: user._id }; + + if (scope == 'all' && user.admin) { + filter = {}; + } + + const tokens = await this.pluginTokenService.findTokens(filter); + + return tokens; + } + + @Delete('/:id') + @UseGuards(AuthGuard) + async revokeToken(@User() user: UserDocument, @Param('id') id: string) { + const token = await this.pluginTokenService.findToken({ user: user._id, _id: id }); + + if (!token) { + throw new NotFoundException(); + } + + this.pluginTokenService.deleteTokenById(token._id); + + return token; + } } diff --git a/src/backend/plugin-token/plugin-token.model.ts b/src/backend/plugin-token/plugin-token.model.ts index dbfd390..0c73ce4 100644 --- a/src/backend/plugin-token/plugin-token.model.ts +++ b/src/backend/plugin-token/plugin-token.model.ts @@ -12,8 +12,8 @@ export type PluginTokenDocument = HydratedDocument; const PluginTokenSchema = new mongoose.Schema({ user: { type: String }, label: { type: String, default: 'New token' }, - pollingSecret: { type: String, required: true }, - token: { type: String, required: true }, + pollingSecret: { type: String, select: false }, + token: { type: String, required: true, select: false }, lastUsed: { type: Date, default: Date.now }, }, { timestamps: true }); diff --git a/src/backend/plugin-token/plugin-token.service.ts b/src/backend/plugin-token/plugin-token.service.ts index b35d6bd..cd47802 100644 --- a/src/backend/plugin-token/plugin-token.service.ts +++ b/src/backend/plugin-token/plugin-token.service.ts @@ -1,6 +1,7 @@ import { Inject, Injectable } from '@nestjs/common'; -import mongoose from 'mongoose'; +import mongoose, { FilterQuery } from 'mongoose'; +import PluginToken from '../../shared/interfaces/plugin-token.interface'; import { UtilsService } from '../utils/utils.service'; import { PLUGINTOKEN_MODEL, PluginTokenDocument, PluginTokenModel } from './plugin-token.model'; @@ -80,4 +81,22 @@ export class PluginTokenService { return count > 0; } + + async findTokens(filter: FilterQuery = {}): Promise { + const tokens = await this.pluginTokenModel.find(filter); + + return tokens; + } + + async findToken(filter: FilterQuery = {}): Promise { + const token = await this.pluginTokenModel.findOne(filter); + + return token; + } + + async deleteTokenById(id: string | mongoose.Types.ObjectId): Promise { + const token = await this.pluginTokenModel.findOneAndDelete({ _id: id }); + + return token; + } } diff --git a/src/frontend/src/App.tsx b/src/frontend/src/App.tsx index 65c3f0d..f678f95 100644 --- a/src/frontend/src/App.tsx +++ b/src/frontend/src/App.tsx @@ -26,15 +26,18 @@ import Debug from './pages/Debug'; import Delivery from './pages/Delivery'; import FlowManagement from './pages/FlowManagement'; import Landingpage from './pages/Landingpage'; +import ProfilePage from './pages/Profile'; import Vdgs from './pages/VdgsNew'; import { button } from './utils/ui/customDesign/button'; import { card } from './utils/ui/customDesign/card'; import { datatable } from './utils/ui/customDesign/datatable'; +import { dataview } from './utils/ui/customDesign/dataview'; import { dialog } from './utils/ui/customDesign/dialog'; import { dropdown } from './utils/ui/customDesign/dropdown'; import { global } from './utils/ui/customDesign/global'; import { inputnumber } from './utils/ui/customDesign/inputnumber'; import { inputtext } from './utils/ui/customDesign/inputtext'; +import { menu } from './utils/ui/customDesign/menu'; import { selectbutton } from './utils/ui/customDesign/selectbutton'; import { toast } from './utils/ui/customDesign/toast'; import { toolbar } from './utils/ui/customDesign/toolbar'; @@ -55,6 +58,8 @@ function App() { inputnumber: inputnumber, inputtext: inputtext, selectbutton: selectbutton, + menu: menu, + dataview: dataview, }, { mergeSections: true, mergeProps: false }, @@ -65,7 +70,7 @@ function App() { <> - +
@@ -85,6 +90,7 @@ function App() { } /> } /> } /> + } /> } /> } /> diff --git a/src/frontend/src/components/Navbar.tsx b/src/frontend/src/components/Navbar.tsx index bf0ec53..d1ff173 100644 --- a/src/frontend/src/components/Navbar.tsx +++ b/src/frontend/src/components/Navbar.tsx @@ -2,14 +2,15 @@ import { Disclosure, Menu, Transition } from '@headlessui/react'; import { Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline'; import { Button } from 'primereact/button'; import { Fragment, useContext, useEffect, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import logo from '../assets/cdm_logo.png'; import AuthContext from '../contexts/AuthProvider'; import DarkModeContext from '../contexts/DarkModeProvider'; -import AuthService from '../services/AuthService'; -import { FrontendSettings } from '@/shared/interfaces/config.interface'; +// import { FrontendSettings } from '@/shared/interfaces/config.interface'; +import ProfilePicture from './ProfilePicture'; + import User from '@/shared/interfaces/user.interface'; interface NavItemDefinition { @@ -24,18 +25,12 @@ function classNames(...classes: string[]) { } export default function NavbarWithDropdown() { - const [config, setConfig] = useState(); + // const [config, setConfig] = useState(); const navigate = useNavigate(); const [items, setItems] = useState([]); - const { auth } = useContext(AuthContext); + const { auth, authenticate, logout } = useContext(AuthContext); const { darkMode, changeDarkMode } = useContext(DarkModeContext); - async function logout() { - await AuthService.logout(); - - window.location.reload(); - } - const navItems: NavItemDefinition[] = [ { label: 'Delivery', @@ -63,10 +58,6 @@ export default function NavbarWithDropdown() { }, ]; - const redirectToVatsimAuth = () => { - window.location.replace('/api/auth'); - }; - useEffect(() => { setItems( navItems.filter((item) => @@ -74,13 +65,13 @@ export default function NavbarWithDropdown() { ), ); - AuthService.getConfig() - .then((data) => { - setConfig(data); - }) - .catch((e) => { - console.error(e); - }); + // AuthService.getConfig() + // .then((data) => { + // setConfig(data); + // }) + // .catch((e) => { + // console.error(e); + // }); return () => {}; }, [auth]); @@ -137,7 +128,7 @@ export default function NavbarWithDropdown() {
- {config?.vaccLogoUrl && vacc-logo} + {/* {config?.vaccLogoUrl && vacc-logo} */} +
{/* Profile dropdown */} @@ -186,11 +177,7 @@ export default function NavbarWithDropdown() {
Open user menu - ## +
+ + {({ active }) => ( + + Profile + + )} + {({ active }) => ( logout()} + onClick={logout} className={classNames( active ? 'bg-gray-100' : '', 'block px-4 py-2 text-sm text-gray-700 cursor-pointer', diff --git a/src/frontend/src/components/ProfilePicture.tsx b/src/frontend/src/components/ProfilePicture.tsx new file mode 100644 index 0000000..9c6975d --- /dev/null +++ b/src/frontend/src/components/ProfilePicture.tsx @@ -0,0 +1,39 @@ +import User from '../../../shared/interfaces/user.interface'; + +export interface IProfilePictureProps { + user: User | undefined; + className?: string | void; + size?: number | void; +} + +export default function ProfilePicture({ user, className = 'rounded-full border-2', size = 64 }: IProfilePictureProps) { + return + + {user ? `${user.firstName.charAt(0)}${user.lastName.charAt(0)}` : '?'} + ; +} diff --git a/src/frontend/src/contexts/AuthProvider.tsx b/src/frontend/src/contexts/AuthProvider.tsx index 59b74fa..8de073c 100644 --- a/src/frontend/src/contexts/AuthProvider.tsx +++ b/src/frontend/src/contexts/AuthProvider.tsx @@ -17,20 +17,33 @@ const AuthContext = createContext<{ user: User | undefined }; setAuth: Dispatch>; -}>({ auth: { user: undefined }, setAuth: () => {} }); + authenticate: () => void; + logout: () => void; +}>({ auth: { user: undefined }, setAuth() {}, authenticate() {}, logout() {} }); export const AuthProvider = ({ children }: PropsWithChildren) => { const [auth, setAuth] = useState<{ user: User | undefined }>({ user: undefined }); const navigate = useNavigate(); + function authenticate() { + window.location.replace('/api/auth'); + } + + async function logout() { + await authService.logout(); + + window.location.reload(); + } + useEffect(() => { authService .getProfile() .then((user) => { setAuth({ user }); }) - .catch(() => { + .catch((error) => { + console.error(error); setAuth({ user: undefined }); navigate('/auth-failure'); }); @@ -38,7 +51,7 @@ export const AuthProvider = ({ children }: PropsWithChildren) => { return ( <> - + {children} diff --git a/src/frontend/src/pages/Profile.tsx b/src/frontend/src/pages/Profile.tsx new file mode 100644 index 0000000..0c662f5 --- /dev/null +++ b/src/frontend/src/pages/Profile.tsx @@ -0,0 +1,129 @@ +import { MinusIcon } from '@heroicons/react/24/outline'; +import { Badge } from 'primereact/badge'; +import { Button } from 'primereact/button'; +import { Card } from 'primereact/card'; +import { Menu } from 'primereact/menu'; +import { useContext, useEffect, useState } from 'react'; + +import PluginToken from '../../../shared/interfaces/plugin-token.interface'; +import ProfilePicture from '../components/ProfilePicture'; +import AuthContext from '../contexts/AuthProvider'; +import pluginTokenService from '../services/pluginToken.service'; +import time from '../utils/time'; + +export default function ProfilePage() { + const { auth, logout } = useContext(AuthContext); + + const tabs = { + details: { + label: 'Details', + }, + pluginTokens: { + label: 'Plugin Tokens', + }, + raw: { + label: 'Raw data', + }, + }; + type TTabId = keyof typeof tabs; + + const [activeTab, setActiveTab] = useState(Object.keys(tabs)[0] as TTabId); + const [tokens, setTokens] = useState([]); + + const { user } = auth; + + function loadTokens() { + pluginTokenService.getOwnTokens().then(setTokens).catch(console.error); + } + + useEffect(() => { + const int = setInterval(loadTokens, 60000); + loadTokens(); + + return () => { + clearInterval(int); + }; + }, []); + + async function revoke(token: PluginToken) { + const confirmed = confirm(`Confirm you want to revoke the token "${token.label}" (${token._id})`); + + if (!confirmed) { + return; + } + + await pluginTokenService.revokeToken(token._id); + + loadTokens(); + } + + if (!user) { + return <>; + } + + return (<> +
+
+
+ +
+
+
+ {user.firstName} {user.lastName} +
+
{user.cid}
+
+ {user.admin && } + {user.hasAtcRating && } + {user.banned && } +
+
+
+ +
+
+ +
+ ({ ...data, command: () => setActiveTab(k as TTabId), className: activeTab == k ? 'bg-zinc-700' : undefined }))} /> + + {activeTab == 'details' &&
+ + + {Object.entries({ + 'First name': user.firstName, + 'Last name': user.lastName, + 'VATSIM CID': user.cid, + 'vACDM User ID': user._id, + 'First Seen': user.createdAt, + 'Last updated': user.updatedAt, + }).map(([label, value]) => )} + +
{label}{value}
+
} + {activeTab == 'pluginTokens' &&
+ {!tokens.length ? <> + You do not have any plugin tokens. Start using the vACDM plugin to have the plugin generate one! + : tokens.map(token => ( +
+
+
{token.label}
+ + + {Object.entries({ + 'ID': token._id, + 'Last seen': time.formatDateTime(token.lastUsed), + 'Created at': time.formatDateTime(token.createdAt), + }).map(([label, value]) => )} + +
{label}{value}
+
+
+
+ ))} +
} + {activeTab == 'raw' &&
{JSON.stringify({ user }, undefined, 2)}
} +
+
+
+ ); +} diff --git a/src/frontend/src/services/AuthService.ts b/src/frontend/src/services/AuthService.ts index 4038eed..3b58c5d 100644 --- a/src/frontend/src/services/AuthService.ts +++ b/src/frontend/src/services/AuthService.ts @@ -26,7 +26,7 @@ export async function getConfig() { export async function logout() { try { - await axios.get('/api/auth/logout', { + await axios.post('/api/auth/logout', { withCredentials: true, }); diff --git a/src/frontend/src/services/pluginToken.service.ts b/src/frontend/src/services/pluginToken.service.ts new file mode 100644 index 0000000..677d549 --- /dev/null +++ b/src/frontend/src/services/pluginToken.service.ts @@ -0,0 +1,17 @@ +import axios from 'axios'; + +async function getOwnTokens() { + const tokens = await axios.get('/api/plugin-token'); + + return tokens.data; +} +async function revokeToken(id: string) { + const tokens = await axios.delete(`/api/plugin-token/${id}`); + + return tokens.data; +} + +export default { + getOwnTokens, + revokeToken, +}; diff --git a/src/frontend/src/utils/time.ts b/src/frontend/src/utils/time.ts index 5799c0d..91a1ec0 100644 --- a/src/frontend/src/utils/time.ts +++ b/src/frontend/src/utils/time.ts @@ -12,6 +12,10 @@ function formatTime(inputTime: Date | undefined) { return dayjs(inputTime).utc().format('HH:mm'); } +function formatDateTime(date: Date | string) { + return dayjs(date).utc().format('DD MMM YYYY HH:mm:ss[z]'); +} + function calculateVdgsDiff(time: Date | undefined) { const now: dayjs.Dayjs = dayjs().second(0); const tsat: number = dayjs(time).unix(); @@ -38,6 +42,7 @@ function formatVdgsTobt(time: string) { export default { formatTime, + formatDateTime, calculateVdgsDiff, flowTimeFormat, formatVdgsTobt, diff --git a/src/frontend/src/utils/ui/customDesign/dataview.ts b/src/frontend/src/utils/ui/customDesign/dataview.ts new file mode 100644 index 0000000..4a618e5 --- /dev/null +++ b/src/frontend/src/utils/ui/customDesign/dataview.ts @@ -0,0 +1,33 @@ +import { classNames } from 'primereact/utils'; + +export const dataview = { + dataview: { + content: { + className: classNames( + 'bg-white blue-gray-700 border-0 p-0', + 'dark:bg-zinc-800 dark:text-white/80', // Dark Mode + ), + }, + grid: 'flex flex-wrap ml-0 mr-0 mt-0 bg-white dark:bg-zinc-800', + list: 'flex flex-wrap ml-0 mr-0 mt-0 bg-white dark:bg-zinc-800', + header: 'bg-gray-100 dark:bg-zinc-800 text-gray-800 dark:text-white/80 border-gray-200 dark:border-blue-900/40 border-t border-b p-4 font-bold', + }, + dataviewlayoutoptions: { + listbutton: ({ props }) => ({ + className: classNames( + 'items-center cursor-pointer inline-flex overflow-hidden relative select-none text-center align-bottom justify-center border', + 'transition duration-200', + 'w-12 pt-3 pb-3 rounded-lg rounded-r-none', + props.layout === 'list' ? 'bg-zinc-800 border-blue-500 text-white dark:bg-sky-300 dark:border-sky-300 dark:text-gray-900' : 'bg-white border-gray-300 text-blue-gray-700 dark:bg-zinc-800 dark:border-blue-900/40 dark:text-white/80', + ), + }), + gridbutton: ({ props }) => ({ + className: classNames( + 'items-center cursor-pointer inline-flex overflow-hidden relative select-none text-center align-bottom justify-center border', + 'transition duration-200', + 'w-12 pt-3 pb-3 rounded-lg rounded-l-none', + props.layout === 'grid' ? 'bg-zinc-800 border-blue-500 text-white dark:bg-sky-300 dark:border-sky-300 dark:text-gray-900' : 'bg-white border-gray-300 text-blue-gray-700 dark:bg-zinc-800 dark:border-blue-900/40 dark:text-white/80', + ), + }), + }, +}; diff --git a/src/frontend/src/utils/ui/customDesign/menu.ts b/src/frontend/src/utils/ui/customDesign/menu.ts new file mode 100644 index 0000000..1d68cd1 --- /dev/null +++ b/src/frontend/src/utils/ui/customDesign/menu.ts @@ -0,0 +1,31 @@ +import { classNames } from 'primereact/utils'; + +import { TRANSITIONS } from './transitions'; + +export const menu = { + root: 'py-1 bg-white dark:bg-zinc-800 text-gray-700 dark:text-white border border-gray-300 dark:border-zinc-900/40 rounded-md w-48', + menu: { + className: classNames('m-0 p-0 list-none', 'outline-none'), + }, + content: ({ state }) => ({ + className: classNames( + 'text-gray-700 dark:text-white transition-shadow duration-200 rounded-none', + 'hover:text-gray-700 dark:hover:text-white hover:bg-gray-200 dark:hover:bg-zinc-700', // Hover + { + 'bg-gray-300 text-gray-700 dark:text-white dark:bg-gray-800/90': state.focused, + }, + ), + }), + action: { + className: classNames('text-gray-700 dark:text-white/80 py-3 px-5 select-none', 'cursor-pointer flex items-center no-underline overflow-hidden relative'), + }, + menuitem: { + className: classNames('hover:bg-zinc-600'), + }, + icon: 'text-gray-600 dark:text-white/70 mr-2', + submenuheader: { + className: classNames('m-0 p-3 text-gray-700 dark:text-white/80 bg-white dark:bg-gray-900 font-bold rounded-tl-none rounded-tr-none'), + }, + separator: 'border-t border-gray-300 dark:border-blue-900/40 my-1', + transition: TRANSITIONS.overlay, +}; diff --git a/src/shared/interfaces/plugin-token.interface.ts b/src/shared/interfaces/plugin-token.interface.ts index e659a34..14bad6d 100644 --- a/src/shared/interfaces/plugin-token.interface.ts +++ b/src/shared/interfaces/plugin-token.interface.ts @@ -10,6 +10,10 @@ interface PluginToken { token: string; lastUsed: Date; + + createdAt: string; + updatedAt: string; + _id: string; } export default PluginToken; diff --git a/src/shared/interfaces/user.interface.ts b/src/shared/interfaces/user.interface.ts index 5d6e9f7..140610b 100644 --- a/src/shared/interfaces/user.interface.ts +++ b/src/shared/interfaces/user.interface.ts @@ -13,6 +13,10 @@ interface User { roles: string[]; banned: boolean; admin: boolean; + + createdAt: string; + updatedAt: string; + _id: string; } export default User;