diff --git a/Tokenization/.dockerignore b/Tokenization/.dockerignore new file mode 100644 index 000000000..7328ea86d --- /dev/null +++ b/Tokenization/.dockerignore @@ -0,0 +1,2 @@ +node_modules/ +.react-router/ \ No newline at end of file diff --git a/Tokenization/Dockerfile b/Tokenization/Dockerfile new file mode 100644 index 000000000..0f68d6f53 --- /dev/null +++ b/Tokenization/Dockerfile @@ -0,0 +1,53 @@ +# ---- Base ---- +FROM node:22-alpine AS base +WORKDIR /var/workspace + +# ---- Dependencies (for production) ---- +FROM base AS dependencies +COPY webapp/package*.json ./ +RUN npm ci --only=production && npm cache clean --force + +# ---- Dev Dependencies (for tests) ---- +FROM base AS dev-dependencies + +# Installs packages required for Puppeteer +RUN apk add --no-cache \ + chromium \ + freetype \ + freetype-dev \ + harfbuzz \ + ca-certificates + +ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true +ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser + +COPY webapp . + +RUN npm --silent install + +# ---- Build ---- +FROM base AS build +COPY webapp/package*.json ./ +RUN npm i --only=production && npm cache clean --force +COPY webapp . +RUN npm run build + +# ---- Test ---- +FROM dev-dependencies AS test +CMD ["npm", "run", "test"] + +# ---- Coverage ---- +FROM dev-dependencies AS coverage +CMD ["npm", "run", "coverage"] + +# ---- Production ---- +FROM nginx:alpine AS production +COPY --from=build /var/workspace/build/client /usr/share/nginx/html +COPY docker/provisioning/nginx/conf.d/production.conf /etc/nginx/nginx.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] + +FROM nginx:1.27 AS reverse-proxy +COPY ./docker/provisioning/nginx/conf.d/default.conf /etc/nginx/conf.d +EXPOSE 8080 + diff --git a/Tokenization/docker-compose.yml b/Tokenization/docker-compose.yml index b61d5a991..9160c247d 100644 --- a/Tokenization/docker-compose.yml +++ b/Tokenization/docker-compose.yml @@ -44,9 +44,8 @@ services: condition: service_completed_successfully reverse-proxy: - image: nginx:1.27 - volumes: - - ./docker/provisioning/nginx/conf.d/:/etc/nginx/conf.d/ + build: + target: reverse-proxy ports: - "8080:8080" depends_on: diff --git a/Tokenization/docker/provisioning/nginx/conf.d/production.conf b/Tokenization/docker/provisioning/nginx/conf.d/production.conf new file mode 100644 index 000000000..c851013a0 --- /dev/null +++ b/Tokenization/docker/provisioning/nginx/conf.d/production.conf @@ -0,0 +1,26 @@ +events {} + +http { + include mime.types; + default_type application/octet-stream; + sendfile on; + + server { + listen 80; + listen [::]:80; + + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + location /api { + proxy_pass http://backend:8080; + } + + location / { + try_files $uri /index.html; + } + + } +} diff --git a/Tokenization/webapp/app/app.css b/Tokenization/webapp/app/app.css index f59fbf8aa..3fd4a94f5 100644 --- a/Tokenization/webapp/app/app.css +++ b/Tokenization/webapp/app/app.css @@ -23,4 +23,4 @@ .scale25 { transform: scale(2.5); -} \ No newline at end of file +} diff --git a/Tokenization/webapp/app/contexts/sessionContext.tsx b/Tokenization/webapp/app/contexts/sessionContext.tsx new file mode 100644 index 000000000..2eae7e40a --- /dev/null +++ b/Tokenization/webapp/app/contexts/sessionContext.tsx @@ -0,0 +1,108 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ +import React, { createContext, useState, useEffect, useMemo, useCallback } from 'react'; +import { useLocation, useNavigate } from 'react-router'; + +/** + * Session context provides information about the current user session. + * + * The session object contains the following properties: + * - personid: User's person ID + * - name: User's display name + * - token: Authentication token + * - username: User's username + * - access: Array of user's access roles + * + */ +interface Session { + personid: string | null; + name: string | null; + token: string | null; + username: string | null; + access: string[] | null; +} + +type SessionKey = keyof Session; + +const defaultSession = { + personid: null, + name: null, + token: null, + username: null, + access: null, +}; + +// List ["personid", "name", "token", ...] +const sessionKeys = Object.keys(defaultSession) as SessionKey[]; + +interface SessionContextType { + session: Session; + hasAccess: (role: string) => boolean; +} + +/** + * React context for managing user session state. + * Provides session data and access control functionality. + */ +export const SessionContext = createContext({ + session: defaultSession, + hasAccess: () => false, +}); + +/** + * Session provider component that manages user session state. + * + * Automatically extracts session data from URL parameters on mount and + * provides session context to child components. + * + * @param children - React components that need access to session context + * + * @example + * ```tsx + * + * + * + * ``` + */ +export const SessionProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [session, setSession] = useState(defaultSession); + const location = useLocation(); + const navigate = useNavigate(); + + useEffect(() => { + let sessionLoad: Session = { ...defaultSession }; + const params = new URLSearchParams(location.search); + for (const sessionKey of sessionKeys) { + const value = params.get(sessionKey); + if (value && sessionKey === 'access') { + sessionLoad = { ...sessionLoad, [sessionKey]: value.split(',') }; + } else if (value) { + sessionLoad = { ...sessionLoad, [sessionKey]: value }; + } + } + setSession(sessionLoad); + navigate(location.pathname, { replace: true }); + // It should run only once when we start page + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const hasAccess = useCallback( + (role: string) => session.access?.includes(role) ?? false, + [session], + ); + + const value = useMemo(() => ({ session, hasAccess }), [session, hasAccess]); + + return {children}; +}; diff --git a/Tokenization/webapp/app/hooks/session.tsx b/Tokenization/webapp/app/hooks/session.tsx new file mode 100644 index 000000000..c86836b06 --- /dev/null +++ b/Tokenization/webapp/app/hooks/session.tsx @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +import { useContext } from 'react'; + +import { SessionContext } from '../contexts/sessionContext'; + +/** + * Custom hook to access the current user session data. + * + * @returns {Session} The current session object + * + * @throws {Error} If the hook is used outside of SessionProvider + * + * @example + * ```tsx + * const session = useSession(); + * console.log(`Welcome, ${session.name}!`); + * ``` + */ +export function useSession() { + const obj = useContext(SessionContext); + if (!obj) { + throw new Error('Session wasnt created'); + } + return obj.session; +} + +/** + * Custom hook to check if the current user has access to a specific role. + * + * @param {string} role - The role to check access for + * @returns {boolean} True if the user has the specified role, false otherwise + * + * @throws {Error} If the hook is used outside of SessionProvider + * + * @example + * ```tsx + * const hasAdminAccess = useAuth('admin'); + * const canEditTokens = useAuth('token-editor'); + * + * if (hasAdminAccess) { + * // Render admin-only content + * } + * ``` + */ +export function useAuth(role: string) { + const obj = useContext(SessionContext); + if (!obj) { + throw new Error('Session wasnt created'); + } + return obj.hasAccess(role); +} diff --git a/Tokenization/webapp/app/root.tsx b/Tokenization/webapp/app/root.tsx index db2946894..f78641b77 100644 --- a/Tokenization/webapp/app/root.tsx +++ b/Tokenization/webapp/app/root.tsx @@ -19,11 +19,11 @@ import { Meta, Outlet, Scripts, - ScrollRestoration, useNavigation, + ScrollRestoration } from 'react-router'; +import { SessionProvider } from './contexts/sessionContext'; import { Spinner } from '~/ui/spinner'; -import AppLayout from '~/ui/layout'; import '@aliceo2/web-ui/Frontend/css/src/bootstrap.css'; import './app.css'; @@ -31,8 +31,6 @@ import './styles/components-styles.css' import './styles/ui-styles.css' export function Layout({ children }: { children: React.ReactNode }) { - const { state } = useNavigation(); - return ( @@ -42,18 +40,20 @@ export function Layout({ children }: { children: React.ReactNode }) { - - {children} - + {children} - + ); } export default function App() { - return ; + + return ( + + + ); } export function HydrateFallback() { diff --git a/Tokenization/webapp/app/routes.ts b/Tokenization/webapp/app/routes.ts index fdfcc2f6f..e19aebdd7 100644 --- a/Tokenization/webapp/app/routes.ts +++ b/Tokenization/webapp/app/routes.ts @@ -15,9 +15,13 @@ import { type RouteConfig, index, route, prefix } from '@react-router/dev/routes'; export default [ - index('routes/home.tsx'), - ...prefix('tokens', [ - index('routes/tokens/overview.tsx'), - route(':tokenId', 'routes/tokens/details.tsx'), + route('', 'ui/layout.tsx', [ + index('routes/home.tsx'), + ...prefix('tokens', [ + index('routes/tokens/overview.tsx'), + route(':tokenId', 'routes/tokens/details.tsx'), + ]), + route('*', 'routes/404.tsx'), ]), + ] satisfies RouteConfig; diff --git a/Tokenization/webapp/app/routes/404.tsx b/Tokenization/webapp/app/routes/404.tsx new file mode 100644 index 000000000..81149c99e --- /dev/null +++ b/Tokenization/webapp/app/routes/404.tsx @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2019-2020 CERN and copyright holders of ALICE O2. + * See http://alice-o2.web.cern.ch/copyright for details of the copyright holders. + * All rights not expressly granted are reserved. + * + * This software is distributed under the terms of the GNU General Public + * License v3 (GPL Version 3), copied verbatim in the file "COPYING". + * + * In applying this license CERN does not waive the privileges and immunities + * granted to it by virtue of its status as an Intergovernmental Organization + * or submit itself to any jurisdiction. + */ + +/** + * 404 Not Found page component. + * Displays a user-friendly error message when a requested page doesn't exist. + */ +export default function NotFound() { + return ( +
+

