diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..a6f610e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,2 @@ +node_modules +*/dist/ diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 40f84e6..0000000 --- a/.eslintrc.json +++ /dev/null @@ -1,40 +0,0 @@ -{ - "parser": "@typescript-eslint/parser", - "parserOptions": { - "project": "./tsconfig.json", - "sourceType": "module" - }, - "extends": [ - "plugin:@typescript-eslint/recommended", - "plugin:react-hooks/recommended", - "prettier", - "plugin:storybook/recommended" - ], - "plugins": [ - "@typescript-eslint", - "react-hooks" - ], - "ignorePatterns": [ - "**/*.mdx", - "**/*.stories.(ts,tsx)", - "stories/**", - "src/**/*.stories.(js,ts,jsx,tsx)" - ], - "rules": { - "@next/next/no-before-interactive-script-outside-document": "off", - "no-unused-vars": [ - 1, - { - "args": "after-used", - "argsIgnorePattern": "^_" - } - ] - }, - "settings": { - "import/resolver": { - "typescript": { - "project": "./tsconfig.json" - } - } - } -} \ No newline at end of file diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8f38dfa..a9fa9d5 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,36 +1,30 @@ name: CI + on: - pull_request: push: branches: - - main + - "main" + pull_request: + branches: + - "main" + workflow_dispatch: + workflow_call: jobs: - lint: - name: Lint - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v3 - - name: Setup Node.js - uses: actions/setup-node@v3 - with: - node-version: lts/* - - name: Install dependencies - run: npm install - - name: Run eslint - run: npm run lint - build: - name: Build + ci: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v3 - - name: Setup Node.js - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v4 with: - node-version: lts/* + node-version: 22 + cache: npm - name: Install dependencies - run: npm install - - name: Attempt a build + run: npm ci --ignore-scripts + - name: Build run: npm run build + - name: Lint sources + run: npm run lint + - name: Check + run: npm run format diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml deleted file mode 100644 index 044f62b..0000000 --- a/.github/workflows/deploy.yaml +++ /dev/null @@ -1,46 +0,0 @@ -name: Storybook Deployment - -on: - push: - branches: - - main - workflow_dispatch: - workflow_call: - -# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages -permissions: - contents: read - pages: write - id-token: write - -# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. -# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete. -concurrency: - group: "pages" - cancel-in-progress: false - -jobs: - gh-pages: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 22 - cache: npm - - name: Install dependencies - run: npm ci - - name: Build storybook - run: npm run build-storybook - - name: Setup Pages - uses: actions/configure-pages@v5 - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - path: "./storybook-static" - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 22a86d1..cef255c 100644 --- a/.gitignore +++ b/.gitignore @@ -141,3 +141,5 @@ storybook-static/ .idea/ .vscode/ + +client/src/app/client/ \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index ec6d3cd..58d825e 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1,14 @@ -package.json +# Library, IDE and build locations +**/node_modules/ +**/coverage/ +**/dist/ +.vscode/ +.idea/ +.eslintcache/ + +# +# NOTE: Could ignore anything that eslint will look at since eslint also applies +# prettier. +# +**/dist +client/src/app/client/ \ No newline at end of file diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index 2a408ad..0000000 --- a/.prettierrc +++ /dev/null @@ -1,7 +0,0 @@ -{ - "importOrder": ["^react", "^@?\\w", "^@app", "^[./]"], - "importOrderSeparation": true, - "importOrderSortSpecifiers": true, - "singleQuote": true, - "printWidth": 120 -} diff --git a/.prettierrc.mjs b/.prettierrc.mjs new file mode 100644 index 0000000..165738d --- /dev/null +++ b/.prettierrc.mjs @@ -0,0 +1,15 @@ +/** @type {import("prettier").Config} */ +const config = { + trailingComma: "es5", // es5 was the default in prettier v2 + semi: true, + singleQuote: false, + printWidth: 120, + + // Values used from .editorconfig: + // - printWidth == max_line_length + // - tabWidth == indent_size + // - useTabs == indent_style + // - endOfLine == end_of_line +}; + +export default config; \ No newline at end of file diff --git a/.storybook/main.ts b/.storybook/main.ts deleted file mode 100644 index 28801b5..0000000 --- a/.storybook/main.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** @type { import('@storybook/react-webpack5').StorybookConfig } */ -import path from 'path'; -import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin'; - -const config = { - stories: ['../stories/**/*.mdx', '../stories/**/*.stories.@(js|jsx|mjs|ts|tsx)'], - addons: ['@storybook/addon-webpack5-compiler-swc', '@storybook/addon-docs'], - framework: { - name: '@storybook/react-webpack5', - options: {}, - }, - staticDirs: ['../src/app/bgimages'], - webpackFinal: async (config) => { - if (config.resolve) { - // Ensure resolve.modules includes the project root for absolute paths - config.resolve.modules = [...(config.resolve.modules || []), path.resolve(__dirname, '..'), 'node_modules']; - - config.resolve.plugins = [ - ...(config.resolve.plugins || []), - new TsconfigPathsPlugin({ - configFile: path.resolve(__dirname, '../tsconfig.json'), - extensions: config.resolve.extensions, - }), - ]; - } - if (config.module) { - // remove svg from existing rule - config.module.rules = config.module.rules?.map((rule: any) => { - if (String(rule.test) === String(/\.(svg|ico|jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|cur|ani)(\?.*)?$/)) { - return { - ...rule, - test: /\.(ico|jpg|jpeg|png|gif|eot|otf|webp|ttf|woff|woff2|cur|ani)(\?.*)?$/, - }; - } - - return rule; - }); - } - - return config; - }, -}; -export default config; diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx deleted file mode 100644 index e75699d..0000000 --- a/.storybook/preview.tsx +++ /dev/null @@ -1,42 +0,0 @@ -/** @type { import('@storybook/react-webpack5').Preview } */ -import '@patternfly/patternfly/patternfly.css'; -import '@patternfly/patternfly/patternfly-addons.css'; -import { MemoryRouter } from 'react-router'; -import { AppLayout } from '../src/app/AppLayout/AppLayout'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { initialize, mswDecorator } from 'msw-storybook-addon'; - -initialize(); - -const preview = { - decorators: [ - mswDecorator, - (Story) => { - const queryClient = new QueryClient(); - return ( - - - - - - - - ); - }, - ], - parameters: { - controls: { - matchers: { - color: /(background|color)$/i, - date: /Date$/i, - }, - }, - options: { - storySort: { - order: ['Trust', 'Dashboard'], - }, - }, - }, -}; - -export default preview; diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a7d09f0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,39 @@ +# Builder image +FROM registry.access.redhat.com/ubi9/nodejs-22:latest AS builder + +USER 1001 +COPY --chown=1001 . . +RUN npm install -g npm@9 +RUN npm clean-install --ignore-scripts && npm run build && npm run dist + +# Runner image +FROM registry.access.redhat.com/ubi9/nodejs-22-minimal:latest + +# Add ps package to allow liveness probe for k8s cluster +# Add tar package to allow copying files with kubectl scp +USER 0 +RUN microdnf -y install tar procps-ng && microdnf clean all + +USER 1001 + +LABEL name="securesign/rhtas-console-ui" \ + description="RHTAS Console - User Interface" \ + help="For more information visit https://github.com/securesign/" \ + license="Apache License 2.0" \ + maintainer="123@gmail.com" \ + summary="RHTAS Console - User Interface" \ + url="https://github.com/securesign/rhtas-console-ui" \ + usage="podman run -p 80 -v securesign/rhtas-console-ui:latest" \ + io.k8s.display-name="rhtas-console-ui" \ + io.k8s.description="RHTAS Console - User Interface" \ + io.openshift.expose-services="80:http" \ + io.openshift.tags="operator,securesign,rhtas,ui,nodejs22" \ + io.openshift.min-cpu="100m" \ + io.openshift.min-memory="350Mi" + +COPY --from=builder /opt/app-root/src/dist /opt/app-root/dist/ + +ENV DEBUG=1 + +WORKDIR /opt/app-root/dist +ENTRYPOINT ["./entrypoint.sh"] diff --git a/README.md b/README.md index f5e03ba..145b6f9 100644 --- a/README.md +++ b/README.md @@ -3,12 +3,14 @@ The RHTAS Console is a web-based frontend for interacting with the [Red Hat Trusted Artifact Signer (TAS)](https://developers.redhat.com/products/trusted-artifact-signer/overview) ecosystem. It provides user-friendly workflows for retrieving, verifying, and monitoring signed software artifacts, integrating with [Sigstore](https://www.sigstore.dev/) services like Rekor, Fulcio, and [TUF](https://theupdateframework.io/) (The Update Framework). Features in progress: + - View trust metadata and certificate details - Verify signatures and attestations - Retrieve container artifacts from registries - Integrate with transparency logs (Rekor) Links: + - [RHTAS Console](https://github.com/securesign/rhtas-console) - Based on [PatternFly React Seed](https://github.com/patternfly/patternfly-react-seed) - Uses [PatternFly v6](https://www.patternfly.org/), React, and [Storybook](https://storybook.js.org/) @@ -18,101 +20,43 @@ Links: ``` git clone https://github.com/securesign/rhtas-console-ui cd rhtas-console-ui -npm install && npm run start +npm ci && npm run start:dev ``` ## Configurations -- [TypeScript Config](./tsconfig.json) -- [Webpack Config](./webpack.config.ts) +- [TypeScript Config](./client/tsconfig.app.json) +- [Vite Config](./client/vite.config.ts) - [Editor Config](./.editorconfig) -- [Storybook Config](./.storybook/main.ts) +- [Openapi](./client/openapi/console.yaml) ## Development ```bash # Install development/build dependencies -npm install +npm ci # Start the development server -npm run start +npm run start:dev # Run a production build (outputs to "dist" dir) npm run build -# Run the test suite -npm run test - -# Run the test suite with coverage -npm run test:coverage - # Run the linter npm run lint # Run the code formatter npm run format -# Launch a tool to inspect the bundle size -npm run bundle-profile:analyze - # Start the express server (run a production build first) npm run start ``` -## Raster image support - -To use an image asset that's shipped with PatternFly core, you'll prefix the paths with "@assets". `@assets` is an alias for the PatternFly assets directory in `node_modules`. - -For example: - -```js -import imgSrc from '@assets/images/g_sizing.png'; -Some image; -``` - -You can use a similar technique to import assets from your local app, just prefix the paths with "`@app`". `@app` is an alias for the main `src/app` directory. - -```js -import loader from '@app/assets/images/loader.gif'; -Content loading; -``` - -## Vector image support - -Inlining SVG in the app's markup is also possible. - -```js -import logo from '@app/assets/images/logo.svg'; -; -``` - -You can also use SVG when applying background images with CSS. To do this, your SVG's must live under a `bgimages` directory (this directory name is configurable in [webpack.config.ts](./webpack.config.ts#L5)). This is necessary because you may need to use SVG's in several other context (inline images, fonts, icons, etc.) and so we need to be able to differentiate between these usages so the appropriate loader is invoked. - -```css -body { - background: url(./assets/bgimages/img_avatar.svg); -} -``` - -## Adding custom CSS - -When importing CSS from a third-party package for the first time, you may encounter the error `Module parse failed: Unexpected token... You may need an appropriate loader to handle this file typ...`. You need to register the path to the stylesheet directory in [stylePaths.ts](./stylePaths.ts). We specify these explicitly for performance reasons to avoid webpack needing to crawl through the entire node_modules directory when parsing CSS modules. - ## Code quality tools -- For accessibility compliance, we use [react-axe](https://github.com/dequelabs/react-axe) -- To keep our bundle size in check, we use [webpack-bundle-analyzer](https://github.com/webpack-contrib/webpack-bundle-analyzer) - To keep our code formatting in check, we use [prettier](https://github.com/prettier/prettier) -- To keep our code logic and test coverage in check, we use [jest](https://github.com/facebook/jest) - To ensure code styles remain consistent, we use [eslint](https://eslint.org/) ## Multi environment configuration -This project uses [dotenv-webpack](https://www.npmjs.com/package/dotenv-webpack) for exposing environment variables to your code. Either export them at the system level like `export MY_ENV_VAR=http://dev.myendpoint.com && npm run start:dev` or simply drop a `.env` file in the root that contains your key-value pairs like below: - -```sh -ENV_1=http://1.myendpoint.com -ENV_2=http://2.myendpoint.com -``` - -With that in place, you can use the values in your code like `console.log(process.env.ENV_1);` +Environment Variables can be injected in the UI though [environment.ts](./common/src/environment.ts) diff --git a/__mocks__/fileMock.js b/__mocks__/fileMock.js deleted file mode 100644 index 86059f3..0000000 --- a/__mocks__/fileMock.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = 'test-file-stub'; diff --git a/__mocks__/styleMock.js b/__mocks__/styleMock.js deleted file mode 100644 index f053ebf..0000000 --- a/__mocks__/styleMock.js +++ /dev/null @@ -1 +0,0 @@ -module.exports = {}; diff --git a/branding/favicon.ico b/branding/favicon.ico new file mode 100644 index 0000000..130b38c Binary files /dev/null and b/branding/favicon.ico differ diff --git a/src/app/bgimages/Logo-Red_Hat-Trusted_Artifact_Signer-A-Standard-RGB.svg b/branding/images/masthead-logo.svg similarity index 100% rename from src/app/bgimages/Logo-Red_Hat-Trusted_Artifact_Signer-A-Standard-RGB.svg rename to branding/images/masthead-logo.svg diff --git a/branding/manifest.json b/branding/manifest.json new file mode 100644 index 0000000..00b000f --- /dev/null +++ b/branding/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "rhtas-console-ui", + "name": "RHTAS Console UI", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/branding/strings.json b/branding/strings.json new file mode 100644 index 0000000..181744c --- /dev/null +++ b/branding/strings.json @@ -0,0 +1,22 @@ +{ + "application": { + "title": "RHTAS Console", + "name": "RHTAS Console UI", + "description": "RHTAS Console UI" + }, + "about": { + "displayName": "RHTAS Console", + "imageSrc": "<%= brandingRoot %>/images/masthead-logo.svg", + "documentationUrl": "https://pages.rhtas.com/" + }, + "masthead": { + "leftBrand": { + "src": "<%= brandingRoot %>/images/masthead-logo.svg", + "alt": "brand", + "height": "40px" + }, + "leftTitle": null, + "rightBrand": null, + "supportUrl": "https://github.com/securesign/rhtas-console/issues" + } +} diff --git a/client/config/openapi-ts.config.ts b/client/config/openapi-ts.config.ts new file mode 100644 index 0000000..e43ff9b --- /dev/null +++ b/client/config/openapi-ts.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "@hey-api/openapi-ts"; + +export default defineConfig({ + plugins: ["@hey-api/client-axios"], + input: "./openapi/console.yaml", + output: { + path: "src/app/client", + format: "prettier", + lint: "eslint", + }, +}); diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..a557ad1 --- /dev/null +++ b/client/index.html @@ -0,0 +1,23 @@ + + + + <%= branding.application.title %> + + + + + + + + + + + + + +
+ + + \ No newline at end of file diff --git a/client/openapi/console.yaml b/client/openapi/console.yaml new file mode 100644 index 0000000..0a2f858 --- /dev/null +++ b/client/openapi/console.yaml @@ -0,0 +1,478 @@ +openapi: 3.0.0 +info: + title: RHTAS Console API + version: 1.0.0 + description: API for interacting with artifact trust, verification, and metadata services in the RHTAS Console. +servers: + - url: https://api.rhtas.example.com + description: Production server +tags: + - name: Artifact + description: Operations for signing and verifying artifacts + - name: Rekor + description: Operations for interacting with Rekor transparency log + - name: Trust + description: Operations for trust configuration and TUF targets +paths: + /healthz: + get: + operationId: GetHealthz + responses: + '200': + description: Server is healthy + content: + application/json: + schema: + type: object + properties: + status: + type: string + enum: [ok] + /api/v1/artifacts/sign: + post: + summary: Sign an artifact using Cosign + tags: + - Artifact + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SignArtifactRequest' + responses: + '200': + description: Artifact signed successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SignArtifactResponse' + '400': + description: Invalid input + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /api/v1/artifacts/verify: + post: + summary: Verify an artifact using Cosign + tags: + - Artifact + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/VerifyArtifactRequest' + responses: + '200': + description: Verification succeeded + content: + application/json: + schema: + $ref: '#/components/schemas/VerifyArtifactResponse' + '400': + description: Invalid input + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Verification failed due to internal error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /api/v1/rekor/entries/{uuid}: + get: + summary: Retrieve Rekor log entry by UUID + tags: + - Rekor + parameters: + - name: uuid + in: path + required: true + schema: + type: string + responses: + '200': + description: Rekor entry data + content: + application/json: + schema: + $ref: '#/components/schemas/RekorEntry' + '404': + description: Entry not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /api/v1/rekor/public-key: + get: + summary: Get Rekor public key + tags: + - Rekor + responses: + '200': + description: Rekor public key in PEM format + content: + application/json: + schema: + $ref: '#/components/schemas/RekorPublicKey' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /api/v1/artifacts/{artifact}/policies: + get: + summary: Get policies and attestations for an artifact + tags: + - Artifact + parameters: + - name: artifact + in: path + required: true + schema: + type: string + example: quay.io/example/app:latest + responses: + '200': + description: Policy and attestation data + content: + application/json: + schema: + $ref: '#/components/schemas/ArtifactPolicies' + '404': + description: Artifact not found + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /api/v1/trust/config: + get: + summary: Get TUF targets and Fulcio certificate authorities + tags: + - Trust + responses: + '200': + description: Trust root data + content: + application/json: + schema: + $ref: '#/components/schemas/TrustConfig' + '500': + description: Internal server error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' +components: + schemas: + SignArtifactRequest: + type: object + properties: + artifact: + type: string + description: > + URI or identifier of the artifact to sign. This could be a container image (e.g., quay.io/example/app:latest), + a file path, a blob digest, or another unique artifact reference. + example: quay.io/example/app:latest + artifactType: + type: string + description: > + Type of the artifact to sign. Common types include `container-image`, `file`, `blob`, `sbom`, etc. + enum: + - container-image + - file + - blob + - sbom + example: container-image + identityToken: + type: string + description: OIDC token for Fulcio (if using keyless signing) + nullable: true + privateKeyRef: + type: string + description: Reference to a private key (KMS URI or file) + nullable: true + annotations: + type: object + additionalProperties: + type: string + description: Optional key-value annotations to include in the signature + example: + env: prod + required: + - artifact + - artifactType + SignArtifactResponse: + type: object + properties: + success: + type: boolean + description: Whether the signing was successful + signature: + type: string + description: The generated signature + certificate: + type: string + description: Fulcio-signed certificate (PEM), if keyless + nullable: true + logEntry: + type: object + description: Rekor transparency log entry + properties: + uuid: + type: string + integratedTime: + type: integer + logIndex: + type: integer + required: + - success + - signature + VerifyArtifactRequest: + type: object + properties: + artifact: + type: string + description: > + URI or identifier of the artifact to verify. This could be a container image (e.g., quay.io/example/app:latest), + a file path, a blob digest, or another unique artifact reference. + example: quay.io/example/app:latest + publicKey: + type: string + description: Optional public key path, KMS URI, or URL (for key-based verification) + nullable: true + example: cosign.pub + cert: + type: string + description: Path or content of certificate for Fulcio-based verification + nullable: true + certChain: + type: string + description: Certificate chain in PEM format (if using keyless verification) + nullable: true + certificateIdentity: + type: string + description: Expected identity from Fulcio certificate (OIDC subject) + nullable: true + certificateOidcIssuer: + type: string + description: OIDC issuer for Fulcio verification + nullable: true + annotations: + type: object + additionalProperties: + type: string + description: Optional key-value annotations to verify in the signature + offline: + type: boolean + default: false + description: Whether to run Cosign in offline mode + output: + type: string + enum: [json, text] + default: json + description: Output format + required: + - artifact + VerifyArtifactResponse: + type: object + properties: + verified: + type: boolean + description: Whether verification was successful + message: + type: string + description: Verification result message + details: + type: object + description: Detailed output from Cosign + required: + - verified + - message + InclusionProof: + type: object + description: Merkle tree inclusion proof for a Rekor entry + properties: + checkpoint: + type: string + description: Checkpoint string for the log, including tree size and root hash + hashes: + type: array + description: Array of Merkle tree hashes for the inclusion proof + items: + type: string + description: A single hash in the inclusion proof + logIndex: + type: integer + format: int64 + description: Log index of the entry in the Merkle tree + rootHash: + type: string + description: Root hash of the Merkle tree at the time of inclusion + treeSize: + type: integer + format: int64 + description: Size of the Merkle tree at the time of inclusion + required: + - checkpoint + - hashes + - logIndex + - rootHash + - treeSize + Verification: + type: object + description: Verification details for a Rekor entry, including inclusion proof and signed timestamp + properties: + inclusionProof: + $ref: '#/components/schemas/InclusionProof' + description: Merkle tree inclusion proof for the entry + signedEntryTimestamp: + type: string + description: Base64-encoded signed timestamp for the entry + required: + - inclusionProof + - signedEntryTimestamp + RekorEntry: + type: object + properties: + uuid: + type: string + description: Unique identifier of the Rekor entry + body: + type: string + description: Base64-encoded entry body + integratedTime: + type: integer + description: Timestamp of when the entry was integrated + logID: + type: string + description: Unique identifier of the transparency log + logIndex: + type: integer + description: Index in the transparency log + verification: + $ref: '#/components/schemas/Verification' + description: Verification details for the entry + required: + - uuid + - body + - integratedTime + - logID + - logIndex + - verification + RekorPublicKey: + type: object + properties: + publicKey: + type: string + description: Rekor public key in PEM format + example: "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkq...\n-----END PUBLIC KEY-----" + required: + - publicKey + ArtifactPolicies: + type: object + properties: + artifact: + type: string + description: The artifact URI + policies: + type: array + items: + type: object + properties: + name: + type: string + description: Policy name + status: + type: string + description: Policy status + lastChecked: + type: string + format: date-time + description: Last time the policy was checked + attestations: + type: array + items: + type: object + properties: + type: + type: string + description: Attestation type + issuer: + type: string + description: Issuer of the attestation + subject: + type: string + description: Subject of the attestation + issuedAt: + type: string + format: date-time + description: Issuance timestamp + required: + - artifact + - policies + - attestations + TrustConfig: + type: object + properties: + tufRoot: + type: object + properties: + version: + type: integer + description: TUF root version + expires: + type: string + format: date-time + description: Expiration timestamp + required: + - version + - expires + fulcioCertAuthorities: + type: array + items: + type: object + properties: + subject: + type: string + description: Certificate authority subject + pem: + type: string + description: Certificate in PEM format + example: "-----BEGIN CERTIFICATE-----\nMIIBIjANBgkq...\n-----END CERTIFICATE-----" + required: + - subject + - pem + required: + - tufRoot + - fulcioCertAuthorities + Error: + type: object + properties: + error: + type: string + description: Error message + required: + - error diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..e29cb8c --- /dev/null +++ b/client/package.json @@ -0,0 +1,73 @@ +{ + "name": "@console-ui/client", + "version": "0.1.0", + "license": "Apache-2.0", + "private": true, + "type": "module", + "scripts": { + "clean": "rimraf ./dist", + "clean:all": "rimraf ./dist ./node_modules", + "build": "npm run generate && tsc -b && vite build", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier --check './src/**/*.{ts,tsx,js,json}'", + "format:fix": "prettier --write './src/**/*.{ts,tsx,js,json}'", + "start:dev": "npm run generate && vite --port 3000", + "start": "vite preview", + "generate": "openapi-ts -f ./config/openapi-ts.config.ts", + "preview": "vite preview" + }, + "dependencies": { + "@hookform/resolvers": "^2.9.11", + "@patternfly/patternfly": "^6.2.0", + "@patternfly/react-charts": "^8.2.0", + "@patternfly/react-component-groups": "^6.2.1", + "@patternfly/react-core": "^6.2.0", + "@patternfly/react-log-viewer": "^6.2.0", + "@patternfly/react-table": "^6.2.0", + "@patternfly/react-tokens": "^6.2.0", + "@tanstack/react-query": "^5.61.0", + "@tanstack/react-query-devtools": "^5.61.0", + "axios": "^1.7.2", + "dayjs": "^1.11.7", + "file-saver": "^2.0.5", + "oidc-client-ts": "^2.4.0", + "packageurl-js": "^2.0.1", + "pretty-bytes": "^6.1.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-error-boundary": "^4.0.13", + "react-hook-form": "^7.43.1", + "react-markdown": "^8.0.7", + "react-oidc-context": "^2.3.1", + "react-router-dom": "^6.21.1", + "usehooks-ts": "^2.14.0", + "victory": "^37.3.4", + "web-vitals": "^0.2.4", + "yup": "^0.32.11" + }, + "devDependencies": { + "@hey-api/openapi-ts": "^0.77.0", + "@testing-library/react": "^16.0.0", + "@types/file-saver": "^2.0.2", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "@vitejs/plugin-react": "^4.5.2", + "typescript": "^5.8.3", + "vite": "^6.3.5", + "vite-plugin-ejs": "^1.7.0", + "vite-plugin-static-copy": "^2.2.0" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/client/src/app/App.css b/client/src/app/App.css new file mode 100644 index 0000000..5070766 --- /dev/null +++ b/client/src/app/App.css @@ -0,0 +1,10 @@ +.pf-c-select__toggle:before { + border-top: var(--pf-c-select__toggle--before--BorderTopWidth) solid + var(--pf-c-select__toggle--before--BorderTopColor) !important; + border-right: var(--pf-c-select__toggle--before--BorderRightWidth) solid + var(--pf-c-select__toggle--before--BorderRightColor) !important; + border-bottom: var(--pf-c-select__toggle--before--BorderBottomWidth) solid + var(--pf-c-select__toggle--before--BorderBottomColor) !important; + border-left: var(--pf-c-select__toggle--before--BorderLeftWidth) solid + var(--pf-c-select__toggle--before--BorderLeftColor) !important; +} diff --git a/client/src/app/App.tsx b/client/src/app/App.tsx new file mode 100644 index 0000000..9f6ebef --- /dev/null +++ b/client/src/app/App.tsx @@ -0,0 +1,21 @@ +import "./App.css"; +import type React from "react"; +import { BrowserRouter as Router } from "react-router-dom"; + +import { AppRoutes } from "./Routes"; +import { DefaultLayout } from "./layout"; + +import "@patternfly/patternfly/patternfly.css"; +import "@patternfly/patternfly/patternfly-addons.css"; + +const App: React.FC = () => { + return ( + + + + + + ); +}; + +export default App; diff --git a/client/src/app/Constants.ts b/client/src/app/Constants.ts new file mode 100644 index 0000000..801acd2 --- /dev/null +++ b/client/src/app/Constants.ts @@ -0,0 +1,3 @@ +import ENV from "./env"; + +export const isAuthRequired = ENV.AUTH_REQUIRED !== "false"; diff --git a/client/src/app/Routes.tsx b/client/src/app/Routes.tsx new file mode 100644 index 0000000..b710b62 --- /dev/null +++ b/client/src/app/Routes.tsx @@ -0,0 +1,32 @@ +import { Suspense, lazy } from "react"; +import { ErrorBoundary } from "react-error-boundary"; +import { useRoutes } from "react-router-dom"; + +import { Bullseye, Spinner } from "@patternfly/react-core"; +import { ErrorFallback } from "./components/ErrorFallback"; + +const Overview = lazy(() => import("./pages/Overview")); +const Certificates = lazy(() => import("./pages/Certificates")); +const TrustRoots = lazy(() => import("./pages/TrustRoots")); + +export const AppRoutes = () => { + const allRoutes = useRoutes([ + { path: "/", element: }, + { path: "/certificates", element: }, + { path: "/trust-roots", element: }, + ]); + + return ( + + + + } + > + + {allRoutes} + + + ); +}; diff --git a/client/src/app/axios-config/apiInit.ts b/client/src/app/axios-config/apiInit.ts new file mode 100644 index 0000000..ea04414 --- /dev/null +++ b/client/src/app/axios-config/apiInit.ts @@ -0,0 +1,77 @@ +/* eslint-disable @typescript-eslint/no-unsafe-member-access, @typescript-eslint/prefer-promise-reject-errors, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-argument */ + +import axios from "axios"; +import { User, UserManager } from "oidc-client-ts"; + +import { OIDC_CLIENT_ID, OIDC_SERVER_URL, oidcClientSettings } from "@app/oidc"; + +import { createClient } from "@app/client/client"; + +export const client = createClient({ + // set default base url for requests + baseURL: "/", + axios: axios, + throwOnError: true, +}); + +function getUser() { + const oidcStorage = sessionStorage.getItem(`oidc.user:${OIDC_SERVER_URL}:${OIDC_CLIENT_ID}`); + if (!oidcStorage) { + return null; + } + + return User.fromStorageString(oidcStorage); +} + +export const initInterceptors = () => { + axios.interceptors.request.use( + (config) => { + const user = getUser(); + const token = user?.access_token; + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + } + ); + + axios.interceptors.response.use( + (response) => { + return response; + }, + async (error) => { + if (error.response && error.response.status === 401) { + const userManager = new UserManager(oidcClientSettings); + try { + const refreshedUser = await userManager.signinSilent(); + const access_token = refreshedUser?.access_token; + + const retryCounter = error.config.retryCounter ?? 1; + + const retryConfig = { + ...error.config, + headers: { + ...error.config.headers, + Authorization: `Bearer ${access_token}`, + }, + }; + + // Retry limited times + if (retryCounter < 2) { + return axios({ + ...retryConfig, + retryCounter: retryCounter + 1, + }); + } + } catch (_refreshError) { + await userManager.signoutRedirect(); + } + } + + return Promise.reject(error); + } + ); +}; diff --git a/client/src/app/axios-config/index.ts b/client/src/app/axios-config/index.ts new file mode 100644 index 0000000..98b6062 --- /dev/null +++ b/client/src/app/axios-config/index.ts @@ -0,0 +1 @@ +export { initInterceptors } from "./apiInit"; diff --git a/client/src/app/components/AppPlaceholder.tsx b/client/src/app/components/AppPlaceholder.tsx new file mode 100644 index 0000000..4748f36 --- /dev/null +++ b/client/src/app/components/AppPlaceholder.tsx @@ -0,0 +1,18 @@ +import type React from "react"; + +import { Bullseye, Spinner } from "@patternfly/react-core"; + +export const AppPlaceholder: React.FC = () => { + return ( + +
+
+ +
+
+

