diff --git a/.github/workflows/tokenization.yml b/.github/workflows/tokenization.yml new file mode 100644 index 000000000..3c6b5bc61 --- /dev/null +++ b/.github/workflows/tokenization.yml @@ -0,0 +1,48 @@ +name: Tokenization +on: + pull_request: + paths: + - 'Tokenization/**/*' + - '.github/workflows/tokenization.yml' + push: + branches: + - 'main' + - 'dev' + +jobs: + lint-check-backend: + name: Check eslint rules for backend on ubuntu-latest + runs-on: ubuntu-latest + timeout-minutes: 6 + steps: + - uses: actions/checkout@v4 + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: '22.x' + - run: (cd Tokenization/backend; npm i) + lint-check-webapp: + name: Check eslint rules for webapp on ubuntu-latest + runs-on: ubuntu-latest + timeout-minutes: 6 + steps: + - uses: actions/checkout@v4 + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: '22.x' + - run: (cd Tokenization/webapp; npm i ) + - run: (cd Tokenization/webapp; npm run typecheck) + - run: (cd Tokenization/webapp; npm run lint) + + ui-test: + name: UI-tests for webapp application + runs-on: ubuntu-latest + timeout-minutes: 6 + steps: + - uses: actions/checkout@v4 + - name: Setup node + uses: actions/setup-node@v4 + with: + node-version: '22.x' + - run: (cd Tokenization/webapp; npm run docker:test) \ No newline at end of file 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.test.yml b/Tokenization/docker-compose.test.yml new file mode 100644 index 000000000..77d4665d5 --- /dev/null +++ b/Tokenization/docker-compose.test.yml @@ -0,0 +1,48 @@ +services: + install-backend: + image: node:22-alpine + working_dir: /var/workspace + volumes: + - ./backend:/var/workspace + command: ["npm", "install", "--no-save", "--silent"] + + backend: + image: node:22-alpine + working_dir: /var/workspace + volumes: + - ./backend:/var/workspace + command: ["npm", "run", "dev"] + healthcheck: + interval: 1s + test: ["CMD-SHELL", + "node", "-c", + "node --input-type=module -e \"process.exit((await fetch('http://backend:8080/api/healthcheck')).ok === true ? 0 : 1)\""] + depends_on: + install-backend: + condition: service_completed_successfully + + + prod-container: + build: + context: . + dockerfile: Dockerfile + target: production + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost/"] + interval: 5s + timeout: 3s + retries: 5 + depends_on: + backend: + condition: service_healthy + + ui-tests: + build: + context: . + dockerfile: Dockerfile + target: test + volumes: + - ./webapp/tests:/var/workspace/tests + depends_on: + prod-container: + condition: service_healthy 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..e675c6211 --- /dev/null +++ b/Tokenization/webapp/app/contexts/sessionContext.tsx @@ -0,0 +1,97 @@ +/** + * @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'; + +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..e5aab98f2 --- /dev/null +++ b/Tokenization/webapp/app/hooks/session.tsx @@ -0,0 +1,69 @@ +/** + * @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 containing: + * - personid: User's person ID + * - name: User's display name + * - token: Authentication token + * - username: User's username + * - access: Array of user's access roles + * + * @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..3dceb7777 100644 --- a/Tokenization/webapp/app/routes.ts +++ b/Tokenization/webapp/app/routes.ts @@ -12,12 +12,17 @@ * or submit itself to any jurisdiction. */ -import { type RouteConfig, index, route, prefix } from '@react-router/dev/routes'; +import { type RouteConfig, index, route } 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'), + route('tokens', 'routes/tokens/layout.tsx', [ + index('routes/tokens/overview.tsx'), + route('new', 'routes/tokens/create.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/routes/home.tsx b/Tokenization/webapp/app/routes/home.tsx index bfc99ffb1..0b1b459eb 100644 --- a/Tokenization/webapp/app/routes/home.tsx +++ b/Tokenization/webapp/app/routes/home.tsx @@ -11,7 +11,6 @@ * granted to it by virtue of its status as an Intergovernmental Organization * or submit itself to any jurisdiction. */ - import { Link } from 'react-router'; import { useSetHeader } from '~/ui/header/headerContext'; @@ -22,8 +21,7 @@ import { useSetHeader } from '~/ui/header/headerContext'; */ export default function Home() { - const { setHeaderContent } = useSetHeader(); - setHeaderContent('Tokenization Admin Interface'); + useSetHeader('Tokenization Admin Interface'); return <>

Welcome to (dummy) Tokenization GUI!

diff --git a/Tokenization/webapp/app/routes/tokens/create.tsx b/Tokenization/webapp/app/routes/tokens/create.tsx new file mode 100644 index 000000000..2d88c059d --- /dev/null +++ b/Tokenization/webapp/app/routes/tokens/create.tsx @@ -0,0 +1,265 @@ +/** + * @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 { useNavigate } from 'react-router'; +import Select, { type MultiValue, type SingleValue } from 'react-select'; +import { useState, useCallback } from 'react'; +import { + TextField, + InputAdornment, +} from '@mui/material'; + +import type { OptionType, HttpMethod } from '~/utils/types'; +import { DangerAlert } from '~/ui/alert'; +import { CreationTokenDialog } from '~/ui/dialog'; +import { useAuth } from '~/hooks/session'; + +// eslint-disable-next-line jsdoc/require-jsdoc +export function clientLoader(): OptionType[] { + return [ + { value: 'service1', label: 'Service 1' }, + { value: 'service2', label: 'Service 2' }, + { value: 'service3', label: 'Service 3' }, + { value: 'service4', label: 'Service 4' }, + ]; +} + +// HTTP Method options +const httpMethodOptions = [ + { value: 'GET', label: 'GET' }, + { value: 'POST', label: 'POST' }, + { value: 'PUT', label: 'PUT' }, + { value: 'DELETE', label: 'DELETE' }, +]; + +/** + * FormCreationInput + * + * Container component for form inputs in the token creation form. + * + * @param props.children element input/select/itp. + * @param props.labelText Optional label text to display above the input field. + */ +function FormCreationInput({ children, labelText }: { children: React.ReactNode; labelText?: string }) { + return
+ + {children} +
; +} + +interface FormCreationSelectInputProps { + id: string; + labelText?: string; + options: OptionType[]; + value: MultiValue | SingleValue; + onChange: (value: MultiValue | SingleValue) => void; + placeholder: string; + isMulti?: boolean; +} + +/** + * FormCreationSelectInput + * + * Container component for select inputs in the token creation form. + * + * @param props.id The id of the select input. + * @param props.labelText Optional label text to display above the select field. + * @param props.options The options to display in the select dropdown. + * @param props.value The currently selected value(s). + * @param props.onChange Callback function to handle changes in selection. + * @param props.placeholder Placeholder text for the select input. + * @param props.isMulti Boolean indicating if multiple selections are allowed. + */ +function FormCreationSelectInput(props: FormCreationSelectInputProps) { + const { labelText, ...rest } = props; + + return +