404 - Page Not Found

+

The page you are looking for does not exist.

+
+ ); +} diff --git a/Tokenization/webapp/app/ui/layout.tsx b/Tokenization/webapp/app/ui/layout.tsx index 58eca9faf..774ed0c14 100644 --- a/Tokenization/webapp/app/ui/layout.tsx +++ b/Tokenization/webapp/app/ui/layout.tsx @@ -12,17 +12,7 @@ * or submit itself to any jurisdiction. */ -/** - * Layout - * - * Main layout component for the application. - * Provides the header, sidebar, and main content area. - * Also sets up the HeaderContext to allow child components to update the header content. - * @param state The current loading state of the application (e.g., 'loading' or ready). - * @param children The main content to be rendered inside the layout. - * @return The application layout with header, sidebar, and content area. - */ - +import { Outlet, useNavigation } from 'react-router'; import { useState } from 'react'; import { AppHeader } from './header/header'; @@ -30,18 +20,12 @@ import { HeaderContext } from './header/headerContext'; import { AppSidebar } from './sidebar'; import { Spinner } from './spinner'; -interface LayoutArgs { - state: string; - children: React.ReactNode; -} - /** - * Component provides layout for the applicatio - * - * @param state - refers to state of the whole website if data are loaded its - loading - * @param children - elements to render inside layout component + * Component provides main layout for the application + * Uses useNavigation state to check if page is loaded */ -export default function Layout({ state, children }: LayoutArgs) { +export default function Layout() { + const { state } = useNavigation(); const [headerContent, setHeaderContent] = useState('Tokenization Admin Interface'); @@ -51,7 +35,7 @@ export default function Layout({ state, children }: LayoutArgs) {
- {state === 'loading' ? : children} + {state === 'loading' ? : }
diff --git a/Tokenization/webapp/app/ui/sidebar.tsx b/Tokenization/webapp/app/ui/sidebar.tsx index 08510ed92..0e701317d 100644 --- a/Tokenization/webapp/app/ui/sidebar.tsx +++ b/Tokenization/webapp/app/ui/sidebar.tsx @@ -16,18 +16,21 @@ import type { NavLinkProps } from 'react-router'; import { NavLink } from 'react-router'; import Button from '@mui/material/Button'; +type StyledNavLinkProps = { + children: React.ReactNode; + to: NavLinkProps['to']; +}; + /** * StyledNavLink * * A wrapper component that renders a Material-UI Button styled as a navigation link. * It uses NavLink from react-router to determine if the link is active and applies * the 'contained' variant for the active route and 'outlined' for inactive routes. - * @param children.children * @param children The content to display inside the button. * @param to The target route path. - * @param children.to */ -const StyledNavLink = ({ children, to }: NavLinkProps) => +const StyledNavLink = ({ children, to }: StyledNavLinkProps) => {({ isActive }) => (