diff --git a/.nvmrc b/.nvmrc index 8ddbc0c6..7950a445 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v18.16.0 +v18.17.0 diff --git a/Dockerfile b/Dockerfile index 7b04e347..561de116 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ # # ############### -FROM node:18.16.0 AS build_stage +FROM node:18.17.0 AS build_stage # Install global dependencies. RUN apt-get update && apt-get install -y build-essential @@ -30,7 +30,7 @@ RUN START_SERVER=false sh ./scripts/startup.sh # # ############### -FROM node:18.16.0 AS serve_stage +FROM node:18.17.0 AS serve_stage ENV VITE_PORTAL_SERVER_URL=$VITE_PORTAL_SERVER_URL \ VITE_CLIENT_ID=$VITE_CLIENT_ID \ @@ -43,9 +43,14 @@ ENV VITE_PORTAL_SERVER_URL=$VITE_PORTAL_SERVER_URL \ VITE_SWAGGER_CONFIG_URL=$VITE_SWAGGER_CONFIG_URL \ VITE_AUDIENCE=$VITE_AUDIENCE \ VITE_HOME_IMAGE_URL=$VITE_HOME_IMAGE_URL \ - VITE_APIS_IMAGE_URL=$VITE_APIS_IMAGE_URL \ + VITE_BANNER_IMAGE_URL=$VITE_BANNER_IMAGE_URL \ VITE_LOGO_IMAGE_URL=$VITE_LOGO_IMAGE_URL \ VITE_COMPANY_NAME=$VITE_COMPANY_NAME \ + VITE_CUSTOM_PAGES=$VITE_CUSTOM_PAGES \ + VITE_SWAGGER_PREFILL_API_KEY=$VITE_SWAGGER_PREFILL_API_KEY \ + VITE_SWAGGER_PREFILL_OAUTH=$VITE_SWAGGER_PREFILL_OAUTH \ + VITE_SWAGGER_PREFILL_BASIC=$VITE_SWAGGER_PREFILL_BASIC \ + VITE_DEFAULT_APP_AUTH=$VITE_DEFAULT_APP_AUTH \ VITE_API_PAGE_RELOAD=$VITE_API_PAGE_RELOAD # Copy the server files, (this includes the UI build). @@ -68,8 +73,13 @@ ENTRYPOINT VITE_PORTAL_SERVER_URL=$VITE_PORTAL_SERVER_URL \ VITE_SWAGGER_CONFIG_URL=$VITE_SWAGGER_CONFIG_URL \ VITE_AUDIENCE=$VITE_AUDIENCE \ VITE_HOME_IMAGE_URL=$VITE_HOME_IMAGE_URL \ - VITE_APIS_IMAGE_URL=$VITE_APIS_IMAGE_URL \ + VITE_BANNER_IMAGE_URL=$VITE_BANNER_IMAGE_URL \ VITE_LOGO_IMAGE_URL=$VITE_LOGO_IMAGE_URL \ VITE_COMPANY_NAME=$VITE_COMPANY_NAME \ + VITE_CUSTOM_PAGES=$VITE_CUSTOM_PAGES \ + VITE_SWAGGER_PREFILL_API_KEY=$VITE_SWAGGER_PREFILL_API_KEY \ + VITE_SWAGGER_PREFILL_OAUTH=$VITE_SWAGGER_PREFILL_OAUTH \ + VITE_SWAGGER_PREFILL_BASIC=$VITE_SWAGGER_PREFILL_BASIC \ + VITE_DEFAULT_APP_AUTH=$VITE_DEFAULT_APP_AUTH \ VITE_API_PAGE_RELOAD=$VITE_API_PAGE_RELOAD \ node ./bin/www diff --git a/Makefile b/Makefile index 61b33136..f012907b 100644 --- a/Makefile +++ b/Makefile @@ -84,11 +84,11 @@ else ifneq ($(HOME_IMAGE_URL),) UI_ARGS += VITE_HOME_IMAGE_URL=$(HOME_IMAGE_URL) endif # -# APIS_IMAGE_URL -ifneq ($(VITE_APIS_IMAGE_URL),) - UI_ARGS += VITE_APIS_IMAGE_URL=$(VITE_APIS_IMAGE_URL) -else ifneq ($(APIS_IMAGE_URL),) - UI_ARGS += VITE_APIS_IMAGE_URL=$(APIS_IMAGE_URL) +# BANNER_IMAGE_URL +ifneq ($(VITE_BANNER_IMAGE_URL),) + UI_ARGS += VITE_BANNER_IMAGE_URL=$(VITE_BANNER_IMAGE_URL) +else ifneq ($(BANNER_IMAGE_URL),) + UI_ARGS += VITE_BANNER_IMAGE_URL=$(BANNER_IMAGE_URL) endif # # LOGO_IMAGE_URL @@ -105,6 +105,41 @@ else ifneq ($(COMPANY_NAME),) UI_ARGS += VITE_COMPANY_NAME=$(COMPANY_NAME) endif # +# CUSTOM PAGES +ifneq ($(VITE_CUSTOM_PAGES),) + UI_ARGS += VITE_CUSTOM_PAGES=$(VITE_CUSTOM_PAGES) +else ifneq ($(CUSTOM_PAGES),) + UI_ARGS += VITE_CUSTOM_PAGES=$(CUSTOM_PAGES) +endif +# +# SWAGGER_PREFILL_API_KEY +ifneq ($(VITE_SWAGGER_PREFILL_API_KEY),) + UI_ARGS += VITE_SWAGGER_PREFILL_API_KEY=$(VITE_SWAGGER_PREFILL_API_KEY) +else ifneq ($(SWAGGER_PREFILL_API_KEY),) + UI_ARGS += VITE_SWAGGER_PREFILL_API_KEY=$(SWAGGER_PREFILL_API_KEY) +endif +# +# SWAGGER_PREFILL_OAUTH +ifneq ($(VITE_SWAGGER_PREFILL_OAUTH),) + UI_ARGS += VITE_SWAGGER_PREFILL_OAUTH=$(VITE_SWAGGER_PREFILL_OAUTH) +else ifneq ($(SWAGGER_PREFILL_OAUTH),) + UI_ARGS += VITE_SWAGGER_PREFILL_OAUTH=$(SWAGGER_PREFILL_OAUTH) +endif +# +# SWAGGER_PREFILL_BASIC +ifneq ($(VITE_SWAGGER_PREFILL_BASIC),) + UI_ARGS += VITE_SWAGGER_PREFILL_BASIC=$(VITE_SWAGGER_PREFILL_BASIC) +else ifneq ($(SWAGGER_PREFILL_BASIC),) + UI_ARGS += VITE_SWAGGER_PREFILL_BASIC=$(SWAGGER_PREFILL_BASIC) +endif +# +# DEFAULT_APP_AUTH +ifneq ($(VITE_DEFAULT_APP_AUTH),) + UI_ARGS += VITE_DEFAULT_APP_AUTH=$(VITE_DEFAULT_APP_AUTH) +else ifneq ($(DEFAULT_APP_AUTH),) + UI_ARGS += VITE_DEFAULT_APP_AUTH=$(DEFAULT_APP_AUTH) +endif + # API_PAGE_RELOAD ifneq ($(VITE_API_PAGE_RELOAD),) UI_ARGS += VITE_API_PAGE_RELOAD=$(VITE_API_PAGE_RELOAD) diff --git a/README.md b/README.md index 620e4891..0739b542 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ This is an example Solo.io Gloo Platform Dev Portal frontend app, built with [Vi 2. Build your image. ```sh - docker build -t "your-image-name" + docker build -t "your-image-name" . ``` 3. Push your image: @@ -135,9 +135,24 @@ You can add these environment variables to a `.env.local` file in the `projects/ 4. rebuilding the project. - `VITE_AUDIENCE` - This is an optional parameter if using Auth0 and need to send an audience parameter in your authorization requests. This should not be URL encoded, since it will be URL encoded when the request is sent. - `VITE_HOME_IMAGE_URL` - This is an optional parameter to set the image URL on the home page. -- `VITE_APIS_IMAGE_URL` - This is an optional parameter to set the image URL on the apis page. +- `VITE_BANNER_IMAGE_URL` - This is an optional parameter to set the banner image URL for the teams, apps, subscriptions, and API's pages. - `VITE_LOGO_IMAGE_URL` - This is an optional parameter to set the image URL for the logo in the upper left. -- `VITE_API_PAGE_RELOAD` - This is an optional parameter that ensures the API page reloads when navigating to it. This is useful when gating the API page behind an auth flow. +- `VITE_CUSTOM_PAGES` - This is an optional value that describes Markdown or HTML custom pages that have been added to the `projects/ui/src/public` folder. In order to test this feature out out with the provided examples, set your `VITE_CUSTOM_PAGES` value to: + ``` + '[{"title": "Markdown Example", "path": "/pages/markdown-example.md"}, {"title": "HTML Example", "path": "/pages/html-example.html"}]' + ``` + When the website is opened, there should be two new pages in the top navigation bar. + ![custom pages example](readme_assets/custom-pages-navbar.png) + The custom page's `path` property must be publicly accessible and end with `.md` or `.html`. +- `VITE_SWAGGER_PREFILL_API_KEY` - Prefills the Swagger UI authorization configuration for an API key or Bearer authorization scheme with the specified values. This can be set using the following format: `'["authDefinitionKey", "apiKeyValue"]'`, where "authDefinitionKey" is the key name of the security scheme to use from the API definition. In case of OpenAPI 3.0 Bearer scheme, `apiKeyValue` must contain just the token itself without the Bearer prefix. To use the logged in user's authorization token for the `apiKeyValue`, you may use the following syntax: `'["authDefinitionKey", "{{USER_TOKEN}}"]'`. +- `VITE_SWAGGER_PREFILL_OAUTH` - Prefills the Swagger UI authorization configuration for an OAuth server. This variable should be set to a serialized JSON object that is the OAuth2 configuration. See the [Swagger UI OAuth2 documentation](https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/oauth2.md) for more information. + - Converting the example object from the Swagger UI documentation to a string would result in the following: + ``` + VITE_SWAGGER_PREFILL_OAUTH='{"clientId": "your-client-id","clientSecret": "your-client-secret-if-required","realm": "your-realms","appName": "your-app-name","scopeSeparator": " ","scopes": "openid profile","additionalQueryStringParams": {"test": "hello"},"useBasicAuthenticationWithAccessCodeGrant": true,"usePkceWithAuthorizationCodeGrant": true}' + ``` +- `VITE_SWAGGER_PREFILL_BASIC` - Prefills the Swagger UI authorization configuration for a Basic authorization scheme. This can be set using the following format: `'["authDefinitionKey", "username", "password"]'`. +- `VITE_DEFAULT_APP_AUTH` - This controls whether the OAuth and/or API Key sections are shown on the App details page. Can be set to `"OAUTH"`, `"API_KEY"`, or `"ALL"`. Defaults to `"ALL"`. +- `VITE_API_PAGE_RELOAD` - This is an optional parameter that ensures the API page reloads when navigating to it when set to `"true"`. This is useful when gating the API page behind an auth flow. #### Environment Variables for PKCE Authorization Flow diff --git a/changelog/v0.0.39/app-and-subscription-metadata.yaml b/changelog/v0.0.39/app-and-subscription-metadata.yaml new file mode 100644 index 00000000..da6c6ca3 --- /dev/null +++ b/changelog/v0.0.39/app-and-subscription-metadata.yaml @@ -0,0 +1,11 @@ +changelog: + - type: NEW_FEATURE + issueLink: https://github.com/solo-io/solo-projects/issues/6952 + description: >- + Adds metadata CRUD operations for apps and subscriptions, + based on admin/non-admin role. + - type: FIX + issueLink: https://github.com/solo-io/solo-projects/issues/6958 + description: >- + Adds a request to create the logged in user if they are not found + in the portal server DB. diff --git a/changelog/v0.0.39/app-details-api-keys-section.yaml b/changelog/v0.0.39/app-details-api-keys-section.yaml new file mode 100644 index 00000000..214a08fd --- /dev/null +++ b/changelog/v0.0.39/app-details-api-keys-section.yaml @@ -0,0 +1,5 @@ +changelog: + - type: FIX + issueLink: https://github.com/solo-io/solo-projects/issues/6881 + description: >- + Adds an API keys section to the App Details page. diff --git a/changelog/v0.0.39/app-oauth-client-functionality.yaml b/changelog/v0.0.39/app-oauth-client-functionality.yaml new file mode 100644 index 00000000..62c2e564 --- /dev/null +++ b/changelog/v0.0.39/app-oauth-client-functionality.yaml @@ -0,0 +1,9 @@ +changelog: + - type: NEW_FEATURE + issueLink: https://github.com/solo-io/solo-projects/issues/6886 + description: >- + Adds the ability to create and delete oauth clients from the App details page. + - type: NEW_FEATURE + issueLink: https://github.com/solo-io/solo-projects/issues/6953 + description: >- + Adds the ability to customize all banner images through the VITE_BANNER_IMAGE_URL environment variable. diff --git a/changelog/v0.0.39/apps-docs-loading-fixes.yaml b/changelog/v0.0.39/apps-docs-loading-fixes.yaml new file mode 100644 index 00000000..e6a5eaa2 --- /dev/null +++ b/changelog/v0.0.39/apps-docs-loading-fixes.yaml @@ -0,0 +1,17 @@ +changelog: + - type: FIX + issueLink: https://github.com/solo-io/solo-projects/issues/6853 + description: >- + Fixes flickering loading state on landing pages. + - type: FIX + issueLink: https://github.com/solo-io/solo-projects/issues/6852 + description: >- + Adds docs setup information to API details page. + - type: FIX + issueLink: https://github.com/solo-io/solo-projects/issues/6851 + description: >- + Removes placeholder images for apps. + - type: FIX + issueLink: https://github.com/solo-io/solo-projects/issues/6854 + description: >- + Adds information when in a logged out empty state on private Portals. diff --git a/changelog/v0.0.39/custom-pages.yaml b/changelog/v0.0.39/custom-pages.yaml new file mode 100644 index 00000000..504cc325 --- /dev/null +++ b/changelog/v0.0.39/custom-pages.yaml @@ -0,0 +1,5 @@ +changelog: + - type: FIX + issueLink: https://github.com/solo-io/solo-projects/issues/6860 + description: >- + Adds the ability for users to create custom pages that show up in the UI. diff --git a/changelog/v0.0.39/header-section-oidc-fixes.yaml b/changelog/v0.0.39/header-section-oidc-fixes.yaml new file mode 100644 index 00000000..64b7367c --- /dev/null +++ b/changelog/v0.0.39/header-section-oidc-fixes.yaml @@ -0,0 +1,6 @@ +changelog: + - type: FIX + issueLink: https://github.com/solo-io/gloo-mesh-enterprise/issues/18658 + description: >- + Adds links for the new teams + apps pages to the OIDC auth header variant. + This also includes some refactoring of that area. diff --git a/changelog/v0.0.39/options-for-app-auth-methods.yaml b/changelog/v0.0.39/options-for-app-auth-methods.yaml new file mode 100644 index 00000000..4450ff69 --- /dev/null +++ b/changelog/v0.0.39/options-for-app-auth-methods.yaml @@ -0,0 +1,6 @@ +changelog: + - type: FIX + issueLink: https://github.com/solo-io/solo-projects/issues/7059 + description: >- + Adds an option to configure the UI to show the API Key, OAuth, or both + authorization sections on the App details page. diff --git a/changelog/v0.0.39/rate-limit-display-updated-when-mixed.yaml b/changelog/v0.0.39/rate-limit-display-updated-when-mixed.yaml new file mode 100644 index 00000000..4eeebb1b --- /dev/null +++ b/changelog/v0.0.39/rate-limit-display-updated-when-mixed.yaml @@ -0,0 +1,10 @@ +changelog: + - type: FIX + issueLink: https://github.com/solo-io/solo-projects/issues/7043 + description: >- + Updates the rate limit UI element to show mixed values when there is a App which + has multiple subscriptions with different rate limits. + - type: FIX + issueLink: https://github.com/solo-io/solo-projects/issues/7066 + description: >- + Fixes a bug where subscriptions incorrectly showed up as deleted. diff --git a/changelog/v0.0.39/swagger-ui-prefill-token.yaml b/changelog/v0.0.39/swagger-ui-prefill-token.yaml new file mode 100644 index 00000000..4c8ca379 --- /dev/null +++ b/changelog/v0.0.39/swagger-ui-prefill-token.yaml @@ -0,0 +1,7 @@ +changelog: + - type: FIX + issueLink: https://github.com/solo-io/solo-projects/issues/6859 + description: >- + Adds configuration options to pass through to Swagger UI's instance methods + as defined here: https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/configuration.md#instance-methods + Including options to fill the Authorization Bearer token if the user is logged in. diff --git a/changelog/v0.0.39/teams-page.yaml b/changelog/v0.0.39/teams-page.yaml new file mode 100644 index 00000000..ee5cb1d1 --- /dev/null +++ b/changelog/v0.0.39/teams-page.yaml @@ -0,0 +1,30 @@ +changelog: + - type: NEW_FEATURE + issueLink: https://github.com/solo-io/solo-projects/issues/6808 + description: >- + Adds the Teams page in to the GG version of the UI. + - type: NEW_FEATURE + issueLink: https://github.com/solo-io/solo-projects/issues/6809 + description: >- + Adds the Team Details page in to the GG version of the UI. + - type: NEW_FEATURE + issueLink: https://github.com/solo-io/solo-projects/issues/6810 + description: >- + Adds the Apps page in to the GG version of the UI. + - type: NEW_FEATURE + issueLink: https://github.com/solo-io/solo-projects/issues/6811 + description: >- + Adds the App Details page in to the GG version of the UI. + - type: NEW_FEATURE + issueLink: https://github.com/solo-io/solo-projects/issues/6812 + description: >- + Adds the Admin Subscriptions page in to the GG version of the UI. + - type: NEW_FEATURE + issueLink: https://github.com/solo-io/solo-projects/issues/6813 + description: >- + Adds the Admin Teams page in to the GG version of the UI. + - type: NEW_FEATURE + issueLink: https://github.com/solo-io/solo-projects/issues/6814 + description: >- + Adds in an edit button + modal to the Apps + Teams Details pages + (for both admins + non-admins) on the GG version of the UI. diff --git a/changelog/v0.0.39/update-hotfixes-for-gg-portal-demo-build.yaml b/changelog/v0.0.39/update-hotfixes-for-gg-portal-demo-build.yaml new file mode 100644 index 00000000..1b2497e2 --- /dev/null +++ b/changelog/v0.0.39/update-hotfixes-for-gg-portal-demo-build.yaml @@ -0,0 +1,7 @@ +changelog: + - type: FIX + issueLink: https://github.com/solo-io/solo-projects/issues/7038 + description: >- + Update metadata endpoint to allow updating of metadata. + Updated subscription modal to allow toggling of subscription status by admin. + Use a selection dropdown for rate limit units to not allow invalid values. diff --git a/projects/ui/.storybook/preview-head.html b/projects/ui/.storybook/preview-head.html new file mode 100644 index 00000000..9f1c1e22 --- /dev/null +++ b/projects/ui/.storybook/preview-head.html @@ -0,0 +1,3 @@ + diff --git a/projects/ui/package.json b/projects/ui/package.json index 17e5eeb8..98fa4534 100644 --- a/projects/ui/package.json +++ b/projects/ui/package.json @@ -1,5 +1,5 @@ { - "name": "gloo-platform-portal-ui", + "name": "dev-portal-starter", "private": true, "version": "0.0.13", "type": "module", @@ -25,6 +25,7 @@ "@mantine/hooks": "^6.0.6", "@types/color": "^3.0.6", "color": "^4.2.3", + "highlight.js": "^11.10.0", "mobx": "^6.8.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -78,5 +79,6 @@ "braces": "^3.0.3", "ws": "^8.17.1", "axios": "^1.7.4" - } + }, + "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/projects/ui/public/pages/gg-logo.png b/projects/ui/public/pages/gg-logo.png new file mode 100644 index 00000000..053935de Binary files /dev/null and b/projects/ui/public/pages/gg-logo.png differ diff --git a/projects/ui/public/pages/html-example.html b/projects/ui/public/pages/html-example.html new file mode 100644 index 00000000..dfc978a0 --- /dev/null +++ b/projects/ui/public/pages/html-example.html @@ -0,0 +1,44 @@ + + + + + +