Loading...

+
+
+
+ ); +}; diff --git a/client/src/app/components/ErrorFallback.tsx b/client/src/app/components/ErrorFallback.tsx new file mode 100644 index 0000000..5175841 --- /dev/null +++ b/client/src/app/components/ErrorFallback.tsx @@ -0,0 +1,39 @@ +import { useNavigate } from "react-router-dom"; + +import { Bullseye, Button, EmptyState, EmptyStateBody, EmptyStateVariant } from "@patternfly/react-core"; +import UserNinjaIcon from "@patternfly/react-icons/dist/esm/icons/user-ninja-icon"; +import spacing from "@patternfly/react-styles/css/utilities/Spacing/spacing"; + +export const ErrorFallback = ({ + resetErrorBoundary, +}: { + error: Error; + resetErrorBoundary: (...args: unknown[]) => void; +}) => { + const navigate = useNavigate(); + + return ( + + + + Try to refresh your page or contact your admin. + + + + + ); +}; diff --git a/client/src/app/components/OidcProvider.tsx b/client/src/app/components/OidcProvider.tsx new file mode 100644 index 0000000..96266e8 --- /dev/null +++ b/client/src/app/components/OidcProvider.tsx @@ -0,0 +1,76 @@ +import React, { Suspense } from "react"; + +import { AuthProvider, useAuth } from "react-oidc-context"; + +import { initInterceptors } from "@app/axios-config"; +import ENV from "@app/env"; +import { oidcClientSettings } from "@app/oidc"; +import { Bullseye, EmptyState, EmptyStateBody, EmptyStateVariant } from "@patternfly/react-core"; +import ExclamationCircleIcon from "@patternfly/react-icons/dist/esm/icons/exclamation-circle-icon"; + +import { AppPlaceholder } from "./AppPlaceholder"; + +interface IOidcProviderProps { + children: React.ReactNode; +} + +export const OidcProvider: React.FC = ({ children }) => { + return ENV.AUTH_REQUIRED !== "true" ? ( + children + ) : ( + { + const params = new URLSearchParams(window.location.search); + const relativePath = params.get("state")?.split(";")?.[1]; + window.history.replaceState({}, document.title, relativePath ?? "/"); + }} + > + {children} + + ); +}; + +const AuthEnabledOidcProvider: React.FC = ({ children }) => { + const auth = useAuth(); + + React.useEffect(() => { + if (!auth.isAuthenticated && !auth.isLoading && !auth.error) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + auth.signinRedirect({ + url_state: window.location.pathname, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps, @typescript-eslint/unbound-method + }, [auth.isAuthenticated, auth.isLoading, auth.error, auth.signinRedirect]); + + React.useEffect(() => { + initInterceptors(); + }, []); + + if (auth.isAuthenticated) { + return }>{children}; + } + if (auth.isLoading) { + return ; + } + if (auth.error) { + return ( + + + + {`${auth.error.name}: ${auth.error.message}`}. Revisit your OIDC configuration or contact your admin. + + + + ); + } + return

Logging in...

; +}; diff --git a/client/src/app/components/PageDrawerContext.tsx b/client/src/app/components/PageDrawerContext.tsx new file mode 100644 index 0000000..5a18653 --- /dev/null +++ b/client/src/app/components/PageDrawerContext.tsx @@ -0,0 +1,174 @@ +import * as React from "react"; + +import { + Drawer, + DrawerActions, + DrawerCloseButton, + DrawerContent, + DrawerContentBody, + DrawerHead, + DrawerPanelBody, + DrawerPanelContent, + type DrawerPanelContentProps, +} from "@patternfly/react-core"; +import pageStyles from "@patternfly/react-styles/css/components/Page/page"; + +const usePageDrawerState = () => { + const [isDrawerExpanded, setIsDrawerExpanded] = React.useState(false); + const [drawerPanelContent, setDrawerPanelContent] = React.useState(null); + const [drawerPanelContentProps, setDrawerPanelContentProps] = React.useState>({}); + const [drawerPageKey, setDrawerPageKey] = React.useState(""); + const drawerFocusRef = React.useRef(document.createElement("span")); + return { + isDrawerExpanded, + setIsDrawerExpanded, + drawerPanelContent, + setDrawerPanelContent, + drawerPanelContentProps, + setDrawerPanelContentProps, + drawerPageKey, + setDrawerPageKey, + drawerFocusRef: drawerFocusRef as typeof drawerFocusRef | null, + }; +}; + +type PageDrawerState = ReturnType; + +const PageDrawerContext = React.createContext({ + isDrawerExpanded: false, + setIsDrawerExpanded: () => {}, + drawerPanelContent: null, + setDrawerPanelContent: () => {}, + drawerPanelContentProps: {}, + setDrawerPanelContentProps: () => {}, + drawerPageKey: "", + setDrawerPageKey: () => {}, + drawerFocusRef: null, +}); + +// PageContentWithDrawerProvider should only be rendered as the direct child of a PatternFly Page component. +interface IPageContentWithDrawerProviderProps { + children: React.ReactNode; // The entire content of the page. See usage in client/src/app/layout/DefaultLayout. +} +export const PageContentWithDrawerProvider: React.FC = ({ children }) => { + const pageDrawerState = usePageDrawerState(); + const { isDrawerExpanded, drawerFocusRef, drawerPanelContent, drawerPanelContentProps, drawerPageKey } = + pageDrawerState; + return ( + +
+ drawerFocusRef?.current?.focus()} position="right"> + + {drawerPanelContent} + + } + > + {children} + + +
+
+ ); +}; + +let numPageDrawerContentInstances = 0; + +// PageDrawerContent can be rendered anywhere, but must have only one instance rendered at a time. +export interface IPageDrawerContentProps { + isExpanded: boolean; + onCloseClick: () => void; // Should be used to update local state such that `isExpanded` becomes false. + header?: React.ReactNode; + children: React.ReactNode; // The content to show in the drawer when `isExpanded` is true. + drawerPanelContentProps?: Partial; // Additional props for the DrawerPanelContent component. + focusKey?: string | number; // A unique key representing the object being described in the drawer. When this changes, the drawer will regain focus. + pageKey: string; // A unique key representing the page where the drawer is used. Causes the drawer to remount when changing pages. +} + +export const PageDrawerContent: React.FC = ({ + isExpanded, + onCloseClick, + header = null, + children, + drawerPanelContentProps, + pageKey: localPageKeyProp, +}) => { + const { setIsDrawerExpanded, drawerFocusRef, setDrawerPanelContent, setDrawerPanelContentProps, setDrawerPageKey } = + React.useContext(PageDrawerContext); + + // Warn if we are trying to render more than one PageDrawerContent (they'll fight over the same state). + React.useEffect(() => { + numPageDrawerContentInstances++; + return () => { + numPageDrawerContentInstances--; + }; + }, []); + if (numPageDrawerContentInstances > 1) { + console.warn( + `${numPageDrawerContentInstances} instances of PageDrawerContent are currently rendered! Only one instance of this component should be rendered at a time.` + ); + } + + // Lift the value of isExpanded out to the context, but derive it from local state such as a selected table row. + // This is the ONLY place where `setIsDrawerExpanded` should be called. + // To expand/collapse the drawer, use the `isExpanded` prop when rendering PageDrawerContent. + React.useEffect(() => { + setIsDrawerExpanded(isExpanded); + return () => { + setIsDrawerExpanded(false); + setDrawerPanelContent(null); + }; + }, [isExpanded, setDrawerPanelContent, setIsDrawerExpanded]); + + // Same with pageKey and drawerPanelContentProps, keep them in sync with the local prop on PageDrawerContent. + React.useEffect(() => { + setDrawerPageKey(localPageKeyProp); + return () => { + setDrawerPageKey(""); + }; + }, [localPageKeyProp, setDrawerPageKey]); + + React.useEffect(() => { + setDrawerPanelContentProps(drawerPanelContentProps ?? {}); + }, [drawerPanelContentProps, setDrawerPanelContentProps]); + + // If the drawer is already expanded describing app A, then the user clicks app B, we want to send focus back to the drawer. + + // TODO: This introduces a layout issue bug when clicking in between the columns of a table. + // React.useEffect(() => { + // drawerFocusRef?.current?.focus(); + // }, [drawerFocusRef, focusKey]); + + React.useEffect(() => { + const drawerHead = header ?? children; + const drawerPanelBody = header === null ? null : children; + + setDrawerPanelContent( + <> + + + {drawerHead} + + + + + + {drawerPanelBody} + + ); + }, [children, drawerFocusRef, header, isExpanded, onCloseClick, setDrawerPanelContent]); + + return null; +}; diff --git a/client/src/app/env.ts b/client/src/app/env.ts new file mode 100644 index 0000000..85796b7 --- /dev/null +++ b/client/src/app/env.ts @@ -0,0 +1,5 @@ +import { buildConsoleEnv, decodeEnv } from "@console-ui/common"; + +export const ENV = buildConsoleEnv(decodeEnv(window._env)); + +export default ENV; diff --git a/client/src/app/hooks/useBranding.ts b/client/src/app/hooks/useBranding.ts new file mode 100644 index 0000000..b2f3876 --- /dev/null +++ b/client/src/app/hooks/useBranding.ts @@ -0,0 +1,12 @@ +import { type BrandingStrings, brandingStrings } from "@console-ui/common"; + +/** + * Wrap the branding strings in a hook so components access it in a standard + * React way instead of a direct import. This allows the branding implementation + * to change in future with a minimal amount of refactoring in existing components. + */ +export const useBranding = (): BrandingStrings => { + return brandingStrings; +}; + +export default useBranding; diff --git a/client/src/app/images/avatar.svg b/client/src/app/images/avatar.svg new file mode 100644 index 0000000..4e160be --- /dev/null +++ b/client/src/app/images/avatar.svg @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/client/src/app/images/pfbg-icon.svg b/client/src/app/images/pfbg-icon.svg new file mode 100644 index 0000000..6625ff2 --- /dev/null +++ b/client/src/app/images/pfbg-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/client/src/app/layout/about.tsx b/client/src/app/layout/about.tsx new file mode 100644 index 0000000..b341ec4 --- /dev/null +++ b/client/src/app/layout/about.tsx @@ -0,0 +1,54 @@ +import type React from "react"; + +import { AboutModal, Content, ContentVariants } from "@patternfly/react-core"; +import spacing from "@patternfly/react-styles/css/utilities/Spacing/spacing"; + +import ENV from "@app/env"; +import useBranding from "@app/hooks/useBranding"; + +interface IButtonAboutAppProps { + isOpen: boolean; + onClose: () => void; +} + +const TRANSPARENT_1x1_GIF = "data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw== "; + +export const AboutApp: React.FC = ({ isOpen, onClose }) => { + const { about } = useBranding(); + + return ( + + + + {about.displayName} is a proactive service that assists in risk management of Open Source Software (OSS) + packages and dependencies. {about.displayName} brings awareness to and remediation of OSS vulnerabilities + discovered within the software supply chain. + + + {about.documentationUrl ? ( + + For more information refer to{" "} + + {about.displayName} documentation + + + ) : null} + + + + + Version + {ENV.VERSION} + + + + + ); +}; diff --git a/client/src/app/layout/default-layout.tsx b/client/src/app/layout/default-layout.tsx new file mode 100644 index 0000000..b2ce573 --- /dev/null +++ b/client/src/app/layout/default-layout.tsx @@ -0,0 +1,29 @@ +import type React from "react"; + +import { Page, SkipToContent } from "@patternfly/react-core"; + +import { PageContentWithDrawerProvider } from "@app/components/PageDrawerContext"; + +import { HeaderApp } from "./header"; +import { SidebarApp } from "./sidebar"; + +interface DefaultLayoutProps { + children?: React.ReactNode; +} + +export const DefaultLayout: React.FC = ({ children }) => { + const pageId = "main-content-page-layout-horizontal-nav"; + const PageSkipToContent = Skip to content; + + return ( + } + sidebar={} + isManagedSidebar + skipToContent={PageSkipToContent} + mainContainerId={pageId} + > + {children} + + ); +}; diff --git a/client/src/app/layout/header.tsx b/client/src/app/layout/header.tsx new file mode 100644 index 0000000..f453efe --- /dev/null +++ b/client/src/app/layout/header.tsx @@ -0,0 +1,276 @@ +import type React from "react"; +import { useReducer, useState } from "react"; +import { useAuth } from "react-oidc-context"; +import { useNavigate } from "react-router-dom"; + +import { + Avatar, + Brand, + Divider, + Dropdown, + DropdownItem, + DropdownList, + Icon, + Masthead, + MastheadBrand, + MastheadContent, + MastheadLogo, + MastheadMain, + MastheadToggle, + MenuToggle, + type MenuToggleElement, + PageToggleButton, + Split, + SplitItem, + Title, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, +} from "@patternfly/react-core"; + +import EllipsisVIcon from "@patternfly/react-icons/dist/esm/icons/ellipsis-v-icon"; +import HelpIcon from "@patternfly/react-icons/dist/esm/icons/help-icon"; +import BarsIcon from "@patternfly/react-icons/dist/js/icons/bars-icon"; +import ExternalLinkAltIcon from "@patternfly/react-icons/dist/js/icons/external-link-alt-icon"; + +import { isAuthRequired } from "@app/Constants"; +import useBranding from "@app/hooks/useBranding"; + +import imgAvatar from "../images/avatar.svg"; +import { AboutApp } from "./about"; + +export const HeaderApp: React.FC = () => { + const { + masthead: { leftBrand, leftTitle, rightBrand, supportUrl }, + } = useBranding(); + + // eslint-disable-next-line react-hooks/rules-of-hooks + const auth = (isAuthRequired && useAuth()) || undefined; + + const navigate = useNavigate(); + + const [isAboutModalOpen, toggleIsAboutModalOpen] = useReducer((state) => !state, false); + const [isHelpDropdownOpen, setIsHelpDropdownOpen] = useState(false); + const [isKebabDropdownOpen, setIsKebabDropdownOpen] = useState(false); + const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false); + + const onHelpDropdownToggle = () => { + setIsHelpDropdownOpen(!isHelpDropdownOpen); + }; + + const onKebabDropdownToggle = () => { + setIsKebabDropdownOpen(!isKebabDropdownOpen); + }; + + const logout = () => { + auth + ?.signoutRedirect() + .then(() => { + console.log("Log out success"); + }) + .catch((err) => { + console.error("Logout failed:", err); + navigate("/"); + }); + }; + + return ( + <> + + + + + + + + + + + + + + {leftBrand ? ( + + ) : null} + + + {leftTitle ? ( + + {leftTitle.text} + + ) : null} + + + + + + + + + {/* toolbar items to always show */} + + + {/* toolbar items to show at desktop sizes */} + + + setIsHelpDropdownOpen(isOpen)} + popperProps={{ position: "right" }} + toggle={(toggleRef: React.Ref) => ( + + + + )} + > + + {supportUrl && ( + + Support{" "} + + + + + )} + + About + + + + + + + {/* toolbar items to show at mobile sizes */} + + + setIsKebabDropdownOpen(isOpen)} + popperProps={{ position: "right" }} + toggle={(toggleRef: React.Ref) => ( + + + + )} + > + + {auth && ( + + Logout + + )} + + {supportUrl && ( + + Support{" "} + + + + + )} + + About + + + + + + + {/* Show the SSO menu at desktop sizes */} + + {auth && ( + + setIsUserDropdownOpen(false)} + onOpenChange={(isOpen: boolean) => setIsUserDropdownOpen(isOpen)} + popperProps={{ position: "right" }} + toggle={(toggleRef: React.Ref) => ( + setIsUserDropdownOpen(!isUserDropdownOpen)} + isFullHeight + isExpanded={isUserDropdownOpen} + icon={} + > + {auth.user?.profile.preferred_username ?? auth.user?.profile.sub} + + )} + > + + + Logout + + + + + )} + + + {rightBrand ? ( + + + + + + ) : null} + + + + + + ); +}; diff --git a/client/src/app/layout/index.ts b/client/src/app/layout/index.ts new file mode 100644 index 0000000..081402f --- /dev/null +++ b/client/src/app/layout/index.ts @@ -0,0 +1 @@ +export { DefaultLayout } from "./default-layout"; diff --git a/client/src/app/layout/layout-constants.ts b/client/src/app/layout/layout-constants.ts new file mode 100644 index 0000000..6343954 --- /dev/null +++ b/client/src/app/layout/layout-constants.ts @@ -0,0 +1,2 @@ +type ThemeType = "light" | "dark"; +export const LayoutTheme: ThemeType = "dark"; diff --git a/client/src/app/layout/sidebar.tsx b/client/src/app/layout/sidebar.tsx new file mode 100644 index 0000000..d1c1339 --- /dev/null +++ b/client/src/app/layout/sidebar.tsx @@ -0,0 +1,56 @@ +import type React from "react"; +import { NavLink } from "react-router-dom"; + +import { Nav, NavList, PageSidebar, PageSidebarBody } from "@patternfly/react-core"; +import { css } from "@patternfly/react-styles"; +import nav from "@patternfly/react-styles/css/components/Nav/nav"; + +const LINK_CLASS = nav.navLink; +const ACTIVE_LINK_CLASS = nav.modifiers.current; + +export const SidebarApp: React.FC = () => { + const renderPageNav = () => { + return ( + + ); + }; + + return ( + + {renderPageNav()} + + ); +}; diff --git a/client/src/app/oidc.ts b/client/src/app/oidc.ts new file mode 100644 index 0000000..87d6518 --- /dev/null +++ b/client/src/app/oidc.ts @@ -0,0 +1,16 @@ +import type { OidcClientSettings } from "oidc-client-ts"; + +import { ENV } from "./env"; + +export const OIDC_SERVER_URL = ENV.OIDC_SERVER_URL ?? "http://localhost:8090/realms/console"; +export const OIDC_CLIENT_ID = ENV.OIDC_CLIENT_ID ?? "frontend"; + +export const oidcClientSettings: OidcClientSettings = { + authority: OIDC_SERVER_URL, + client_id: OIDC_CLIENT_ID, + redirect_uri: window.location.origin, + post_logout_redirect_uri: window.location.origin, + response_type: "code", + loadUserInfo: true, + scope: ENV.OIDC_SCOPE ?? "openid", +}; diff --git a/src/app/Trust/Certificates/Certificates.data.tsx b/client/src/app/pages/Certificates/Certificates.data.tsx similarity index 70% rename from src/app/Trust/Certificates/Certificates.data.tsx rename to client/src/app/pages/Certificates/Certificates.data.tsx index c32e6e8..41b0f51 100644 --- a/src/app/Trust/Certificates/Certificates.data.tsx +++ b/client/src/app/pages/Certificates/Certificates.data.tsx @@ -1,4 +1,4 @@ -export type CertificateType = 'TUF' | 'Fulcio' | 'Signing'; +export type CertificateType = "TUF" | "Fulcio" | "Signing"; export interface ICertificateProps { // certificate authority subject @@ -15,29 +15,29 @@ export interface ICertificateProps { // UI / contextual name?: string; // display label, fallback to 'subject' - status?: 'valid' | 'expired' | 'expiring' | 'unknown'; + status?: "valid" | "expired" | "expiring" | "unknown"; type?: CertificateType; - role?: 'root' | 'targets' | 'snapshot' | 'timestamp'; // TUF only + role?: "root" | "targets" | "snapshot" | "timestamp"; // TUF only version?: number; // TUF only } -export const columns = ['Subject', 'PEM']; +export const columns = ["Subject", "PEM"]; export const certificates: ICertificateProps[] = [ { - subject: 'CN=Fulcio Root CA,O=Sigstore', + subject: "CN=Fulcio Root CA,O=Sigstore", pem: `-----BEGIN CERTIFICATE----- MIIBszCCAVugAwIBAgIUWY1QrUe7GpU4... (truncated) -----END CERTIFICATE-----`, }, { - subject: 'CN=Dev Fulcio CA,O=Sigstore Dev', + subject: "CN=Dev Fulcio CA,O=Sigstore Dev", pem: `-----BEGIN CERTIFICATE----- MIICkzCCAfugAwIBAgIUQw9X3lNwJzZL... (truncated) -----END CERTIFICATE-----`, }, { - subject: 'CN=GitHub OIDC CA,O=GitHub', + subject: "CN=GitHub OIDC CA,O=GitHub", pem: `-----BEGIN CERTIFICATE----- MIIDeTCCAmGgAwIBAgIUEj0+4xFe7r... (truncated) -----END CERTIFICATE-----`, diff --git a/client/src/app/pages/Certificates/Certificates.tsx b/client/src/app/pages/Certificates/Certificates.tsx new file mode 100644 index 0000000..5334460 --- /dev/null +++ b/client/src/app/pages/Certificates/Certificates.tsx @@ -0,0 +1,8 @@ +import type React from "react"; + +import { certificates, columns } from "./Certificates.data"; +import { CertificatesPage } from "./CertificatesPage"; + +export const Certificates: React.FC = () => { + return ; +}; diff --git a/src/app/Trust/Certificates/CertificatesPage.tsx b/client/src/app/pages/Certificates/CertificatesPage.tsx similarity index 75% rename from src/app/Trust/Certificates/CertificatesPage.tsx rename to client/src/app/pages/Certificates/CertificatesPage.tsx index 1b4c767..984e53c 100644 --- a/src/app/Trust/Certificates/CertificatesPage.tsx +++ b/client/src/app/pages/Certificates/CertificatesPage.tsx @@ -19,25 +19,25 @@ import { ToolbarItem, ToolbarToggleGroup, Tooltip, -} from '@patternfly/react-core'; -import { FilterIcon } from '@patternfly/react-icons'; -import { Table, Thead, Tr, Th, Tbody, Td, ActionsColumn } from '@patternfly/react-table'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import { ICertificateProps } from './Certificates.data'; -import ShieldIcon from '@patternfly/react-icons/dist/esm/icons/shield-alt-icon'; -import { capitalizeFirstLetter, formatDate, getCertificateStatusColor } from '@app/utils/utils'; +} from "@patternfly/react-core"; +import { FilterIcon } from "@patternfly/react-icons"; +import { Table, Thead, Tr, Th, Tbody, Td, ActionsColumn } from "@patternfly/react-table"; +import { useCallback, useEffect, useRef, useState } from "react"; +import type { ICertificateProps } from "./Certificates.data"; +import ShieldIcon from "@patternfly/react-icons/dist/esm/icons/shield-alt-icon"; +import { capitalizeFirstLetter, formatDate, getCertificateStatusColor } from "@app/utils/utils"; export interface ICertificatesPageProps { certificates: ICertificateProps[]; columns: string[]; } -type Direction = 'asc' | 'desc' | undefined; +type Direction = "asc" | "desc" | undefined; const CertificatesPage = ({ certificates, columns }: ICertificatesPageProps) => { const [page, setPage] = useState(1); const [perPage, setPerPage] = useState(10); - const [searchValue, setSearchValue] = useState(''); + const [searchValue, setSearchValue] = useState(""); const onSearchChange = (value: string) => { setSearchValue(value); @@ -51,7 +51,7 @@ const CertificatesPage = ({ certificates, columns }: ICertificatesPageProps) => setPerPage(newPerPage); }; - const renderPagination = (variant: 'top' | 'bottom' | PaginationVariant, isCompact: boolean) => ( + const renderPagination = (variant: "top" | "bottom" | PaginationVariant, isCompact: boolean) => ( onSetPage={handleSetPage} onPerPageSelect={handlePerPageSelect} perPageOptions={[ - { title: '10', value: 10 }, - { title: '20', value: 20 }, - { title: '50', value: 50 }, - { title: '100', value: 100 }, + { title: "10", value: 10 }, + { title: "20", value: 20 }, + { title: "50", value: 50 }, + { title: "100", value: 100 }, ]} variant={variant} titles={{ @@ -78,27 +78,28 @@ const CertificatesPage = ({ certificates, columns }: ICertificatesPageProps) => return [...certificates].sort((a, b) => { let returnValue = 0; - if (typeof Object.values(a)[sortIndex] === 'number') { + if (typeof Object.values(a)[sortIndex] === "number") { // numeric sort returnValue = Object.values(a)[sortIndex] - Object.values(b)[sortIndex]; } else { // string sort + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call returnValue = Object.values(a)[sortIndex].localeCompare(Object.values(b)[sortIndex]); } - if (sortDirection === 'desc') { + if (sortDirection === "desc") { return returnValue * -1; } return returnValue; }); }; - const [sortedData, setSortedData] = useState([...sortRows(certificates, 0, 'asc')]); + const [sortedData, setSortedData] = useState([...sortRows(certificates, 0, "asc")]); const [sortedRows, setSortedRows] = useState([...sortedData]); // index of the currently active column const [activeSortIndex, setActiveSortIndex] = useState(0); // sort direction of the currently active column - const [activeSortDirection, setActiveSortDirection] = useState('asc'); + const [activeSortDirection, setActiveSortDirection] = useState("asc"); const onSort = (_event: unknown, index: number, direction: Direction) => { setActiveSortIndex(index); @@ -114,22 +115,24 @@ const CertificatesPage = ({ certificates, columns }: ICertificatesPageProps) => const getFilteredAndSortedRows = useCallback(() => { let filtered = certificates; if (searchValue) { - const input = new RegExp(searchValue.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i'); + const input = new RegExp(searchValue.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"); filtered = certificates.filter((cert) => input.test(cert.subject)); } return [...filtered].sort((a, b) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const aVal = Object.values(a)[activeSortIndex]; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const bVal = Object.values(b)[activeSortIndex]; let result = 0; - if (typeof aVal === 'number' && typeof bVal === 'number') { + if (typeof aVal === "number" && typeof bVal === "number") { result = aVal - bVal; - } else if (typeof aVal === 'string' && typeof bVal === 'string') { + } else if (typeof aVal === "string" && typeof bVal === "string") { result = aVal.localeCompare(bVal); } - return activeSortDirection === 'desc' ? -result : result; + return activeSortDirection === "desc" ? -result : result; }); }, [certificates, searchValue, activeSortIndex, activeSortDirection]); @@ -140,7 +143,7 @@ const CertificatesPage = ({ certificates, columns }: ICertificatesPageProps) => }, [getFilteredAndSortedRows, page, perPage]); // Set up attribute selector - const [activeAttributeMenu, setActiveAttributeMenu] = useState<'Filter text' | 'Type' | 'Valid To'>('Filter text'); + const [activeAttributeMenu, setActiveAttributeMenu] = useState<"Filter text" | "Type" | "Valid To">("Filter text"); const [isAttributeMenuOpen, setIsAttributeMenuOpen] = useState(false); const attributeToggleRef = useRef(null); const attributeMenuRef = useRef(null); @@ -155,7 +158,7 @@ const CertificatesPage = ({ certificates, columns }: ICertificatesPageProps) => attributeMenuRef.current?.contains(event.target as Node) || attributeToggleRef.current?.contains(event.target as Node) ) { - if (event.key === 'Escape' || event.key === 'Tab') { + if (event.key === "Escape" || event.key === "Tab") { setIsAttributeMenuOpen(!isAttributeMenuOpen); attributeToggleRef.current?.focus(); } @@ -168,11 +171,11 @@ const CertificatesPage = ({ certificates, columns }: ICertificatesPageProps) => } }; - window.addEventListener('keydown', handleAttribueMenuKeys); - window.addEventListener('click', handleAttributeClickOutside); + window.addEventListener("keydown", handleAttribueMenuKeys); + window.addEventListener("click", handleAttributeClickOutside); return () => { - window.removeEventListener('keydown', handleAttribueMenuKeys); - window.removeEventListener('click', handleAttributeClickOutside); + window.removeEventListener("keydown", handleAttribueMenuKeys); + window.removeEventListener("click", handleAttributeClickOutside); }; }, [isAttributeMenuOpen, attributeMenuRef]); @@ -180,7 +183,7 @@ const CertificatesPage = ({ certificates, columns }: ICertificatesPageProps) => ev.stopPropagation(); // Stop handleClickOutside from handling setTimeout(() => { if (attributeMenuRef.current) { - const firstElement = attributeMenuRef.current.querySelector('li > button:not(:disabled)'); + const firstElement = attributeMenuRef.current.querySelector("li > button:not(:disabled)"); // eslint-disable-next-line @typescript-eslint/no-unused-expressions firstElement && (firstElement as HTMLElement).focus(); } @@ -203,7 +206,7 @@ const CertificatesPage = ({ certificates, columns }: ICertificatesPageProps) => { - setActiveAttributeMenu(itemId?.toString() as 'Filter text'); + setActiveAttributeMenu(itemId?.toString() as "Filter text"); setIsAttributeMenuOpen(!isAttributeMenuOpen); }} > @@ -223,7 +226,7 @@ const CertificatesPage = ({ certificates, columns }: ICertificatesPageProps) => triggerRef={attributeToggleRef} popper={attributeMenu} popperRef={attributeMenuRef} - appendTo={attributeContainerRef.current || undefined} + appendTo={attributeContainerRef.current ?? undefined} isVisible={isAttributeMenuOpen} /> @@ -235,7 +238,7 @@ const CertificatesPage = ({ certificates, columns }: ICertificatesPageProps) => placeholder="Filter by text" value={searchValue} onChange={(_event, value) => onSearchChange(value)} - onClear={() => onSearchChange('')} + onClear={() => onSearchChange("")} /> ); @@ -246,18 +249,18 @@ const CertificatesPage = ({ certificates, columns }: ICertificatesPageProps) => {attributeDropdown} setSearchValue('')} - deleteLabelGroup={() => setSearchValue('')} + labels={searchValue !== "" ? [searchValue] : ([] as string[])} + deleteLabel={() => setSearchValue("")} + deleteLabelGroup={() => setSearchValue("")} categoryName="Subject" - showToolbarItem={activeAttributeMenu === 'Filter text'} + showToolbarItem={activeAttributeMenu === "Filter text"} > {searchInput} - + {publishedOnInput} - {renderPagination('top', true)} + {renderPagination("top", true)} ); @@ -276,7 +279,7 @@ const CertificatesPage = ({ certificates, columns }: ICertificatesPageProps) => { - setSearchValue(''); + setSearchValue(""); }} > {toolbarItems} @@ -298,7 +301,7 @@ const CertificatesPage = ({ certificates, columns }: ICertificatesPageProps) => }, }; return ( - + {column} ); @@ -309,34 +312,34 @@ const CertificatesPage = ({ certificates, columns }: ICertificatesPageProps) => {sortedRows.map((row, rowIndex) => ( <> - - + + {row.subject} - {row.issuer || row.pem} - {row.type || ''} + {row.issuer ?? row.pem} + {row.type ?? ""} {/* {row.role ? capitalizeFirstLetter(row.role) : ''} */} - - {' '} - {row.status ? capitalizeFirstLetter(row.status) : ''} + + {" "} + {row.status ? capitalizeFirstLetter(row.status) : ""} {/* {row.version ? row.version : null} */} { - console.log('Copy Fingerprint button clicked'); + console.log("Copy Fingerprint button clicked"); }, }, { - title: 'Download', + title: "Download", onClick: () => { - console.log('Download button clicked'); + console.log("Download button clicked"); }, }, ]} @@ -347,7 +350,7 @@ const CertificatesPage = ({ certificates, columns }: ICertificatesPageProps) => ))} - {renderPagination('bottom', false)} + {renderPagination("bottom", false)} ); diff --git a/client/src/app/pages/Certificates/index.ts b/client/src/app/pages/Certificates/index.ts new file mode 100644 index 0000000..e7db3b2 --- /dev/null +++ b/client/src/app/pages/Certificates/index.ts @@ -0,0 +1 @@ +export { Certificates as default } from "./Certificates"; diff --git a/client/src/app/pages/Overview/Overview.tsx b/client/src/app/pages/Overview/Overview.tsx new file mode 100644 index 0000000..fef82a8 --- /dev/null +++ b/client/src/app/pages/Overview/Overview.tsx @@ -0,0 +1,7 @@ +import type React from "react"; + +import { TrustOverview } from "./TrustOverview"; + +export const Overview: React.FC = () => { + return ; +}; diff --git a/src/app/Trust/Overview/TrustOverview.tsx b/client/src/app/pages/Overview/TrustOverview.tsx similarity index 73% rename from src/app/Trust/Overview/TrustOverview.tsx rename to client/src/app/pages/Overview/TrustOverview.tsx index 267cdc4..71a3b86 100644 --- a/src/app/Trust/Overview/TrustOverview.tsx +++ b/client/src/app/pages/Overview/TrustOverview.tsx @@ -1,4 +1,4 @@ -import { Fragment } from 'react'; +import { Fragment } from "react"; import { Button, Card, @@ -24,40 +24,40 @@ import { TimestampTooltipVariant, Flex, FlexItem, -} from '@patternfly/react-core'; -import { ChartDonut, ChartThemeColor } from '@patternfly/react-charts/victory'; -import { MultiContentCard } from '@patternfly/react-component-groups'; -import { ArrowRightIcon, LockIcon, RedoIcon } from '@patternfly/react-icons'; -import { formatDate } from '@app/utils/utils'; +} from "@patternfly/react-core"; +import { ChartDonut, ChartThemeColor } from "@patternfly/react-charts/victory"; +import { MultiContentCard } from "@patternfly/react-component-groups"; +import { ArrowRightIcon, LockIcon, RedoIcon } from "@patternfly/react-icons"; +import { formatDate } from "@app/utils/utils"; // import { useQuery } from '@tanstack/react-query'; const exampleCerts = [ { - subject: 'CN=Release Signing Cert,O=Release Engineering', - issuer: 'CN=Some Root CA', - validFrom: '2024-04-15T00:00:00Z', + subject: "CN=Release Signing Cert,O=Release Engineering", + issuer: "CN=Some Root CA", + validFrom: "2024-04-15T00:00:00Z", validTo: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toISOString(), - fingerprint: 'FF:EE:DD:CC:BB:AA:99:88:77:66:55:44:33:22:11:00:FF:EE:DD:CC', - type: 'Fulcio', - status: 'expiring', + fingerprint: "FF:EE:DD:CC:BB:AA:99:88:77:66:55:44:33:22:11:00:FF:EE:DD:CC", + type: "Fulcio", + status: "expiring", }, { - subject: 'CN=Build Signing Cert,O=CI System', - issuer: 'CN=Intermed.. CA', - validFrom: '2024-06-01T00:00:00Z', + subject: "CN=Build Signing Cert,O=CI System", + issuer: "CN=Intermed.. CA", + validFrom: "2024-06-01T00:00:00Z", validTo: new Date(Date.now() + 10 * 24 * 60 * 60 * 1000).toISOString(), - fingerprint: '11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44', - type: 'Fulcio', - status: 'expiring', + fingerprint: "11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00:11:22:33:44", + type: "Fulcio", + status: "expiring", }, { - subject: 'CN=Test Cert Expiring Soon,O=Example Org', - issuer: 'CN=Example CA', - validFrom: '2024-05-01T00:00:00Z', + subject: "CN=Test Cert Expiring Soon,O=Example Org", + issuer: "CN=Example CA", + validFrom: "2024-05-01T00:00:00Z", validTo: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), - fingerprint: 'AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD', - type: 'Fulcio', - status: 'expiring', + fingerprint: "AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD", + type: "Fulcio", + status: "expiring", }, ]; @@ -100,12 +100,13 @@ const TrustOverview = () => { ariaTitle="Certificate validity" constrainToVisibleArea data={[ - { x: 'Valid', y: 35 }, - { x: 'Expiring', y: 55 }, - { x: 'Expired', y: 10 }, + { x: "Valid", y: 35 }, + { x: "Expiring", y: 55 }, + { x: "Expired", y: 10 }, ]} + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access labels={({ datum }) => `${datum.x}: ${datum.y}%`} - legendData={[{ name: 'Valid: 35' }, { name: 'Expiring: 55' }, { name: 'Expired: 10' }]} + legendData={[{ name: "Valid: 35" }, { name: "Expiring: 55" }, { name: "Expired: 10" }]} legendOrientation="vertical" name="certificate-chart" padding={{ @@ -118,7 +119,7 @@ const TrustOverview = () => { title="100" themeColor={ChartThemeColor.multiOrdered} width={350} - />{' '} + />{" "} - ); - } - - return ( - - - - We didn't find a page that matches the address you navigated to. - - - - - ) -}; - -export { NotFound }; diff --git a/src/app/Settings/General/GeneralSettings.tsx b/src/app/Settings/General/GeneralSettings.tsx deleted file mode 100644 index 92c1b6b..0000000 --- a/src/app/Settings/General/GeneralSettings.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import * as React from 'react'; -import { PageSection, Title } from '@patternfly/react-core'; -import { useDocumentTitle } from '@app/utils/useDocumentTitle'; - -const GeneralSettings: React.FunctionComponent = () => { - useDocumentTitle("General Settings"); - return ( - - - General Settings - - - ); -} - -export { GeneralSettings }; diff --git a/src/app/Settings/Profile/ProfileSettings.tsx b/src/app/Settings/Profile/ProfileSettings.tsx deleted file mode 100644 index b50e053..0000000 --- a/src/app/Settings/Profile/ProfileSettings.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import * as React from 'react'; -import { PageSection, Title } from '@patternfly/react-core'; -import { useDocumentTitle } from '@app/utils/useDocumentTitle'; - -const ProfileSettings: React.FunctionComponent = () => { - useDocumentTitle("Profile Settings"); - - return ( - - - Profile Settings - - - ); - -} - -export { ProfileSettings }; diff --git a/src/app/app.css b/src/app/app.css deleted file mode 100644 index aa11c0b..0000000 --- a/src/app/app.css +++ /dev/null @@ -1,11 +0,0 @@ -html, -body, -#root { - height: 100%; -} - -.pf-v6-c-content { - --pf-v6-c-content--small--Color: var(--pf-t--global--color--status--danger--default); /* changes all color to the semantic token for danger */ - --pf-v6-c-content--blockquote--BorderLeftColor: var(--pf-t--global--color--nonstatus--purple--default); /* changes all
left border color to the semantic token for non-status (purple) */ - --pf-v6-c-content--hr--BackgroundColor: var(--pf-t--global--color--nonstatus--yellow--default); /* changes a
color to the semantic token for non-status (yellow) */ -} diff --git a/src/app/bgimages/Logo-Red_Hat-Trusted_Artifact_Signer-A-Reverse-RGB.svg b/src/app/bgimages/Logo-Red_Hat-Trusted_Artifact_Signer-A-Reverse-RGB.svg deleted file mode 100644 index d813404..0000000 --- a/src/app/bgimages/Logo-Red_Hat-Trusted_Artifact_Signer-A-Reverse-RGB.svg +++ /dev/null @@ -1,118 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/app/bgimages/Logo-Red_Hat-Trusted_Artifact_Signer-A-White-RGB.svg b/src/app/bgimages/Logo-Red_Hat-Trusted_Artifact_Signer-A-White-RGB.svg deleted file mode 100644 index c5c1c37..0000000 --- a/src/app/bgimages/Logo-Red_Hat-Trusted_Artifact_Signer-A-White-RGB.svg +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/app/bgimages/Patternfly-Logo.svg b/src/app/bgimages/Patternfly-Logo.svg deleted file mode 100644 index 7b105b8..0000000 --- a/src/app/bgimages/Patternfly-Logo.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - Patternfly Logo - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/src/app/index.tsx b/src/app/index.tsx deleted file mode 100644 index 0db8fef..0000000 --- a/src/app/index.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import '@patternfly/react-core/dist/styles/base.css'; -import { BrowserRouter as Router } from 'react-router-dom'; -import { AppLayout } from '@app/AppLayout/AppLayout'; -import { AppRoutes } from '@app/routes'; -import '@app/app.css'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; - -const queryClient = new QueryClient(); - -const App = () => ( - - - - - - - -); - -export default App; diff --git a/src/app/routes.tsx b/src/app/routes.tsx deleted file mode 100644 index 8f043d2..0000000 --- a/src/app/routes.tsx +++ /dev/null @@ -1,108 +0,0 @@ -import * as React from 'react'; -import { Route, Routes } from 'react-router-dom'; -// import { Dashboard } from '@app/Dashboard/Dashboard'; -// import { GeneralSettings } from '@app/Settings/General/GeneralSettings'; -// import { ProfileSettings } from '@app/Settings/Profile/ProfileSettings'; -import { NotFound } from '@app/NotFound/NotFound'; -import { TrustRootsPage } from './Trust/TrustRoots/TrustRootsPage'; -import { TrustOverview } from './Trust/Overview/TrustOverview'; -// import { ArtifactsPage } from './Artifacts/ArtifactsPage'; -import { CertificatesPage } from './Trust/Certificates/CertificatesPage'; -import { certificates, columns } from './Trust/Certificates/Certificates.data'; - -export interface IAppRoute { - label?: string; // Excluding the label will exclude the route from the nav sidebar in AppLayout - /* eslint-disable @typescript-eslint/no-explicit-any */ - element: React.ReactElement; - /* eslint-enable @typescript-eslint/no-explicit-any */ - exact?: boolean; - path: string; - title: string; - routes?: undefined; -} - -export interface IAppRouteGroup { - label: string; - routes: IAppRoute[]; -} - -export type AppRouteConfig = IAppRoute | IAppRouteGroup; - -const routes: AppRouteConfig[] = [ - { - label: 'Trust', - routes: [ - { - element: , - exact: true, - label: 'Overview', - // path: '/trust/overview', - path: '/', - title: 'Overview', - }, - { - element: , - exact: true, - label: 'Certificates', - path: '/trust/certificates', - title: 'Certificates', - }, - { - element: , - exact: true, - label: 'Trust Roots', - path: '/trust/roots', - title: 'Trust Roots', - }, - ], - }, - // { - // element: , - // exact: true, - // label: 'Artifacts', - // path: '/artifacts', - // title: 'Artifacts', - // }, - // { - // label: 'Settings', - // routes: [ - // { - // element: , - // exact: true, - // label: 'General', - // path: '/settings/general', - // title: 'General Settings', - // }, - // { - // element: , - // exact: true, - // label: 'Profile', - // path: '/settings/profile', - // title: 'Profile Settings', - // }, - // ], - // }, - // { - // element: , - // exact: true, - // label: 'Dashboard', - // path: '/dashboard', - // title: 'RHTAS Console UI', - // }, -]; - -const flattenedRoutes: IAppRoute[] = routes.reduce( - (flattened, route) => [...flattened, ...(route.routes ? route.routes : [route])], - [] as IAppRoute[], -); - -const AppRoutes = (): React.ReactElement => ( - - {flattenedRoutes.map(({ path, element }, idx) => ( - - ))} - } /> - -); - -export { AppRoutes, routes }; diff --git a/src/app/utils/useDocumentTitle.ts b/src/app/utils/useDocumentTitle.ts deleted file mode 100644 index 0442ab4..0000000 --- a/src/app/utils/useDocumentTitle.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as React from 'react'; - -// a custom hook for setting the page title -export function useDocumentTitle(title: string) { - React.useEffect(() => { - const originalTitle = document.title; - document.title = title; - - return () => { - document.title = originalTitle; - }; - }, [title]); -} diff --git a/src/favicon.png b/src/favicon.png deleted file mode 100644 index dccdce0..0000000 Binary files a/src/favicon.png and /dev/null differ diff --git a/src/index.html b/src/index.html deleted file mode 100644 index 0ce67c9..0000000 --- a/src/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - RHTAS Console - - - - - - - - -
- - - diff --git a/src/index.tsx b/src/index.tsx deleted file mode 100644 index 8b18988..0000000 --- a/src/index.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; -import App from '@app/index'; - -if (process.env.NODE_ENV !== "production") { - const config = { - rules: [ - { - id: 'color-contrast', - enabled: false - } - ] - }; - // eslint-disable-next-line @typescript-eslint/no-require-imports - const axe = require("react-axe"); - axe(React, ReactDOM, 1000, config); -} - -const root = ReactDOM.createRoot(document.getElementById("root") as Element); - -root.render( - - - -) diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts deleted file mode 100644 index 30b0202..0000000 --- a/src/mocks/handlers.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { http, HttpResponse } from 'msw'; - -export const handlers = [ - // healthz - // Retrieves the current health status of the server. - http.get('http://localhost:8080/healthz', () => { - return HttpResponse.json({}); - }), - - // artifacts: sign - // Signs an artifact using Cosign. - http.get('http://localhost:8080/api/v1/artifacts/sign', () => { - return HttpResponse.json({}); - }), - - // artifacts: verify - // Verifies an artifact using Cosign. - http.get('http://localhost:8080/api/v1/artifacts/verify', () => { - return HttpResponse.json({}); - }), - - // artifacts: policies - // Retrieves policies and attestations for an artifact. - http.get('http://localhost:8080/api/v1/artifacts/{artifact}/policies', () => { - return HttpResponse.json({}); - }), - - // artifacts: image - // Retrieves metadata for a container image by full reference URI. - http.get('http://localhost:8080/api/v1/artifacts/image', () => { - return HttpResponse.json({}); - }), - - // rekor: entries - // Retrieves a Rekor transparency log entry by UUID. - http.get('http://localhost:8080/api/v1/rekor/entries/{uuid}', () => { - return HttpResponse.json({}); - }), - - // rekor: public key - // Retrieves the Rekor public key in PEM format. - http.get('http://localhost:8080/api/v1/rekor/public-key', () => { - return HttpResponse.json({}); - }), - - // trust config - // Retrieves TUF targets and Fulcio certificate authorities. - http.get('http://localhost:8080/api/v1/trust/config', () => { - return HttpResponse.json({}); - }), -]; diff --git a/src/mocks/node.js b/src/mocks/node.js deleted file mode 100644 index f10a6b6..0000000 --- a/src/mocks/node.js +++ /dev/null @@ -1,4 +0,0 @@ -import { setupServer } from 'msw/node'; -import { handlers } from './handlers.js'; - -export const server = setupServer(...handlers); diff --git a/src/typings.d.ts b/src/typings.d.ts deleted file mode 100644 index 16df0f5..0000000 --- a/src/typings.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -declare module '*.png'; -declare module '*.jpg'; -declare module '*.jpeg'; -declare module '*.gif'; -declare module '*.svg'; -declare module '*.css'; -declare module '*.wav'; -declare module '*.mp3'; -declare module '*.m4a'; -declare module '*.rdf'; -declare module '*.ttl'; -declare module '*.pdf'; diff --git a/stories/Trust/Certificates.stories.tsx b/stories/Trust/Certificates.stories.tsx deleted file mode 100644 index 63d2c5f..0000000 --- a/stories/Trust/Certificates.stories.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-webpack5'; -import { CertificatesPage } from '@app/Trust/Certificates/CertificatesPage'; -import { certificates, columns } from '@app/Trust/Certificates/Certificates.data'; - -const meta = { - title: 'Trust/Certificates Page', - component: CertificatesPage, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const CurrentState: Story = { - args: { - certificates: certificates, - columns: columns, - }, -}; - -export const WithExtendedFields: Story = { - args: { - certificates: [ - { - subject: 'CN=Fulcio Root CA,O=Sigstore', - pem: `-----BEGIN CERTIFICATE----- -MIIBszCCAVugAwIBAgIUWY1QrUe7GpU4... (truncated) ------END CERTIFICATE-----`, - issuer: 'CN=Sigstore Root CA', - validFrom: '2023-01-01T00:00:00Z', - validTo: '2033-01-01T00:00:00Z', - fingerprint: '3A:1F:DE:AD:BE:EF:00:00:12:34:56:78:90:AB:CD:EF:01:23:45:67', - type: 'Fulcio', - status: 'valid', - }, - { - subject: 'CN=Test Cert Expiring Soon,O=Example Org', - issuer: 'CN=Example CA', - validFrom: '2024-05-01T00:00:00Z', - validTo: new Date(Date.now() + 15 * 24 * 60 * 60 * 1000).toISOString(), - fingerprint: 'AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD', - type: 'Fulcio', - status: 'expiring', - }, - { - subject: 'CN=Expired Test Cert,O=Example Org', - issuer: 'CN=Example CA', - validFrom: '2022-01-01T00:00:00Z', - validTo: '2023-01-01T00:00:00Z', - fingerprint: '00:11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:11:22:33:44', - type: 'Fulcio', - status: 'expired', - }, - { - subject: 'CN=GitHub OIDC Signing CA,O=GitHub', - pem: `-----BEGIN CERTIFICATE----- -MIIDeTCCAmGgAwIBAgIUEj0+4xFe7r... (truncated) ------END CERTIFICATE-----`, - issuer: 'CN=GitHub Root CA', - validFrom: '2024-03-15T00:00:00Z', - validTo: '2026-03-15T00:00:00Z', - fingerprint: 'FE:ED:FA:CE:BE:EF:DE:AD:12:34:56:78:9A:BC:DE:F0:12:34:56:78', - type: 'Fulcio', - status: 'valid', - }, - { - name: 'Fulcio Production CA', - subject: 'CN=Fulcio Root CA,O=Sigstore', - issuer: 'Sigstore Root CA', - validFrom: '2022-07-01T00:00:00Z', - validTo: '2032-07-01T00:00:00Z', - fingerprint: 'FE:ED:FA:CE:DE:AD:BE:EF:00:11:22:33:44:55:66:77:88:99:AA:BB', - type: 'Fulcio', - status: 'valid', - }, - ], - // columns: ['Subject', 'Issuer', 'Type', 'Status', 'Version'], - columns: ['Subject', 'Issuer', 'Type', 'Status'], - }, -}; diff --git a/stories/Trust/TrustOverview.stories.tsx b/stories/Trust/TrustOverview.stories.tsx deleted file mode 100644 index 36dc223..0000000 --- a/stories/Trust/TrustOverview.stories.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-webpack5'; -import { TrustOverview } from '@app/Trust/Overview/TrustOverview'; - -const meta = { - title: 'Trust/Trust Overview', - component: TrustOverview, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const DefaultState: Story = {}; diff --git a/stories/Trust/TrustRoots.stories.tsx b/stories/Trust/TrustRoots.stories.tsx deleted file mode 100644 index 8ba856c..0000000 --- a/stories/Trust/TrustRoots.stories.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import type { Meta, StoryObj } from '@storybook/react-webpack5'; -import { TrustRootsPage } from '@app/Trust/TrustRoots/TrustRootsPage'; - -const meta = { - title: 'Trust/Trust Roots Page', - component: TrustRootsPage, -} satisfies Meta; - -export default meta; -type Story = StoryObj; - -export const DefaultState: Story = {}; diff --git a/stories/assets/accessibility.png b/stories/assets/accessibility.png deleted file mode 100644 index 6ffe6fe..0000000 Binary files a/stories/assets/accessibility.png and /dev/null differ diff --git a/stories/assets/accessibility.svg b/stories/assets/accessibility.svg deleted file mode 100644 index 107e93f..0000000 --- a/stories/assets/accessibility.svg +++ /dev/null @@ -1 +0,0 @@ -Accessibility \ No newline at end of file diff --git a/stories/assets/addon-library.png b/stories/assets/addon-library.png deleted file mode 100644 index 95deb38..0000000 Binary files a/stories/assets/addon-library.png and /dev/null differ diff --git a/stories/assets/assets.png b/stories/assets/assets.png deleted file mode 100644 index cfba681..0000000 Binary files a/stories/assets/assets.png and /dev/null differ diff --git a/stories/assets/avif-test-image.avif b/stories/assets/avif-test-image.avif deleted file mode 100644 index 530709b..0000000 Binary files a/stories/assets/avif-test-image.avif and /dev/null differ diff --git a/stories/assets/context.png b/stories/assets/context.png deleted file mode 100644 index e5cd249..0000000 Binary files a/stories/assets/context.png and /dev/null differ diff --git a/stories/assets/discord.svg b/stories/assets/discord.svg deleted file mode 100644 index d638958..0000000 --- a/stories/assets/discord.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/stories/assets/docs.png b/stories/assets/docs.png deleted file mode 100644 index a749629..0000000 Binary files a/stories/assets/docs.png and /dev/null differ diff --git a/stories/assets/figma-plugin.png b/stories/assets/figma-plugin.png deleted file mode 100644 index 8f79b08..0000000 Binary files a/stories/assets/figma-plugin.png and /dev/null differ diff --git a/stories/assets/github.svg b/stories/assets/github.svg deleted file mode 100644 index dc51352..0000000 --- a/stories/assets/github.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/stories/assets/share.png b/stories/assets/share.png deleted file mode 100644 index 8097a37..0000000 Binary files a/stories/assets/share.png and /dev/null differ diff --git a/stories/assets/styling.png b/stories/assets/styling.png deleted file mode 100644 index d341e82..0000000 Binary files a/stories/assets/styling.png and /dev/null differ diff --git a/stories/assets/testing.png b/stories/assets/testing.png deleted file mode 100644 index d4ac39a..0000000 Binary files a/stories/assets/testing.png and /dev/null differ diff --git a/stories/assets/theming.png b/stories/assets/theming.png deleted file mode 100644 index 1535eb9..0000000 Binary files a/stories/assets/theming.png and /dev/null differ diff --git a/stories/assets/tutorials.svg b/stories/assets/tutorials.svg deleted file mode 100644 index b492a9c..0000000 --- a/stories/assets/tutorials.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/stories/assets/youtube.svg b/stories/assets/youtube.svg deleted file mode 100644 index a7515d7..0000000 --- a/stories/assets/youtube.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 76a6fb4..0000000 --- a/tsconfig.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "compilerOptions": { - "allowImportingTsExtensions": true, - "allowJs": true, - "allowSyntheticDefaultImports": true, - "baseUrl": ".", - "esModuleInterop": true, - "forceConsistentCasingInFileNames": true, - "importHelpers": true, - "isolatedModules": true, - "jsx": "react-jsx", - "lib": [ - "DOM", - "ES2020" - ], - "module": "ESNext", - "moduleResolution": "Bundler", - "noEmit": true, - "noImplicitAny": false, - "noImplicitReturns": true, - "noImplicitThis": true, - "paths": { - "@app/*": [ - "src/app/*" - ], - "@assets/*": [ - "node_modules/@patternfly/react-core/dist/styles/assets/*" - ] - }, - "resolveJsonModule": true, - "skipLibCheck": true, - "strict": true, - "target": "ES2020", - "useDefineForClassFields": true, - "noUnusedLocals": false, - "noUnusedParameters": false - }, - "include": [ - "**/*.ts", - "**/*.tsx", - "**/*.jsx", - "**/*.js" - ], - "exclude": [ - "node_modules" - ] -} \ No newline at end of file diff --git a/webpack.config.ts b/webpack.config.ts deleted file mode 100644 index 48d0a66..0000000 --- a/webpack.config.ts +++ /dev/null @@ -1,147 +0,0 @@ -const path = require('path'); -const HtmlWebpackPlugin = require('html-webpack-plugin'); -const CopyPlugin = require('copy-webpack-plugin'); -const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin'); -const Dotenv = require('dotenv-webpack'); -const BG_IMAGES_DIRNAME = 'bgimages'; -const ASSET_PATH = process.env.ASSET_PATH || '/'; -const HOST = process.env.HOST || 'localhost'; -const PORT = process.env.PORT || '9000'; - -module.exports = { - mode: 'development', - devtool: 'eval-source-map', - devServer: { - host: HOST, - port: PORT, - historyApiFallback: true, - open: true, - static: { - directory: path.resolve(__dirname, 'dist'), - }, - client: { - overlay: true, - }, - }, - module: { - rules: [ - { - test: /\.css$/, - use: ['style-loader', 'css-loader'], - }, - { - test: /\.(tsx|ts|jsx)?$/, - use: [ - { - loader: 'ts-loader', - options: { - transpileOnly: true, - experimentalWatchApi: true, - }, - }, - ], - }, - { - test: /\.(svg|ttf|eot|woff|woff2)$/, - type: 'asset/resource', - include: [ - path.resolve(__dirname, 'node_modules/patternfly/dist/fonts'), - path.resolve(__dirname, 'node_modules/@patternfly/react-core/dist/styles/assets/fonts'), - path.resolve(__dirname, 'node_modules/@patternfly/react-core/dist/styles/assets/pficon'), - path.resolve(__dirname, 'node_modules/@patternfly/patternfly/assets/fonts'), - path.resolve(__dirname, 'node_modules/@patternfly/patternfly/assets/pficon'), - ], - }, - { - test: /\.svg$/, - type: 'asset/inline', - include: (resourcePath: string | string[]) => resourcePath.indexOf('background-filter.svg') > 1, - use: [ - { - options: { - limit: 5000, - outputPath: 'svgs', - name: '[name].[ext]', - }, - }, - ], - }, - { - test: /\.svg$/, - include: (input: string) => input.indexOf(BG_IMAGES_DIRNAME) > -1, - type: 'asset/inline', - }, - { - test: /\.svg$/, - include: (input: string) => - input.indexOf(BG_IMAGES_DIRNAME) === -1 && - input.indexOf('fonts') === -1 && - input.indexOf('background-filter') === -1 && - input.indexOf('pficon') === -1, - use: { - loader: 'raw-loader', - options: {}, - }, - }, - { - test: /\.(jpg|jpeg|png|gif)$/i, - include: [ - path.resolve(__dirname, 'src'), - path.resolve(__dirname, 'node_modules/patternfly'), - path.resolve(__dirname, 'node_modules/@patternfly/patternfly/assets/images'), - path.resolve(__dirname, 'node_modules/@patternfly/react-styles/css/assets/images'), - path.resolve(__dirname, 'node_modules/@patternfly/react-core/dist/styles/assets/images'), - path.resolve( - __dirname, - 'node_modules/@patternfly/react-core/node_modules/@patternfly/react-styles/css/assets/images' - ), - path.resolve( - __dirname, - 'node_modules/@patternfly/react-table/node_modules/@patternfly/react-styles/css/assets/images' - ), - path.resolve( - __dirname, - 'node_modules/@patternfly/react-inline-edit-extension/node_modules/@patternfly/react-styles/css/assets/images' - ), - ], - type: 'asset/inline', - use: [ - { - options: { - limit: 5000, - outputPath: 'images', - name: '[name].[ext]', - }, - }, - ], - }, - ], - }, - output: { - filename: '[name].bundle.js', - path: path.resolve(__dirname, 'dist'), - publicPath: ASSET_PATH, - }, - plugins: [ - new HtmlWebpackPlugin({ - template: path.resolve(__dirname, 'src', 'index.html'), - }), - new Dotenv({ - systemvars: true, - silent: true, - }), - new CopyPlugin({ - patterns: [{ from: './src/favicon.png', to: 'images' }], - }), - ], - resolve: { - extensions: ['.js', '.ts', '.tsx', '.jsx'], - plugins: [ - new TsconfigPathsPlugin({ - configFile: path.resolve(__dirname, './tsconfig.json'), - }), - ], - symlinks: false, - cacheWithContext: false, - }, -};