Example HTML Page

+ +

Section 1

+ + + +

Section 2

+ +

Any HTML content can go here.

+ + Here is an image: +
+ Gloo Gateway Logo + +
+ + + diff --git a/projects/ui/public/pages/markdown-example.md b/projects/ui/public/pages/markdown-example.md new file mode 100644 index 00000000..02388cc7 --- /dev/null +++ b/projects/ui/public/pages/markdown-example.md @@ -0,0 +1,37 @@ +# Example Markdown Page (#) + +This is a custom Markdown page test. + +## Section 1 (##) + +- Supports bullet points +- Supports bullet points + +### 1.1 (###) + +Testing that **Bold works** here. + +#### 1.1.1 (####) + +Testing that _Italics works_ here. + +##### 1.1.1 (#####) + +Links work: [www.solo.io](www.solo.io) + +1. Numbered lists work +2. test +3. test + +Images work: + +![Gloo Gateway Logo](/pages/gg-logo.png) + +And code does too: + +```ts +const x = 123; +function y() { + return x + 5; +} +``` diff --git a/projects/ui/src/Apis/api-types.ts b/projects/ui/src/Apis/api-types.ts index f342a2f6..ddbfb1f8 100644 --- a/projects/ui/src/Apis/api-types.ts +++ b/projects/ui/src/Apis/api-types.ts @@ -2,6 +2,8 @@ // Gloo Mesh Gateway Types // +import { getEnumValues } from "../Utility/utility"; + type RateLimitPolicy = { unit: "UNKNOWN" | "SECOND" | "MINUTE" | "HOUR" | "DAY"; requestsPerUnit: number; @@ -100,12 +102,23 @@ export type App = { deletedAt: string; updatedAt: string; id: string; - idpClientId: string; - idpClientName: string; - idpClientSecret: string; + idpClientId?: string; + idpClientName?: string; + idpClientSecret?: string; name: string; description: string; teamId: string; + metadata?: AppMetadata; +}; + +export type ApiKey = { + id: string; + createdAt: string; + updatedAt: string; + deletedAt: string; + apiKey: string; + name: string; + metadata: Record; }; export type Team = { @@ -145,6 +158,7 @@ export type Subscription = { id: string; requestedAt: string; updatedAt: string; + metadata?: SubscriptionMetadata; }; export type ApiVersionExtended = ApiVersion & { @@ -152,6 +166,53 @@ export type ApiVersionExtended = ApiVersion & { apiProductName: string; }; +export type OauthCredential = { + id: string; + idpClientId: string; + idpClientSecret?: string; + idpClientName: string; +}; + +export enum RateLimitUnit { + "UNKNOWN", + "SECOND", + "MINUTE", + "HOUR", + "DAY", + "MONTH", + "YEAR", +} +// This list of units is used both for the type and for the dropdown in the UI. +export const rateLimitUnitOptions = getEnumValues(RateLimitUnit).map( + (unit) => ({ + value: RateLimitUnit[unit], + label: RateLimitUnit[unit], + }) +); + +export type RateLimit = { + requestsPerUnit: string; + unit: string; +}; + +export type SubscriptionMetadata = { + createdAt?: string; + customMetadata: Record; + deletedAt?: string; + id: string; + rateLimit: RateLimit; + updatedAt: string; +}; + +export type AppMetadata = { + createdAt?: string; + customMetadata: Record; + deletedAt?: string; + id: string; + rateLimit: RateLimit; + updatedAt: string; +}; + // // Shared Types // @@ -160,8 +221,8 @@ export type User = { name: string; email: string; username: string; - // TODO: Once auth is working, check if we can get admin info here and update the areas that use admin endpoints (e.g. subscriptions areas). - // admin: string; + // isAdmin may be undefined on older versions of the gg portal server. + isAdmin?: string; }; /** diff --git a/projects/ui/src/Apis/gg_hooks.ts b/projects/ui/src/Apis/gg_hooks.ts index 9a83abf7..979b6df0 100644 --- a/projects/ui/src/Apis/gg_hooks.ts +++ b/projects/ui/src/Apis/gg_hooks.ts @@ -4,11 +4,14 @@ import useSWRMutation from "swr/mutation"; import { AuthContext } from "../Context/AuthContext"; import { omitErrorMessageResponse } from "../Utility/utility"; import { + ApiKey, ApiProductDetails, ApiProductSummary, ApiVersion, App, Member, + OauthCredential, + RateLimit, Subscription, SubscriptionStatus, SubscriptionsListError, @@ -19,15 +22,15 @@ import { import { fetchJSON, useMultiSwrWithAuth, useSwrWithAuth } from "./utility"; // -// Queries +// region Queries // -// User +// region User export function useGetCurrentUser() { return useSwrWithAuth("/me"); } -// Apps +// region Apps + API Keys + OAuth export function useListAppsForTeam(team: Team) { return useSwrWithAuth(`/teams/${team.id}/apps`); } @@ -53,11 +56,16 @@ export function useListFlatAppsForTeamsOmitErrors(teams: Team[]) { return { ...swrRes, data }; } export function useGetAppDetails(id?: string) { - return useSwrWithAuth(`/apps/${id}`); + return useSwrWithAuth(`/apps/${id}`, id ?? null); +} +export function useListApiKeysForApp(appId: string) { + return useSwrWithAuth(`/apps/${appId}/api-keys`); +} +export function useGetOauthCredentialsForApp(appId: string) { + return useSwrWithAuth(`/apps/${appId}/oauth-credentials`); } -// Teams -const TEAMS_SWR_KEY = "teams"; +// region Teams export function useListTeams() { return useSwrWithAuth(`/teams`); } @@ -68,7 +76,7 @@ export function useGetTeamDetails(id?: string) { return useSwrWithAuth(`/teams/${id}`); } -// Api Products +// region API Products export function useListApiProducts() { return useSwrWithAuth("/api-products"); } @@ -79,7 +87,7 @@ export function useGetApiProductVersions(id?: string) { return useSwrWithAuth(`/api-products/${id}/versions`); } -// Subscriptions +// region Subscriptions // this is an admin endpoint export function useListSubscriptionsForStatus(status: SubscriptionStatus) { const swrResponse = useSwrWithAuth( @@ -93,9 +101,11 @@ export function useListSubscriptionsForStatus(status: SubscriptionStatus) { }, [swrResponse]); return swrResponse; } -export function useListSubscriptionsForApp(appId: string) { +export function useListSubscriptionsForApp(appId: string | null) { + const endpoint = `/apps/${appId}/subscriptions`; const swrResponse = useSwrWithAuth( - `/apps/${appId}/subscriptions` + endpoint, + appId === null ? null : endpoint ); useEffect(() => { if (isSubscriptionsListError(swrResponse.data)) { @@ -115,7 +125,7 @@ export function useListSubscriptionsForApps(apps: App[]) { } // -// Mutations +// region Mutations // const getLatestAuthHeaders = (latestAccessToken: string | undefined) => { @@ -129,27 +139,25 @@ const getLatestAuthHeaders = (latestAccessToken: string | undefined) => { type MutationWithArgs = { arg: T }; // ------------------------ // -// Create Team +// region Create Team type CreateTeamParams = MutationWithArgs<{ name: string; description: string }>; export function useCreateTeamMutation() { const { latestAccessToken } = useContext(AuthContext); - const { mutate } = useSWRConfig(); const createTeam = async (url: string, { arg }: CreateTeamParams) => { const res = await fetchJSON(url, { method: "POST", headers: getLatestAuthHeaders(latestAccessToken), body: JSON.stringify(arg), }); - mutate(TEAMS_SWR_KEY); return res as Team; }; return useSWRMutation(`/teams`, createTeam); } // ------------------------ // -// Create Team Member +// region Create Team Member type AddTeamMemberParams = MutationWithArgs<{ email: string; teamId: string }>; @@ -169,7 +177,7 @@ export function useAddTeamMemberMutation() { } // ------------------------ // -// Remove Team Member +// region Remove Team Member type AdminRemoveTeamMemberParams = MutationWithArgs<{ teamId: string; @@ -194,7 +202,7 @@ export function useRemoveTeamMemberMutation() { } // ------------------------ // -// Create App +// region Create App type CreateAppParams = MutationWithArgs<{ name: string; description: string }>; @@ -220,7 +228,7 @@ export function useCreateAppMutation(teamId: string | undefined) { } // ------------------------ // -// Update App +// region Update App type UpdateAppParams = MutationWithArgs<{ appId: string; @@ -247,7 +255,7 @@ export function useUpdateAppMutation() { } // ------------------------ // -// Update Team +// region Update Team type UpdateTeamParams = MutationWithArgs<{ teamId: string; @@ -265,14 +273,13 @@ export function useUpdateTeamMutation() { headers: getLatestAuthHeaders(latestAccessToken), body: JSON.stringify({ name: teamName, description: teamDescription }), }); - mutate(TEAMS_SWR_KEY); mutate(`/teams/${teamId}`); }; return useSWRMutation("update-team", updateTeam); } // ------------------------ // -// Create App and Subscription +// region Create App and Subscription type CreateAppAndSubscriptionParams = MutationWithArgs<{ appName: string; @@ -315,7 +322,7 @@ export function useCreateAppAndSubscriptionMutation() { } // ------------------------ // -// Create Subscription +// region Create Subscription type CreateSubscriptionParams = MutationWithArgs<{ apiProductId: string; @@ -344,7 +351,7 @@ export function useCreateSubscriptionMutation(appId: string) { } // -------------------------------- // -// (Admin) Approve/Reject Subscription +// region (Admin) Approve/Reject Subscription type UpdateSubscriptionParams = MutationWithArgs<{ subscription: Subscription; @@ -387,7 +394,7 @@ export function useAdminRejectSubscriptionMutation() { } // -------------------------------- // -// Delete Subscription +// region Delete Subscription export function useDeleteSubscriptionMutation() { const { latestAccessToken } = useContext(AuthContext); @@ -406,37 +413,182 @@ export function useDeleteSubscriptionMutation() { } // -------------------------------- // -// Delete Team +// region Delete Team type DeleteTeamParams = MutationWithArgs<{ teamId: string }>; export function useDeleteTeamMutation() { const { latestAccessToken } = useContext(AuthContext); - const { mutate } = useSWRConfig(); const deleteTeam = async (_: string, { arg }: DeleteTeamParams) => { await fetchJSON(`/teams/${arg.teamId}`, { method: "DELETE", headers: getLatestAuthHeaders(latestAccessToken), }); - mutate(TEAMS_SWR_KEY); }; return useSWRMutation(`delete-team`, deleteTeam); } // -------------------------------- // -// Delete App +// region Delete App type DeleteAppParams = MutationWithArgs<{ appId: string }>; export function useDeleteAppMutation() { const { latestAccessToken } = useContext(AuthContext); - const { mutate } = useSWRConfig(); const deleteApp = async (_: string, { arg }: DeleteAppParams) => { await fetchJSON(`/apps/${arg.appId}`, { method: "DELETE", headers: getLatestAuthHeaders(latestAccessToken), }); - mutate(TEAMS_SWR_KEY); }; return useSWRMutation(`delete-team`, deleteApp); } + +// -------------------------------- // +// region Create API Key + +type CreateApiKeyParams = MutationWithArgs<{ apiKeyName: string }>; + +export function useCreateApiKeyMutation(appId: string) { + const { latestAccessToken } = useContext(AuthContext); + const createApiKey = async (_: string, { arg }: CreateApiKeyParams) => { + return await fetchJSON(`/apps/${appId}/api-keys`, { + method: "POST", + headers: getLatestAuthHeaders(latestAccessToken), + body: JSON.stringify(arg), + }); + }; + return useSWRMutation( + `/apps/${appId}/api-keys`, + createApiKey + ); +} + +// -------------------------------- // +// region Delete API Key + +type DeleteApiKeyParams = MutationWithArgs<{ apiKeyId: string }>; + +export function useDeleteApiKeyMutation(appId: string) { + const { latestAccessToken } = useContext(AuthContext); + const deleteApiKey = async (_: string, { arg }: DeleteApiKeyParams) => { + await fetchJSON(`/api-keys/${arg.apiKeyId}`, { + method: "DELETE", + headers: getLatestAuthHeaders(latestAccessToken), + }); + }; + return useSWRMutation(`/apps/${appId}/api-keys`, deleteApiKey); +} + +// -------------------------------- // +// region Create OAuth Client + +export function useCreateOAuthMutation(appId: string) { + const { latestAccessToken } = useContext(AuthContext); + const createOAuth = async () => { + return (await fetchJSON(`/apps/${appId}/oauth-credentials`, { + method: "POST", + headers: getLatestAuthHeaders(latestAccessToken), + })) as OauthCredential; + }; + return useSWRMutation(`/apps/${appId}/oauth-credentials`, createOAuth); +} + +// -------------------------------- // +// region Delete OAuth Client + +type DeleteOAuthParams = MutationWithArgs<{ credentialId: string }>; + +export function useDeleteOAuthMutation(appId: string) { + const { latestAccessToken } = useContext(AuthContext); + const deleteOAuth = async (_: string, { arg }: DeleteOAuthParams) => { + await fetchJSON(`/oauth-credentials/${arg.credentialId}`, { + method: "DELETE", + headers: getLatestAuthHeaders(latestAccessToken), + }); + }; + return useSWRMutation(`/apps/${appId}/oauth-credentials`, deleteOAuth); +} + +// -------------------------------- // +// region Create User + +export function useCreateUserMutation() { + const { latestAccessToken } = useContext(AuthContext); + const createUser = async () => { + await fetchJSON(`/me`, { + method: "PUT", + headers: getLatestAuthHeaders(latestAccessToken), + }); + }; + return useSWRMutation(`create-user`, createUser); +} + +// -------------------------------- // +// region (Admin) Upsert App Metadata + +export type UpsertAppMetadataParams = MutationWithArgs<{ + appId: string; + rateLimit?: RateLimit; + customMetadata?: Record; +}>; + +export function useUpsertAppMetadataMutation() { + const { latestAccessToken } = useContext(AuthContext); + const { mutate } = useSWRConfig(); + const fetcher = async (_: string, { arg }: UpsertAppMetadataParams) => { + const req: Record = { appId: arg.appId }; + if (arg.customMetadata !== undefined) { + req.customMetadata = arg.customMetadata; + } + if (arg.rateLimit !== undefined) { + req.rateLimit = arg.rateLimit; + } + await fetchJSON(`/apps/${arg.appId}/metadata`, { + method: "POST", + headers: getLatestAuthHeaders(latestAccessToken), + body: JSON.stringify(req), + }); + mutate(TEAM_APPS_SWR_KEY); + mutate(`/apps/${arg.appId}`); + }; + return useSWRMutation(`upsert-app-metadata`, fetcher); +} + +// -------------------------------- // +// region (Admin) Upsert Subscription Metadata + +export type UpsertSubscriptionMetadataParams = MutationWithArgs<{ + subscription: Subscription; + customMetadata?: Record; + rateLimit?: RateLimit; +}>; + +export function useUpsertSubscriptionMetadataMutation() { + const { latestAccessToken } = useContext(AuthContext); + const { mutate } = useSWRConfig(); + const fetcher = async ( + _: string, + { arg }: UpsertSubscriptionMetadataParams + ) => { + const req: Record = { subscriptionId: arg.subscription.id }; + if (arg.customMetadata !== undefined) { + req.customMetadata = arg.customMetadata; + } + if (arg.rateLimit !== undefined) { + req.rateLimit = arg.rateLimit; + } + await fetchJSON(`/subscriptions/${arg.subscription.id}/metadata`, { + method: "POST", + headers: getLatestAuthHeaders(latestAccessToken), + body: JSON.stringify(arg), + }); + // We use several queries to get subscriptions across different pages. + // Doing all the mutations here so we don't miss anything. + mutate(`/apps/${arg.subscription.applicationId}/subscriptions`); + mutate(`/subscriptions?status=${SubscriptionStatus.APPROVED}`); + mutate(`/subscriptions?status=${SubscriptionStatus.PENDING}`); + mutate(APP_SUBS_SWR_KEY); + }; + return useSWRMutation(`upsert-subscription-metadata`, fetcher); +} diff --git a/projects/ui/src/Apis/utility.ts b/projects/ui/src/Apis/utility.ts index ff180ea6..e37329bd 100644 --- a/projects/ui/src/Apis/utility.ts +++ b/projects/ui/src/Apis/utility.ts @@ -69,7 +69,7 @@ export async function fetchJSON(...args: Parameters) { */ export const useSwrWithAuth = ( path: string, - swrKey?: string, + swrKey?: string | null, config?: Parameters>[2] ) => { const { latestAccessToken } = useContext(AuthContext); diff --git a/projects/ui/src/Components/AdminApps/AdminAppsPage.tsx b/projects/ui/src/Components/AdminApps/AdminAppsPage.tsx new file mode 100644 index 00000000..2e5b9701 --- /dev/null +++ b/projects/ui/src/Components/AdminApps/AdminAppsPage.tsx @@ -0,0 +1,5 @@ +import { AppsPage } from "../Apps/AppsPage"; + +// The admin apps page reuses the standard apps page components, +// which have modifications to support admin functions. +export default AppsPage; diff --git a/projects/ui/src/Components/ApiDetails/gloo-gateway-components/ApiProductDetailsPageBody.tsx b/projects/ui/src/Components/ApiDetails/gloo-gateway-components/ApiProductDetailsPageBody.tsx index bdabf8e8..0ea6fdd2 100644 --- a/projects/ui/src/Components/ApiDetails/gloo-gateway-components/ApiProductDetailsPageBody.tsx +++ b/projects/ui/src/Components/ApiDetails/gloo-gateway-components/ApiProductDetailsPageBody.tsx @@ -1,4 +1,4 @@ -import { Tabs } from "@mantine/core"; +import { Code, Tabs } from "@mantine/core"; import { useEffect, useState } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { @@ -7,6 +7,7 @@ import { ApiVersionSchema, } from "../../../Apis/api-types"; import { ContentWidthDiv } from "../../../Styles/ContentWidthHelpers"; +import { EmptyData } from "../../Common/EmptyData"; import DocsTabContent from "./DocsTab/DocsTabContent"; import SchemaTabContent from "./SchemaTab/SchemaTabContent"; @@ -62,9 +63,7 @@ export function ApiProductDetailsPageBody({ */} Spec - {includesDocumentation && ( - Docs - )} + Docs {/* @@ -72,17 +71,27 @@ export function ApiProductDetailsPageBody({ */} - {includesDocumentation && ( - + + {includesDocumentation ? ( - - )} + ) : ( + + + You may add documentation for this API in the{" "} + + spec.versions[your-version].openapiMetadata.description + {" "} + field of this ApiProduct resource. Markdown is + supported. + + + )} + ); diff --git a/projects/ui/src/Components/ApiDetails/gloo-gateway-components/ApiProductDetailsPageHeading.tsx b/projects/ui/src/Components/ApiDetails/gloo-gateway-components/ApiProductDetailsPageHeading.tsx index 8a2b832e..f474541c 100644 --- a/projects/ui/src/Components/ApiDetails/gloo-gateway-components/ApiProductDetailsPageHeading.tsx +++ b/projects/ui/src/Components/ApiDetails/gloo-gateway-components/ApiProductDetailsPageHeading.tsx @@ -1,4 +1,5 @@ import { Box, Flex, Select } from "@mantine/core"; +import { useState } from "react"; import toast from "react-hot-toast"; import { ApiProductSummary, @@ -6,12 +7,14 @@ import { ApiVersionSchema, } from "../../../Apis/api-types"; import { Icon } from "../../../Assets/Icons"; +import { useIsLoggedIn } from "../../../Context/AuthContext"; import { FormModalStyles } from "../../../Styles/shared/FormModalStyles"; import { useGetImageURL } from "../../../Utility/custom-image-utility"; import { downloadFile, filterMetadataToDisplay, } from "../../../Utility/utility"; +import NewSubscriptionModal from "../../Apps/Details/Modals/NewSubscriptionModal"; import { BannerHeading } from "../../Common/Banner/BannerHeading"; import { BannerHeadingTitle } from "../../Common/Banner/BannerHeadingTitle"; import { Button } from "../../Common/Button"; @@ -31,8 +34,8 @@ const ApiProductDetailsPageHeading = ({ onSelectedApiVersionChange: (newVersionId: string | null) => void; apiVersionSpec: ApiVersionSchema | undefined; }) => { - // const { isLoggedIn } = useContext(AuthContext); - // const [showSubscribeModal, setShowSubscribeModal] = useState(false); + const isLoggedIn = useIsLoggedIn(); + const [showSubscribeModal, setShowSubscribeModal] = useState(false); const downloadApiSpec = () => { if (!selectedApiVersion?.apiSpec) { @@ -102,14 +105,12 @@ const ApiProductDetailsPageHeading = ({ /> )} - {/* - // Note: Removing sections for GGv2 demo. {isLoggedIn && ( - )} */} + )} - + {!isAdmin && ( + + + + )} } breadcrumbItems={[{ label: "Home", link: "/" }, { label: "Apps" }]} diff --git a/projects/ui/src/Components/Apps/Details/ApiKeysSection/AddApiKeysSubSection.tsx b/projects/ui/src/Components/Apps/Details/ApiKeysSection/AddApiKeysSubSection.tsx new file mode 100644 index 00000000..4883b9c7 --- /dev/null +++ b/projects/ui/src/Components/Apps/Details/ApiKeysSection/AddApiKeysSubSection.tsx @@ -0,0 +1,93 @@ +import { Box, Input } from "@mantine/core"; +import { FormEvent, useEffect, useRef, useState } from "react"; +import toast from "react-hot-toast"; +import { di } from "react-magnetic-di"; +import { App } from "../../../../Apis/api-types"; +import { useCreateApiKeyMutation } from "../../../../Apis/gg_hooks"; +import { DetailsPageStyles } from "../../../../Styles/shared/DetailsPageStyles"; +import { Accordion } from "../../../Common/Accordion"; +import { Button } from "../../../Common/Button"; +import ViewCreatedItemModal from "../Modals/ViewCreatedItemModal"; + +const AddApiKeysSubSection = ({ + open, + onClose, + app, +}: { + open: boolean; + onClose: () => void; + app: App; +}) => { + di(useCreateApiKeyMutation); + + // + // Form Fields + // + const [formAppName, setFormAppName] = useState(""); + + // + // Form + // + const formRef = useRef(null); + const isFormDisabled = !open || !formAppName; + useEffect(() => { + // The form resets here when `open` changes. + setFormAppName(""); + }, [open]); + + // + // Form Submit + // + const [createdApiKey, setCreatedApiKey] = useState(""); + const { trigger: createApiKey } = useCreateApiKeyMutation(app.id); + const onSubmit = async (e?: FormEvent) => { + e?.preventDefault(); + const isValid = formRef.current?.reportValidity(); + if (!isValid || isFormDisabled) { + return; + } + const res = await toast.promise(createApiKey({ apiKeyName: formAppName }), { + error: "There was an error creating the API Key.", + loading: "Creating the API Key...", + success: "Created the API Key!", + }); + onClose(); + setCreatedApiKey(res.apiKey); + }; + + // + // Render + // + return ( + <> + + + + setFormAppName(e.target.value)} + /> + + + + + setCreatedApiKey("")} + /> + + ); +}; + +export default AddApiKeysSubSection; diff --git a/projects/ui/src/Components/Apps/Details/ApiKeysSection/AppApiKeysSection.tsx b/projects/ui/src/Components/Apps/Details/ApiKeysSection/AppApiKeysSection.tsx new file mode 100644 index 00000000..4beafb40 --- /dev/null +++ b/projects/ui/src/Components/Apps/Details/ApiKeysSection/AppApiKeysSection.tsx @@ -0,0 +1,131 @@ +import { Box, Flex } from "@mantine/core"; +import { useMemo, useState } from "react"; +import { di } from "react-magnetic-di"; +import { APIKey, App } from "../../../../Apis/api-types"; +import { useListApiKeysForApp } from "../../../../Apis/gg_hooks"; +import { useIsAdmin } from "../../../../Context/AuthContext"; +import { DetailsPageStyles } from "../../../../Styles/shared/DetailsPageStyles"; +import { GridCardStyles } from "../../../../Styles/shared/GridCard.style"; +import { UtilityStyles } from "../../../../Styles/shared/Utility.style"; +import { formatDateToMMDDYYYY } from "../../../../Utility/utility"; +import { Button } from "../../../Common/Button"; +import CustomPagination, { + pageOptions, + useCustomPagination, +} from "../../../Common/CustomPagination"; +import { EmptyData } from "../../../Common/EmptyData"; +import { Loading } from "../../../Common/Loading"; +import Table from "../../../Common/Table"; +import ToggleAddButton from "../../../Common/ToggleAddButton"; +import ConfirmDeleteApiKeyModal from "../Modals/ConfirmDeleteApiKeyModal"; +import AddApiKeysSubSection from "./AddApiKeysSubSection"; + +const AppApiKeysSection = ({ app }: { app: App }) => { + di(useIsAdmin, useListApiKeysForApp); + const isAdmin = useIsAdmin(); + const { data: apiKeys } = useListApiKeysForApp(app.id); + const [showAddApiKeySubSection, setShowAddApiKeySubSection] = useState(false); + + const customPaginationData = useCustomPagination( + apiKeys ?? [], + pageOptions.table + ); + const { paginatedData } = customPaginationData; + + const [confirmDeleteApiKey, setConfirmDeleteApiKey] = useState(); + + const rows = useMemo(() => { + return paginatedData?.map((apiKey) => { + return ( + ( + + {apiKey.name} + {formatDateToMMDDYYYY(new Date(apiKey.createdAt))} + + + + + + + ) ?? [] + ); + }); + }, [paginatedData]); + + if (apiKeys === undefined) { + return ; + } + return ( + + + API Keys + {!isAdmin && ( + + setShowAddApiKeySubSection(!showAddApiKeySubSection) + } + /> + )} + + setShowAddApiKeySubSection(false)} + /> + {!apiKeys?.length ? ( + + + + ) : ( + + + + + + + + + + + + {rows} + + + + + +
NameCreated + + Delete + +
+ + + +
+
+
+
+ )} + setConfirmDeleteApiKey(undefined)} + /> +
+ ); +}; + +export default AppApiKeysSection; diff --git a/projects/ui/src/Components/Apps/Details/ApiSubscriptionsSection/AppApiSubscriptionsSection.tsx b/projects/ui/src/Components/Apps/Details/ApiSubscriptionsSection/AppApiSubscriptionsSection.tsx index 0dc43e45..5d831402 100644 --- a/projects/ui/src/Components/Apps/Details/ApiSubscriptionsSection/AppApiSubscriptionsSection.tsx +++ b/projects/ui/src/Components/Apps/Details/ApiSubscriptionsSection/AppApiSubscriptionsSection.tsx @@ -35,7 +35,7 @@ const AppApiSubscriptionsSection = ({ }, [subscriptions, app]); return ( - + API Subscriptions {subscriptions.length === 0 && ( - - + + )} diff --git a/projects/ui/src/Components/Apps/Details/AppDetailsPageContent.tsx b/projects/ui/src/Components/Apps/Details/AppDetailsPageContent.tsx index 11575485..a08f6c17 100644 --- a/projects/ui/src/Components/Apps/Details/AppDetailsPageContent.tsx +++ b/projects/ui/src/Components/Apps/Details/AppDetailsPageContent.tsx @@ -1,25 +1,38 @@ -import { Box, Flex, Loader } from "@mantine/core"; +import { Box, Flex, Loader, Tooltip } from "@mantine/core"; +import { useMemo } from "react"; import { di } from "react-magnetic-di"; +import { NavLink } from "react-router-dom"; import { App } from "../../../Apis/api-types"; -import { useListSubscriptionsForApp } from "../../../Apis/gg_hooks"; +import { + useListSubscriptionsForApp, + useListTeams, +} from "../../../Apis/gg_hooks"; +import { Icon } from "../../../Assets/Icons"; +import { UtilityStyles } from "../../../Styles/shared/Utility.style"; +import { + AppAuthMethod, + defaultAppAuthMethod, +} from "../../../user_variables.tmplr"; +import { getTeamDetailsLink } from "../../../Utility/link-builders"; import { BannerHeading } from "../../Common/Banner/BannerHeading"; import { BannerHeadingTitle } from "../../Common/Banner/BannerHeadingTitle"; import { PageContainer } from "../../Common/PageContainer"; +import AppApiKeysSection from "./ApiKeysSection/AppApiKeysSection"; import AppApiSubscriptionsSection from "./ApiSubscriptionsSection/AppApiSubscriptionsSection"; import AppAuthenticationSection from "./AuthenticationSection/AppAuthenticationSection"; import EditAppButtonWithModal from "./EditAppButtonWithModal"; +import AppMetadataSection from "./MetadataSection/AppMetadataSection"; export const AppDetailsPageContent = ({ app }: { app: App }) => { - di(useListSubscriptionsForApp); + di(useListSubscriptionsForApp, useListTeams); const { isLoading: isLoadingSubscriptions, data: subscriptions } = useListSubscriptionsForApp(app.id); - // Mock data for testing - // app.idpClientId = "4df81266-f855-466d-8ded-699056780850"; - // app.idpClientName = "test-idp"; - // app.idpClientSecret = "hidden"; - const appHasOAuthClient = - app.idpClientId && app.idpClientName && app.idpClientSecret; + const { data: teams } = useListTeams(); + const team = useMemo( + () => teams?.find((t) => t.id === app.teamId), + [teams, app] + ); return ( @@ -27,6 +40,7 @@ export const AppDetailsPageContent = ({ app }: { app: App }) => { title={ } stylingTweaks={{ fontSize: "32px", lineHeight: "36px", @@ -34,7 +48,31 @@ export const AppDetailsPageContent = ({ app }: { app: App }) => { additionalInfo={} /> } - description={app.description} + description={ + <> + {!!team && !!team.name && ( + + + + + + {team?.name} + + + + + )} + {app.description} + + } breadcrumbItems={[ { label: "Home", link: "/" }, { label: "Apps", link: "/apps" }, @@ -43,7 +81,18 @@ export const AppDetailsPageContent = ({ app }: { app: App }) => { /> - {appHasOAuthClient && } + {(defaultAppAuthMethod === AppAuthMethod[AppAuthMethod.ALL] || + defaultAppAuthMethod === AppAuthMethod[AppAuthMethod.OAUTH]) && ( + + )} + + + + {(defaultAppAuthMethod === AppAuthMethod[AppAuthMethod.ALL] || + defaultAppAuthMethod === AppAuthMethod[AppAuthMethod.API_KEY]) && ( + + )} + {isLoadingSubscriptions || subscriptions === undefined ? ( ) : ( diff --git a/projects/ui/src/Components/Apps/Details/AuthenticationSection/AppAuthenticationSection.tsx b/projects/ui/src/Components/Apps/Details/AuthenticationSection/AppAuthenticationSection.tsx index 0f16b39a..5ac66e1c 100644 --- a/projects/ui/src/Components/Apps/Details/AuthenticationSection/AppAuthenticationSection.tsx +++ b/projects/ui/src/Components/Apps/Details/AuthenticationSection/AppAuthenticationSection.tsx @@ -1,35 +1,136 @@ -import { Box } from "@mantine/core"; -import { App } from "../../../../Apis/api-types"; +import { Box, Flex } from "@mantine/core"; +import { useEffect, useState } from "react"; +import { App, OauthCredential } from "../../../../Apis/api-types"; +import { useGetOauthCredentialsForApp } from "../../../../Apis/gg_hooks"; import { DetailsPageStyles } from "../../../../Styles/shared/DetailsPageStyles"; import { GridCardStyles } from "../../../../Styles/shared/GridCard.style"; +import { Button } from "../../../Common/Button"; import { DataPairPill } from "../../../Common/DataPairPill"; +import { Loading } from "../../../Common/Loading"; +import ConfirmCreateOAuthModal from "../Modals/ConfirmCreateOAuthModal"; +import ConfirmDeleteOAuthModal from "../Modals/ConfirmDeleteOAuthModal"; +import ViewCreatedItemModal from "../Modals/ViewCreatedItemModal"; const AppAuthenticationSection = ({ app }: { app: App }) => { + const { data: fetchedClientCredentials, error: oauthError } = + useGetOauthCredentialsForApp(app.id); + + const [credentialIdToDelete, setCredentialIdToDelete] = useState(""); + const [showConfirmCreateCredentials, setShowConfirmCreateCredentials] = + useState(false); + + // We split out the `credentialsToShow` here from the `fetchedClientCredentials` + // so that we can set it to the response when the client is created. + const [credentialsToShow, setCredentialsToShow] = useState< + OauthCredential | undefined + >(undefined); + + useEffect(() => { + if (fetchedClientCredentials === undefined) { + return; + } + if (credentialsToShow === undefined) { + setCredentialsToShow(fetchedClientCredentials); + } + }, [fetchedClientCredentials]); + + // + // region Render + // return ( Authentication - + OAuth Client - - Client ID: - - {app.idpClientId} - - {/* - // Designs show this "hidden" field, but we have the value. - // TODO: Figure out what to show here. - - */} - - + {!!oauthError ? ( + + + + ) : credentialsToShow === undefined ? ( + + + + + + ) : ( + <> + + Client ID: + + {credentialsToShow.idpClientId} + + + + + + + + )} + {/* + + // region Modals + */} + setShowConfirmCreateCredentials(false)} + onCreatedClient={(newCredentials) => { + setCredentialsToShow(newCredentials); + }} + open={showConfirmCreateCredentials} + /> + setCredentialIdToDelete("")} + open={credentialIdToDelete !== ""} + /> + { + if (credentialsToShow) { + setCredentialsToShow({ + ...credentialsToShow, + idpClientSecret: "", + }); + } + }} + additionalContentTop={ + <> + + Client ID + + + {credentialsToShow?.idpClientId ?? ""} + + + } + /> ); }; diff --git a/projects/ui/src/Components/Apps/Details/MetadataSection/AppMetadataSection.tsx b/projects/ui/src/Components/Apps/Details/MetadataSection/AppMetadataSection.tsx new file mode 100644 index 00000000..8fc6b09b --- /dev/null +++ b/projects/ui/src/Components/Apps/Details/MetadataSection/AppMetadataSection.tsx @@ -0,0 +1,27 @@ +import { Box } from "@mantine/core"; +import { App } from "../../../../Apis/api-types"; +import { DetailsPageStyles } from "../../../../Styles/shared/DetailsPageStyles"; +import { GridCardStyles } from "../../../../Styles/shared/GridCard.style"; +import { MetadataDisplay } from "../../../../Utility/AdminUtility/MetadataDisplay"; + +const AppMetadataSection = ({ app }: { app: App }) => { + // + // region Render + // + return ( + + Metadata + + + + + + + ); +}; + +export default AppMetadataSection; diff --git a/projects/ui/src/Components/Apps/Details/Modals/ConfirmCreateOAuthModal.tsx b/projects/ui/src/Components/Apps/Details/Modals/ConfirmCreateOAuthModal.tsx new file mode 100644 index 00000000..f0411af9 --- /dev/null +++ b/projects/ui/src/Components/Apps/Details/Modals/ConfirmCreateOAuthModal.tsx @@ -0,0 +1,63 @@ +import { Box, CloseButton, Flex } from "@mantine/core"; +import { FormEvent } from "react"; +import toast from "react-hot-toast"; +import { di } from "react-magnetic-di"; +import { OauthCredential } from "../../../../Apis/api-types"; +import { useCreateOAuthMutation } from "../../../../Apis/gg_hooks"; +import { FormModalStyles } from "../../../../Styles/shared/FormModalStyles"; +import { Button } from "../../../Common/Button"; + +const ConfirmCreateOAuthModal = ({ + appId, + open, + onClose, + onCreatedClient, +}: { + appId: string; + open: boolean; + onClose: () => void; + onCreatedClient: (newCredentials: OauthCredential) => void; +}) => { + di(useCreateOAuthMutation); + const { trigger: createOAuth } = useCreateOAuthMutation(appId); + const onConfirm = async (e?: FormEvent) => { + e?.preventDefault(); + const newlyCreatedCredentials = await toast.promise(createOAuth(), { + error: (e) => "There was an error creating OAuth client. " + e, + loading: "Creating the OAuth client...", + success: "Created the OAuth client!", + }); + onCreatedClient(newlyCreatedCredentials); + onClose(); + }; + + // + // Render + // + return ( + + +
+ Create OAuth Client + + Are you sure that you want to create an OAuth Client for this App? + +
+ +
+ + + + + + + +
+ ); +}; + +export default ConfirmCreateOAuthModal; diff --git a/projects/ui/src/Components/Apps/Details/Modals/ConfirmDeleteApiKeyModal.tsx b/projects/ui/src/Components/Apps/Details/Modals/ConfirmDeleteApiKeyModal.tsx new file mode 100644 index 00000000..4d01a8e8 --- /dev/null +++ b/projects/ui/src/Components/Apps/Details/Modals/ConfirmDeleteApiKeyModal.tsx @@ -0,0 +1,61 @@ +import { Box, CloseButton, Flex } from "@mantine/core"; +import { FormEvent } from "react"; +import toast from "react-hot-toast"; +import { di } from "react-magnetic-di"; +import { useDeleteApiKeyMutation } from "../../../../Apis/gg_hooks"; +import { FormModalStyles } from "../../../../Styles/shared/FormModalStyles"; +import { Button } from "../../../Common/Button"; + +const ConfirmDeleteApiKeyModal = ({ + apiKeyId, + appId, + open, + onClose, +}: { + apiKeyId: string; + appId: string; + open: boolean; + onClose: () => void; +}) => { + di(useDeleteApiKeyMutation); + const { trigger: deleteApiKey } = useDeleteApiKeyMutation(appId); + const onConfirm = async (e?: FormEvent) => { + e?.preventDefault(); + await toast.promise(deleteApiKey({ apiKeyId }), { + error: (e) => "There was an error deleting the API Key. " + e, + loading: "Deleting the API Key...", + success: "Deleted the API Key!", + }); + onClose(); + }; + + // + // Render + // + return ( + + +
+ Delete API Key + + Are you sure that you want to delete this API Key? + +
+ +
+ + + + + + + +
+ ); +}; + +export default ConfirmDeleteApiKeyModal; diff --git a/projects/ui/src/Components/Apps/Details/Modals/ConfirmDeleteOAuthModal.tsx b/projects/ui/src/Components/Apps/Details/Modals/ConfirmDeleteOAuthModal.tsx new file mode 100644 index 00000000..64c79629 --- /dev/null +++ b/projects/ui/src/Components/Apps/Details/Modals/ConfirmDeleteOAuthModal.tsx @@ -0,0 +1,61 @@ +import { Box, CloseButton, Flex } from "@mantine/core"; +import { FormEvent } from "react"; +import toast from "react-hot-toast"; +import { di } from "react-magnetic-di"; +import { useDeleteOAuthMutation } from "../../../../Apis/gg_hooks"; +import { FormModalStyles } from "../../../../Styles/shared/FormModalStyles"; +import { Button } from "../../../Common/Button"; + +const ConfirmDeleteOAuthModal = ({ + credentialId, + appId, + open, + onClose, +}: { + credentialId: string; + appId: string; + open: boolean; + onClose: () => void; +}) => { + di(useDeleteOAuthMutation); + const { trigger: deleteOAuth } = useDeleteOAuthMutation(appId); + const onConfirm = async (e?: FormEvent) => { + e?.preventDefault(); + await toast.promise(deleteOAuth({ credentialId }), { + error: (e) => "There was an error deleting the OAuth credentials. " + e, + loading: "Deleting the OAuth credentials...", + success: "Deleted the OAuth credentials!", + }); + onClose(); + }; + + // + // Render + // + return ( + + +
+ Delete OAuth Client + + Are you sure that you want to delete the OAuth Client for this App? + +
+ +
+ + + + + + + +
+ ); +}; + +export default ConfirmDeleteOAuthModal; diff --git a/projects/ui/src/Components/Apps/Details/Modals/NewSubscriptionModal.tsx b/projects/ui/src/Components/Apps/Details/Modals/NewSubscriptionModal.tsx index 0d29da86..0cf9646a 100644 --- a/projects/ui/src/Components/Apps/Details/Modals/NewSubscriptionModal.tsx +++ b/projects/ui/src/Components/Apps/Details/Modals/NewSubscriptionModal.tsx @@ -119,7 +119,10 @@ const NewSubscriptionModal = ({ await toast.promise( createSubscription({ apiProductId: formApiProductId }), { - error: "There was an error creating the subscription.", + error: (e) => + "message" in e + ? "Error: " + e.message + : "There was an error creating the subscription.", loading: "Creating the subscription...", success: "Created the subscription!", } diff --git a/projects/ui/src/Components/Apps/Details/Modals/ViewCreatedItemModal.tsx b/projects/ui/src/Components/Apps/Details/Modals/ViewCreatedItemModal.tsx new file mode 100644 index 00000000..1d11d60d --- /dev/null +++ b/projects/ui/src/Components/Apps/Details/Modals/ViewCreatedItemModal.tsx @@ -0,0 +1,123 @@ +import { Alert, Box, CloseButton, Flex } from "@mantine/core"; +import { useEffect, useState } from "react"; +import toast from "react-hot-toast"; +import { Icon } from "../../../../Assets/Icons"; +import { FormModalStyles } from "../../../../Styles/shared/FormModalStyles"; +import { copyToClipboard } from "../../../../Utility/utility"; +import { Button } from "../../../Common/Button"; + +const ViewCreatedItemModal = ({ + itemToCopyValue, + itemToCopyName, + createdObjectName, + additionalContentTop, + additionalContentBottom, + open, + onCloseModal, +}: { + itemToCopyValue: string; + itemToCopyName: string; + createdObjectName: string; + additionalContentTop?: React.ReactNode; + additionalContentBottom?: React.ReactNode; + open: boolean; + onCloseModal: () => void; +}) => { + const [hasCopiedItem, setHasCopiedItem] = useState(false); + + useEffect(() => { + // Reset state on close. + if (!open) { + setHasCopiedItem(false); + } + }, [open]); + + const handleOnClose = () => { + if (!hasCopiedItem) { + return; + } + onCloseModal(); + }; + + // + // Render + // + return ( + + + + + Created {createdObjectName} + + + {hasCopiedItem && ( + + )} + + + + {additionalContentTop} + + + + {itemToCopyName} + + + + } + title="Warning!" + color="orange" + > + This {itemToCopyName} value will not be available later. Please click + the {itemToCopyName} value to copy and secure this value now. + + + {additionalContentBottom} + + + + + + + ); +}; + +export default ViewCreatedItemModal; diff --git a/projects/ui/src/Components/Apps/PageContent/AppSummaryCards/AppSummaryGridCard.tsx b/projects/ui/src/Components/Apps/PageContent/AppSummaryCards/AppSummaryGridCard.tsx index decacafb..1e84dcc6 100644 --- a/projects/ui/src/Components/Apps/PageContent/AppSummaryCards/AppSummaryGridCard.tsx +++ b/projects/ui/src/Components/Apps/PageContent/AppSummaryCards/AppSummaryGridCard.tsx @@ -1,44 +1,62 @@ -import { Box } from "@mantine/core"; -import { useMemo } from "react"; +import { Box, Flex, Tooltip } from "@mantine/core"; +import { useState } from "react"; +import { NavLink } from "react-router-dom"; import { Icon } from "../../../../Assets/Icons"; +import { useIsAdmin } from "../../../../Context/AuthContext"; import { CardStyles } from "../../../../Styles/shared/Card.style"; import { GridCardStyles } from "../../../../Styles/shared/GridCard.style"; -import { getAppDetailsLink } from "../../../../Utility/link-builders"; +import { UtilityStyles } from "../../../../Styles/shared/Utility.style"; +import { MetadataDisplay } from "../../../../Utility/AdminUtility/MetadataDisplay"; +import { + getAppDetailsLink, + getTeamDetailsLink, +} from "../../../../Utility/link-builders"; +import { SubscriptionInfoCardStyles } from "../../../Common/SubscriptionsList/SubscriptionInfoCard/SubscriptionInfoCard.style"; import { AppWithTeam } from "../AppsList"; /** * MAIN COMPONENT **/ export function AppSummaryGridCard({ app }: { app: AppWithTeam }) { - // In the future banner images may come through API data. - // Even when that is the case, a default image may be desired - // for when no image is available. - // Further, you may have some clever trick for setting one of - // many default images. - const defaultCardImage = useMemo( - () => { - return "https://img.huffingtonpost.com/asset/57f2730f170000f70aac9059.jpeg?ops=scalefit_960_noupscale"; - }, - // Currently we don't need to change images unless the api itself has changed. - // Depending on the function within the memo, this may not always be the case. - [app.id] - ); + const isAdmin = useIsAdmin(); + const [isWide, setIsWide] = useState(false); return ( - - - placeholder - - - {app.name} - {app.description} - - - - - {app.team.name} - - - + +
+ + + {app.name} + + + + + + {app.team.name} + + + + + {app.description} + setIsWide(value)} + /> + + +
+ {!isAdmin && ( + + + DETAILS + + + )} +
); } diff --git a/projects/ui/src/Components/Apps/PageContent/AppSummaryCards/AppSummaryListCard.tsx b/projects/ui/src/Components/Apps/PageContent/AppSummaryCards/AppSummaryListCard.tsx deleted file mode 100644 index f2c0fd9e..00000000 --- a/projects/ui/src/Components/Apps/PageContent/AppSummaryCards/AppSummaryListCard.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Box, Flex } from "@mantine/core"; -import { NavLink } from "react-router-dom"; -import { Icon } from "../../../../Assets/Icons"; -import { CardStyles } from "../../../../Styles/shared/Card.style"; -import { ListCardStyles } from "../../../../Styles/shared/ListCard.style"; -import { getAppDetailsLink } from "../../../../Utility/link-builders"; -import { AppWithTeam } from "../AppsList"; - -/** - * MAIN COMPONENT - **/ -export function AppSummaryListCard({ app }: { app: AppWithTeam }) { - return ( - - - - - - - - {app.name} - {app.description} - - - - - - {app.team.name} - - - - - ); -} diff --git a/projects/ui/src/Components/Apps/PageContent/AppsFilter.tsx b/projects/ui/src/Components/Apps/PageContent/AppsFilter.tsx index 80e560ee..5ac6b624 100644 --- a/projects/ui/src/Components/Apps/PageContent/AppsFilter.tsx +++ b/projects/ui/src/Components/Apps/PageContent/AppsFilter.tsx @@ -1,15 +1,13 @@ import { Select, TextInput } from "@mantine/core"; -import { useContext, useMemo } from "react"; +import { useMemo } from "react"; import { Team } from "../../../Apis/api-types"; import { Icon } from "../../../Assets/Icons"; -import { AppContext } from "../../../Context/AppContext"; import { FilterStyles as Styles } from "../../../Styles/shared/Filters.style"; import { FilterType } from "../../../Utility/filter-utility"; import { AppliedFiltersSection, FiltrationProp, } from "../../Common/Filters/AppliedFiltersSection"; -import GridListToggle from "../../Common/GridListToggle"; export function AppsFilter({ filters, @@ -18,8 +16,6 @@ export function AppsFilter({ filters: FiltrationProp; teams: Team[]; }) { - const { preferGridView, setPreferGridView } = useContext(AppContext); - const addNameFilter = (evt: { target: { value: string } }) => { const displayName = evt.target.value; // Check for duplicate filters. @@ -98,11 +94,6 @@ export function AppsFilter({ placeholder="Team" /> - - setPreferGridView(!newIsList)} - isList={!preferGridView} - /> diff --git a/projects/ui/src/Components/Apps/PageContent/AppsList.tsx b/projects/ui/src/Components/Apps/PageContent/AppsList.tsx index 15e7ffed..cea6dd17 100644 --- a/projects/ui/src/Components/Apps/PageContent/AppsList.tsx +++ b/projects/ui/src/Components/Apps/PageContent/AppsList.tsx @@ -1,15 +1,14 @@ -import { useContext, useMemo } from "react"; +import { Box } from "@mantine/core"; +import { useMemo } from "react"; import { di } from "react-magnetic-di"; import { App, Team } from "../../../Apis/api-types"; import { useListAppsForTeams } from "../../../Apis/gg_hooks"; -import { AppContext } from "../../../Context/AppContext"; import { FilterPair, FilterType } from "../../../Utility/filter-utility"; import { omitErrorMessageResponse } from "../../../Utility/utility"; import { EmptyData } from "../../Common/EmptyData"; import { Loading } from "../../Common/Loading"; import { AppsPageStyles } from "../AppsPage.style"; import { AppSummaryGridCard } from "./AppSummaryCards/AppSummaryGridCard"; -import { AppSummaryListCard } from "./AppSummaryCards/AppSummaryListCard"; export type AppWithTeam = App & { team: Team }; @@ -23,7 +22,6 @@ export function AppsList({ nameFilter: string; }) { di(useListAppsForTeams); - const { preferGridView } = useContext(AppContext); // This is the App[][] of apps per team. const { isLoading, data: appsListPerTeam } = useListAppsForTeams(teams); // This is the flattened AppWithTeam[] that includes team information. @@ -88,23 +86,19 @@ export function AppsList({ if (isLoading) { return ; } + if (!appsList?.length) { + return ; + } if (!filteredAppsList.length) { - return ; + return ; } - if (preferGridView) { - return ( + return ( + {filteredAppsList.map((api) => ( ))} - ); - } - return ( -
- {filteredAppsList.map((app) => ( - - ))} -
+
); } diff --git a/projects/ui/src/Components/Common/Banner/BannerHeading.tsx b/projects/ui/src/Components/Common/Banner/BannerHeading.tsx index 1b6e55eb..1e6b3374 100644 --- a/projects/ui/src/Components/Common/Banner/BannerHeading.tsx +++ b/projects/ui/src/Components/Common/Banner/BannerHeading.tsx @@ -1,7 +1,8 @@ import { Box } from "@mantine/core"; -import { useContext } from "react"; +import { useContext, useMemo } from "react"; import Banner from "../../../Assets/banner@2x.webp"; import { AppContext } from "../../../Context/AppContext"; +import { bannerImageURL } from "../../../user_variables.tmplr"; import Breadcrumbs from "../Breadcrumbs"; import { BannerStyles as Styles } from "./BannerHeading.style"; @@ -24,6 +25,16 @@ export function BannerHeading({ }) { const { pageContentIsWide } = useContext(AppContext); + const bgImage = useMemo(() => { + if (!!bgImageURL) { + return bgImageURL; + } + if (!!bannerImageURL) { + return bannerImageURL; + } + return Banner; + }, [bgImageURL]); + return ( <> @@ -45,7 +56,7 @@ export function BannerHeading({ - background + background diff --git a/projects/ui/src/Components/Common/Button.tsx b/projects/ui/src/Components/Common/Button.tsx index ceea19b8..bab0ce78 100644 --- a/projects/ui/src/Components/Common/Button.tsx +++ b/projects/ui/src/Components/Common/Button.tsx @@ -38,10 +38,12 @@ const colorMap: { }, }; +export type ButtonVariant = "outline" | "subtle" | "light" | "filled"; + export function Button( props: { color?: "primary" | "success" | "warning" | "danger" | "secondary"; - variant?: "outline" | "subtle" | "light" | "filled"; + variant?: ButtonVariant; tabIndex?: number; onClick?: MouseEventHandler; /** diff --git a/projects/ui/src/Components/Common/DataPairPill.tsx b/projects/ui/src/Components/Common/DataPairPill.tsx index 763aacff..787cbc4c 100644 --- a/projects/ui/src/Components/Common/DataPairPill.tsx +++ b/projects/ui/src/Components/Common/DataPairPill.tsx @@ -36,7 +36,7 @@ const StyledDataPairPill = styled.div( ` ); -export type KeyValuePair = { pairKey: string; value: string }; +export type KeyValuePair = { pairKey: string; value: React.ReactNode | string }; export function DataPairPill({ pairKey, diff --git a/projects/ui/src/Components/Common/EmptyData.tsx b/projects/ui/src/Components/Common/EmptyData.tsx index d637102d..bde84dbe 100644 --- a/projects/ui/src/Components/Common/EmptyData.tsx +++ b/projects/ui/src/Components/Common/EmptyData.tsx @@ -1,28 +1,46 @@ -import { Flex, Text } from "@mantine/core"; +import { css } from "@emotion/react"; +import styled from "@emotion/styled"; +import { Box } from "@mantine/core"; +import { borderRadiusConstants } from "../../Styles/constants"; -type EmptyDataProps = - | { - topic: string; - message?: string; - } - | { - topicMessageOverride: string; - }; -export function EmptyData(props: EmptyDataProps) { +const StyledEmptyContentOuter = styled.div( + ({ theme }) => css` + display: flex; + justify-content: center; + text-align: center; + line-height: 2rem; + background-color: white; + box-shadow: 1px 1px 5px ${theme.splashBlue}; + border: 1px solid ${theme.splashBlue}; + border-radius: ${borderRadiusConstants.small}; + margin-bottom: 30px; + padding: 30px; + ` +); + +export const EmptyData = (props: { + children?: React.ReactNode; + title?: React.ReactNode; +}) => { return ( - - - {"topicMessageOverride" in props ? ( - <>{props.topicMessageOverride} - ) : ( - <>No {props.topic} results were found + + + {props.title && ( + + {props.title} + + )} + {!!props.children && ( + + {props.children} + )} - - {"message" in props && !!props.message && ( - - {props.message} - - )} - +
+ ); -} +}; diff --git a/projects/ui/src/Components/Common/MarkdownRenderer.tsx b/projects/ui/src/Components/Common/MarkdownRenderer.tsx new file mode 100644 index 00000000..b632d46a --- /dev/null +++ b/projects/ui/src/Components/Common/MarkdownRenderer.tsx @@ -0,0 +1,81 @@ +import { css } from "@emotion/react"; +import styled from "@emotion/styled"; +import hljs from "highlight.js"; +import { useEffect, useRef } from "react"; +import Markdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import { borderRadiusConstants } from "../../Styles/constants"; + +export const MarkdownOuterContainer = styled.div( + ({ theme }) => css` + padding: 30px; + * { + margin: revert; + padding: revert; + font-family: revert; + font-weight: revert; + } + blockquote p { + color: ${theme.augustGrey}; + } + pre:has(code) { + padding: 1rem 2rem; + border-radius: ${borderRadiusConstants.small}; + width: 100%; + background-color: #1c1b1b; + } + em { + font-style: italic; + } + a { + text-decoration: underline; + } + h1 { + font-size: 2rem; + } + h2 { + font-size: 1.7rem; + } + h3 { + font-size: 1.5rem; + } + h4 { + font-size: 1.2rem; + } + h5 { + font-size: 1rem; + } + ` +); + +const MarkdownRenderer = ({ markdown }: { markdown: string }) => { + const mdContainerRef = useRef(null); + + // Highlight the content when it's rendered. + useEffect(() => { + if (!markdown || !mdContainerRef.current) { + return; + } + // Highlight each code element. + // This is faster than doing `hljs.highlightAll()`. + const codeElements = mdContainerRef.current.querySelectorAll("code"); + for (let i = 0; i < codeElements.length; i++) { + hljs.highlightElement(codeElements[i]); + } + return () => { + // If this "data-highlighted" attribute isn't reset, it may not + // highlight the code correctly when the page is revisited. + for (let i = 0; i < codeElements.length; i++) { + codeElements[i]?.removeAttribute("data-highlighted"); + } + }; + }, [markdown, mdContainerRef.current]); + + return ( + + {markdown} + + ); +}; + +export default MarkdownRenderer; diff --git a/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionInfoCard/SubscriptionInfoCard.style.tsx b/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionInfoCard/SubscriptionInfoCard.style.tsx index 73fa529b..d1e9656a 100644 --- a/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionInfoCard/SubscriptionInfoCard.style.tsx +++ b/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionInfoCard/SubscriptionInfoCard.style.tsx @@ -11,12 +11,17 @@ import { export namespace SubscriptionInfoCardStyles { export const Card = styled.div<{ subscriptionState: SubscriptionState; + wide?: boolean; }>( - ({ theme, subscriptionState }) => css` - width: 428px; - ${mediaQueryWithScreenSize.mediumAndSmaller} { - width: 100%; - } + ({ theme, subscriptionState, wide }) => css` + ${!wide + ? css` + width: 428px; + ${mediaQueryWithScreenSize.mediumAndSmaller} { + width: 100%; + } + ` + : ""} border: 1px solid ${subscriptionStateMap[subscriptionState].borderColor}; border-radius: ${borderRadiusConstants.small}; background-color: white; diff --git a/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionInfoCard/SubscriptionInfoCard.tsx b/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionInfoCard/SubscriptionInfoCard.tsx index 762ebdcd..294e57d6 100644 --- a/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionInfoCard/SubscriptionInfoCard.tsx +++ b/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionInfoCard/SubscriptionInfoCard.tsx @@ -1,5 +1,5 @@ import { Flex } from "@mantine/core"; -import { useContext, useMemo } from "react"; +import { useMemo, useState } from "react"; import { di } from "react-magnetic-di"; import { Subscription } from "../../../../Apis/api-types"; import { @@ -8,8 +8,9 @@ import { useListTeams, } from "../../../../Apis/gg_hooks"; import { Icon } from "../../../../Assets/Icons"; -import { AuthContext } from "../../../../Context/AuthContext"; +import { useIsAdmin } from "../../../../Context/AuthContext"; import { CardStyles } from "../../../../Styles/shared/Card.style"; +import { MetadataDisplay } from "../../../../Utility/AdminUtility/MetadataDisplay"; import { FilterType } from "../../../../Utility/filter-utility"; import { getAppDetailsLink, @@ -37,8 +38,9 @@ const SubscriptionInfoCard = ({ subscription: Subscription; filters?: FiltrationProp; }) => { - di(useListTeams, useListAppsForTeams); - const { isAdmin } = useContext(AuthContext); + di(useListTeams, useListAppsForTeams, useIsAdmin); + const isAdmin = useIsAdmin(); + const [isWide, setIsWide] = useState(false); // // Get Team and App for Subscription @@ -150,7 +152,7 @@ const SubscriptionInfoCard = ({ return null; } return ( - + @@ -177,6 +179,12 @@ const SubscriptionInfoCard = ({ ItemIcon={Icon.TeamsIcon} item={teamOfAppThatSubscribed} /> + setIsWide(value)} + /> {isAdmin ? ( diff --git a/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionInfoCard/SubscriptionInfoCardAdminFooter.tsx b/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionInfoCard/SubscriptionInfoCardAdminFooter.tsx index fd9c5382..595ad44a 100644 --- a/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionInfoCard/SubscriptionInfoCardAdminFooter.tsx +++ b/projects/ui/src/Components/Common/SubscriptionsList/SubscriptionInfoCard/SubscriptionInfoCardAdminFooter.tsx @@ -19,10 +19,6 @@ const SubscriptionInfoCardAdminFooter = ({ const [showRejectSubModal, setShowRejectSubModal] = useState(false); const [showDeleteSubModal, setShowDeleteSubModal] = useState(false); - const canApproveRejectSubscription = - subscriptionState === SubscriptionState.PENDING; - const canDeleteSubscription = subscriptionState !== SubscriptionState.DELETED; - // // Render // @@ -34,7 +30,7 @@ const SubscriptionInfoCardAdminFooter = ({